Private
Public Access
1
0

重构:移除数据库客户端模块 v0.4.0(-17,885行,专注文件管理)

- 删除全部 MySQL/Redis/MongoDB 客户端代码(dbclient/api/service/storage)
- 清理 4 个驱动依赖(mysql/redis/mongo/gorm-mysql),构建体积 -10MB
- 前端移除 db-cli 整个目录(40 文件)+ 7 个 API/工具文件
- 版本号升级至 v0.4.0,顶部 Tab 仅保留文件管理
This commit is contained in:
2026-04-26 00:03:22 +08:00
parent 742581c5d6
commit 4f1d5f885f
92 changed files with 29 additions and 17889 deletions

View File

@@ -1,5 +1,19 @@
# 更新日志
## [0.4.0] - 2026-04-25
### 重构 🔧
- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理
- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖
- **构建体积优化**: 原始 exe 从 36MB 降至 26MBUPX 压缩后仅 7.5MB(压缩率 28.8%
### 变更说明
- 顶部 Tab 仅保留「文件管理」,移除数据库入口
- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响
- 本地 SQLite 配置存储AppConfig保留不变
---
## [0.3.4] - 2026-04-22
### 新增 ✨

164
app.go
View File

@@ -1,3 +1,6 @@
// [fs-only] 数据库客户端模块已移除feature/fs-only 分支)
// 保留模块:文件系统 | Markdown编辑器 | 版本历史(抽屉) | 系统信息 | 更新检查 | PDF导出
// 顶部Tab仅file-system数据库 db-cli 已删除)
package main
import (
@@ -23,9 +26,6 @@ import (
// App 应用结构体
type App struct {
ctx context.Context
connectionAPI *api.ConnectionAPI
sqlAPI *api.SqlAPI
tabAPI *api.TabAPI
updateAPI *api.UpdateAPI
configAPI *api.ConfigAPI
pdfAPI *api.PdfAPI
@@ -136,31 +136,6 @@ func (a *App) getVisibleTabs() []string {
// initModulesByConfig 根据配置初始化模块
func (a *App) initModulesByConfig(visibleTabs []string) error {
// 检查是否启用数据库模块
if common.Contains(visibleTabs, common.TabDatabase) {
fmt.Println("[启动] 初始化数据库模块...")
var err error
// 初始化 ConnectionAPI
if a.connectionAPI, err = api.NewConnectionAPI(); err != nil {
return err
}
// 初始化 SqlAPI
if a.sqlAPI, err = api.NewSqlAPI(); err != nil {
return err
}
// 初始化 TabAPI
if a.tabAPI, err = api.NewTabAPI(); err != nil {
return err
}
fmt.Println("[启动] 数据库模块初始化完成")
} else {
fmt.Println("[启动] 跳过数据库模块(未启用)")
}
// 检查是否启用文件系统模块
if common.Contains(visibleTabs, common.TabFileSystem) {
fmt.Println("[启动] 初始化文件系统模块...")
@@ -439,94 +414,6 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
return paths, nil
}
// ========== 数据库连接管理接口 ==========
// 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)
}
// LoadAllDatabases 加载全部数据库列表
func (a *App) LoadAllDatabases(req api.LoadAllDatabasesRequest) ([]string, error) {
return a.connectionAPI.LoadAllDatabases(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 {
@@ -587,18 +474,6 @@ func (a *App) WindowToggleAlwaysOnTop() bool {
return a.isAlwaysOnTop
}
// ========== 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()
}
// ========== 版本更新管理接口 ==========
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
@@ -862,8 +737,6 @@ func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
for _, tab := range newlyEnabled {
switch tab {
case common.TabDatabase:
a.initDatabaseModule()
case common.TabFileSystem:
a.initFilesystemModule()
case common.TabDevice:
@@ -872,37 +745,6 @@ func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
}
}
// initDatabaseModule 延迟初始化数据库模块
func (a *App) initDatabaseModule() {
if a.connectionAPI != nil {
fmt.Println("[模块] 数据库模块已初始化,跳过")
return
}
fmt.Println("[模块] 延迟初始化数据库模块...")
var err error
// 初始化 ConnectionAPI
if a.connectionAPI, err = api.NewConnectionAPI(); err != nil {
fmt.Printf("[模块] 数据库模块初始化失败: %v\n", err)
return
}
// 初始化 SqlAPI
if a.sqlAPI, err = api.NewSqlAPI(); err != nil {
fmt.Printf("[模块] SqlAPI 初始化失败: %v\n", err)
return
}
// 初始化 TabAPI
if a.tabAPI, err = api.NewTabAPI(); err != nil {
fmt.Printf("[模块] TabAPI 初始化失败: %v\n", err)
return
}
fmt.Println("[模块] 数据库模块初始化完成")
}
// initFilesystemModule 延迟初始化文件系统模块
func (a *App) initFilesystemModule() {
if a.filesystem != nil {

View File

@@ -1 +1 @@
{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- Markdown 编辑器: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- PDF 导出: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- 窗口置顶 + 收藏夹置顶\n- Excel/Word 文件预览支持\n- 数据库 UI 大幅改进: 查询历史、查询模板、SQL 工具栏、结果导出\n- 数据库可见性过滤与连接管理增强\n\n### 优化 🚀\n- MySQL 动态连接池重构(健康检查、性能权重、自适应扩缩容)\n- SQL 查询优化器(查询缓存、慢查询日志)\n- Redis Pipeline 支持\n- Wails 框架升级 + FileListPanel 重写\n- CSV 编辑模式优化 + 拷贝功能优化\n\n### 修复 🐛\n- Office 类型检测修复、CORS 跨域修复、大文件卡死修复\n\n### 安全修复 🔒\n- XSS 防护、PDF 路径穿越防护、HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化、大规模死代码清理(-1306行)", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c", "force_update": false}
{"version": "0.4.0", "download_url": "https://c.1216.top/download/u-desk-0.4.0.exe", "changelog": "### 重构 🔧\n- 移除数据库客户端模块:删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- 清理依赖:移除 mysql/redis/mongo 驱动依赖\n- 构建体积优化:原始 exe 26MBUPX 压缩后 7.5MB(压缩率 28.8%\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响", "force_update": false, "release_date": "2026-04-25", "file_size": 7766016}

View File

@@ -1 +1 @@
{"updated_at": "2026-04-13T23:45:00+08:00", "versions": [{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c"}, {"version": "0.3.2", "release_date": "2026-02-05", "changelog": "### 重构 🔧\n- CodeMirror 架构优化 - 统一导出避免多实例问题\n- 语言加载器优化 - 从动态 import 改为静态导入\n- 动态主题切换 - 使用 Compartment 实现无损切换\n\n### 优化 🚀\n- 编辑器性能 - 添加内容更新防抖\n- 亮色主题 - 改进代码编辑器亮色模式样式", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.3.0", "release_date": "2026-02-04", "changelog": "### 新增 ✨\n- Markdown 图表支持 - Mermaid 流程图、时序图、类图等\n- 代码语法高亮 - 支持 20+ 种常用编程语言\n- 文件列表优化 - 文件夹优先显示,同类型按名称排序", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.2.0", "release_date": "2026-01-28", "changelog": "### 新增 ✨\n- 应用配置管理 - 全新设置面板,支持自定义显示模块和默认启动页\n- 智能更新提醒 - 新增版本更新通知组件\n- 模块重命名 - 应用更名为 u-desk", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.5", "release_date": "2026-01-22", "changelog": "### 新增 ✨\n- 文件管理模块 - 文件浏览、编辑、操作功能\n- 版本更新管理 - 自动检查和下载更新\n- 系统信息查询 - CPU、内存、磁盘等硬件信息", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.0", "release_date": "2026-01-18", "changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能", "download_url": "", "file_size": 0, "sha256": ""}]}
{"updated_at": "2026-04-25T23:58:00+08:00", "versions": [{"version": "0.4.0", "release_date": "2026-04-25", "changelog": "### 重构 🔧\n- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖\n- **构建体积优化**: 原始 exe 从 36MB 降至 26MBUPX 压缩后仅 7.5MB(压缩率 28.8%\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响\n- 本地 SQLite 配置存储AppConfig保留不变", "download_url": "https://c.1216.top/download/u-desk-0.4.0.exe", "file_size": 7766016, "sha256": "532c30bdc57ea0ff5bc71756714b7ca18388ad3e09b2c4eefcdb6816349c7dda"}, {"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c"}, {"version": "0.3.2", "release_date": "2026-02-05", "changelog": "### 重构 🔧\n- CodeMirror 架构优化 - 统一导出避免多实例问题\n- 语言加载器优化 - 从动态 import 改为静态导入\n- 动态主题切换 - 使用 Compartment 实现无损切换\n\n### 优化 🚀\n- 编辑器性能 - 添加内容更新防抖\n- 亮色主题 - 改进代码编辑器亮色模式样式", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.3.0", "release_date": "2026-02-04", "changelog": "### 新增 ✨\n- Markdown 图表支持 - Mermaid 流程图、时序图、类图等\n- 代码语法高亮 - 支持 20+ 种常用编程语言\n- 文件列表优化 - 文件夹优先显示,同类型按名称排序", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.2.0", "release_date": "2026-01-28", "changelog": "### 新增 ✨\n- 应用配置管理 - 全新设置面板,支持自定义显示模块和默认启动页\n- 智能更新提醒 - 新增版本更新通知组件\n- 模块重命名 - 应用更名为 u-desk", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.5", "release_date": "2026-01-22", "changelog": "### 新增 ✨\n- 文件管理模块 - 文件浏览、编辑、操作功能\n- 版本更新管理 - 自动检查和下载更新\n- 系统信息查询 - CPU、内存、磁盘等硬件信息", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.0", "release_date": "2026-01-18", "changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能", "download_url": "", "file_size": 0, "sha256": ""}]}

View File

@@ -1,73 +0,0 @@
package main
import (
"fmt"
"log"
"u-desk/internal/storage"
"u-desk/internal/storage/models"
)
func main() {
// 初始化数据库
db, err := storage.Init()
if err != nil {
log.Fatalf("数据库初始化失败: %v", err)
}
fmt.Println("=== 数据库连接配置调试工具 ===")
fmt.Println()
// 列出所有连接
var connections []models.DbConnection
result := db.Order("id").Find(&connections)
if result.Error != nil {
log.Fatalf("查询失败: %v", result.Error)
}
fmt.Printf("当前有 %d 个连接配置:\n", len(connections))
fmt.Println()
for _, conn := range connections {
fmt.Printf("ID: %d\n", conn.ID)
fmt.Printf(" 名称: %s\n", conn.Name)
fmt.Printf(" 类型: %s\n", conn.Type)
fmt.Printf(" 主机: %s:%d\n", conn.Host, conn.Port)
fmt.Printf(" 用户名: %s\n", conn.Username)
fmt.Printf(" 创建时间: %s\n", conn.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Println()
}
// 询问用户操作
var choice int
fmt.Print("请选择操作:\n")
fmt.Print("1. 删除指定 ID 的连接\n")
fmt.Print("2. 列出连接详情\n")
fmt.Print("0. 退出\n")
fmt.Print("请输入: ")
fmt.Scanln(&choice)
if choice == 1 {
var id uint
fmt.Print("请输入要删除的连接 ID: ")
fmt.Scanln(&id)
// 确认
var confirm string
fmt.Printf("确认删除 ID=%d 的连接吗?(y/N): ", id)
fmt.Scanln(&confirm)
if confirm == "y" || confirm == "Y" {
result := db.Delete(&models.DbConnection{}, id)
if result.Error != nil {
log.Printf("删除失败: %v", result.Error)
} else {
fmt.Printf("删除成功!影响行数: %d\n", result.RowsAffected)
}
} else {
fmt.Println("已取消删除")
}
}
fmt.Println("\n工具退出")
}

13
go.mod
View File

@@ -6,24 +6,17 @@ require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/glebarez/sqlite v1.11.0
github.com/go-sql-driver/mysql v1.9.3
github.com/redis/go-redis/v9 v9.17.3
github.com/shirou/gopsutil/v3 v3.24.5
github.com/wailsapp/wails/v2 v2.12.0
github.com/yuin/goldmark v1.8.2
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/sys v0.40.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.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.22.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
@@ -37,7 +30,6 @@ require (
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/labstack/echo/v4 v4.15.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
@@ -62,15 +54,10 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // 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.47.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect

49
go.sum
View File

@@ -1,15 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
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/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
@@ -18,8 +10,6 @@ github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipw
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
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.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -31,8 +21,6 @@ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:
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=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
@@ -57,8 +45,6 @@ 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.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -96,8 +82,6 @@ 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-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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=
@@ -129,72 +113,39 @@ 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.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
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.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
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/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
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/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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=
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.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

View File

@@ -1,128 +0,0 @@
package api
import (
"u-desk/internal/service"
"u-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"`
VisibleDatabases string `json:"visible_databases"`
}
// 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,
VisibleDatabases: req.VisibleDatabases,
}
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,
"visible_databases": conn.VisibleDatabases,
"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,
)
}
// LoadAllDatabasesRequest 加载全部数据库请求结构体
type LoadAllDatabasesRequest struct {
ID uint `json:"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"`
}
// LoadAllDatabases 加载全部数据库列表
func (api *ConnectionAPI) LoadAllDatabases(req LoadAllDatabasesRequest) ([]string, error) {
return api.connService.LoadAllDatabases(
req.Type, req.Host, req.Port,
req.Username, req.Password, req.Database,
req.Options, req.ID,
)
}

View File

@@ -1,137 +0,0 @@
package api
import (
"encoding/json"
"u-desk/internal/service"
"u-desk/internal/storage/models"
"u-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
}

View File

@@ -1,79 +0,0 @@
package api
import (
"fmt"
"u-desk/internal/service"
"u-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
}

View File

@@ -2,8 +2,6 @@ package common
// Default visible tabs configuration
const (
// TabDatabase 数据库管理 Tab
TabDatabase = "db-cli"
// TabFileSystem 文件系统 Tab
TabFileSystem = "file-system"
// TabDevice 设备测试 Tab
@@ -11,4 +9,4 @@ const (
)
// DefaultVisibleTabs 默认可见的 Tabs
var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}
var DefaultVisibleTabs = []string{TabFileSystem, TabDevice}

View File

@@ -1,12 +0,0 @@
package common
import "time"
// 数据库操作超时配置
const (
TimeoutPing = 2 * time.Second // 连接测试超时
TimeoutConnect = 5 * time.Second // 初始连接超时
TimeoutFastQuery = 10 * time.Second // 元数据查询超时
TimeoutQuery = 30 * time.Second // 普通查询超时
TimeoutLongOp = 60 * time.Second // 长时间操作超时
)

View File

@@ -1,175 +0,0 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"sync"
)
// 旧版硬编码密钥(用于兼容迁移已有加密数据)
var legacyKey = []byte("go-desk-db-cli-key-32bytes123456")
var (
encryptionKey []byte
keyOnce sync.Once
keyInitErr error
)
// getKey 获取或创建机器唯一密钥
// 首次启动时生成并持久化到用户配置目录,后续直接读取
func getKey() ([]byte, error) {
keyOnce.Do(func() {
keyFile, err := getKeyFilePath()
if err != nil {
keyInitErr = fmt.Errorf("获取密钥路径失败: %v", err)
return
}
// 尝试读取已有密钥
if data, err := os.ReadFile(keyFile); err == nil && len(data) == 32 {
encryptionKey = data
return
}
// 生成新密钥
newKey := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, newKey); err != nil {
keyInitErr = fmt.Errorf("生成密钥失败: %v", err)
return
}
// 持久化密钥
dir := filepath.Dir(keyFile)
if err := os.MkdirAll(dir, 0700); err != nil {
keyInitErr = fmt.Errorf("创建密钥目录失败: %v", err)
return
}
if err := os.WriteFile(keyFile, newKey, 0600); err != nil {
keyInitErr = fmt.Errorf("保存密钥失败: %v", err)
return
}
encryptionKey = newKey
})
return encryptionKey, keyInitErr
}
// getKeyFilePath 返回密钥文件路径
func getKeyFilePath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "u-desk", ".aes-key"), nil
}
// DecryptPasswordV2 使用指定密钥解密(用于密钥迁移)
func DecryptPasswordV2(encryptedPassword string, key []byte) (string, error) {
if encryptedPassword == "" {
return "", nil
}
if len(encryptedPassword) < 10 {
return "", nil
}
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
if err != nil {
return "", fmt.Errorf("解码失败: %v", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("创建解密器失败: %v", err)
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("创建 GCM 失败: %v", err)
}
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
}
// EncryptPassword 加密密码
func EncryptPassword(password string) (string, error) {
if password == "" {
return "", nil
}
key, err := getKey()
if err != nil {
return "", fmt.Errorf("获取加密密钥失败: %v", err)
}
block, err := aes.NewCipher(key)
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
}
key, err := getKey()
if err != nil {
return "", fmt.Errorf("获取解密密钥失败: %v", err)
}
// 先用新密钥尝试解密
result, err := DecryptPasswordV2(encryptedPassword, key)
if err == nil {
return result, nil
}
// 新密钥失败,尝试旧密钥(兼容已迁移的旧数据)
result, err = DecryptPasswordV2(encryptedPassword, legacyKey)
if err == nil {
return result, nil
}
// 两种密钥都失败
return "", fmt.Errorf("解密失败: %v", err)
}

View File

@@ -1,138 +0,0 @@
package database
import (
"errors"
"fmt"
"u-desk/internal/model"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var (
ErrNotConnected = errors.New("数据库未连接")
)
// DB 数据库连接封装
type DB struct {
db *gorm.DB
}
var globalDB *DB
// Init 初始化数据库连接
func Init() (*DB, error) {
if globalDB != nil {
return globalDB, nil
}
// 数据库配置 - 测试服 lab_dev
// 测试机外网IP: 39.99.243.191
// 使用 mysqldriver.Config 结构体构建 DSN自动处理密码中的特殊字符
config := mysqldriver.Config{
User: "root",
Passwd: "123456",
Net: "tcp",
Addr: "127.0.0.1:3306",
DBName: "lab_dev",
Params: map[string]string{"charset": "utf8mb4", "parseTime": "True", "loc": "Local"},
AllowNativePasswords: true,
}
dsn := config.FormatDSN()
// GORM 配置
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
db, err := gorm.Open(mysql.Open(dsn), gormConfig)
if err != nil {
return nil, fmt.Errorf("打开数据库连接失败: %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("数据库连接测试失败: %v", err)
}
// 设置连接池参数
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(time.Duration(300) * time.Second)
globalDB = &DB{db: db}
return globalDB, nil
}
// QueryUsers 查询用户列表
func (d *DB) QueryUsers(keyword string, status int, role int, organid int, page int, pageSize int, sortField string, sortOrder string) (map[string]interface{}, error) {
if d.db == nil {
return nil, ErrNotConnected
}
query := d.db.Model(&model.MemberInfo{})
// 关键字搜索(姓名、账号、电话)
if keyword != "" {
query = query.Where("membername LIKE ? OR account LIKE ? OR contactphone LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
}
// 状态筛选
if status > 0 {
query = query.Where("status = ?", status)
} else {
// 默认过滤删除状态
query = query.Where("status != ?", 3)
}
// 角色筛选(需要关联查询,暂时简化)
if role > 0 {
// TODO: 关联 sys_member_role 表查询
}
// 机构筛选
if organid > 0 {
query = query.Where("organid = ?", organid)
}
// 排序
if sortField != "" {
if sortOrder == "descend" || sortOrder == "desc" {
query = query.Order(sortField + " DESC")
} else {
query = query.Order(sortField + " ASC")
}
} else {
// 默认按创建时间倒序
query = query.Order("createtime DESC")
}
// 总数
var total int64
query.Count(&total)
// 分页
offset := (page - 1) * pageSize
var users []model.MemberInfo
if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, fmt.Errorf("查询用户失败: %v", err)
}
// 返回结果
result := map[string]interface{}{
"rows": users,
"total": total,
}
return result, nil
}

View File

@@ -1,479 +0,0 @@
package dbclient
import (
"crypto/sha256"
"fmt"
"sync"
"time"
)
// QueryCache 查询缓存
type QueryCache struct {
items map[string]*CachedQuery
size int
ttl time.Duration
mu sync.RWMutex
stopCh chan struct{}
wg sync.WaitGroup
// 智能缓存策略
hitRate float64 // 缓存命中率
hitCount int64 // 命中次数
missCount int64 // 未命中次数
evictionCount int64 // 驱逐次数
hotQueries map[string]bool // 热点查询标记
cooldowns map[string]time.Time // 冷却时间(避免频繁驱逐)
// 内存限制
maxMemoryBytes int64 // 缓存最大内存(字节),默认 100MB
usedMemory int64 // 当前估算内存使用量
}
// NewQueryCache 创建新的查询缓存
func NewQueryCache(size int, ttl time.Duration) *QueryCache {
cache := &QueryCache{
items: make(map[string]*CachedQuery),
size: size,
ttl: ttl,
stopCh: make(chan struct{}),
hitRate: 0.0,
hitCount: 0,
missCount: 0,
evictionCount: 0,
hotQueries: make(map[string]bool),
cooldowns: make(map[string]time.Time),
maxMemoryBytes: 100 * 1024 * 1024, // 默认 100MB
}
// 启动清理协程
cache.StartCleanup()
// 启动统计协程
cache.StartStatsCollection()
return cache
}
// Get 从缓存中获取查询结果
func (c *QueryCache) Get(params QueryParams) (*CachedQuery, error) {
key := c.generateKey(params)
c.mu.RLock()
item, exists := c.items[key]
if !exists {
c.missCount++
_, inCooldown := c.cooldowns[key]
if inCooldown && time.Now().Before(c.cooldowns[key]) {
c.mu.RUnlock()
return nil, ErrCacheCooldown
}
c.mu.RUnlock()
return nil, ErrCacheNotFound
}
// 检查是否过期
if time.Now().After(item.ExpiryTime) {
if c.isHotQuery(key) {
c.mu.RUnlock()
c.mu.Lock()
item.ExpiryTime = time.Now().Add(c.ttl)
c.hitCount++
c.markAsHot(key)
c.mu.Unlock()
return item, nil
}
c.mu.RUnlock()
c.mu.Lock()
delete(c.items, key)
c.evictionCount++
c.missCount++
c.mu.Unlock()
return nil, ErrCacheExpired
}
// 命中
c.hitCount++
needsMark := !c.hotQueries[key]
c.mu.RUnlock()
if needsMark {
c.mu.Lock()
c.markAsHot(key)
c.mu.Unlock()
}
return item, nil
}
// Set 将查询结果存入缓存
func (c *QueryCache) Set(params QueryParams, item *CachedQuery) {
key := c.generateKey(params)
// 估算条目内存大小
itemSize := c.estimateSize(params, item)
c.mu.Lock()
defer c.mu.Unlock()
// 更新统计
c.recordQueryAttempt(key)
// 如果超过内存限制,执行驱逐直到有空间
for c.usedMemory+itemSize > c.maxMemoryBytes && len(c.items) > 0 {
c.smartEvict(key)
}
// 如果条目数已满,执行智能驱逐
if len(c.items) >= c.size {
c.smartEvict(key)
}
// 如果已有旧条目,先减去旧的大小
if old, exists := c.items[key]; exists {
c.usedMemory -= c.estimateItemSize(old)
}
c.items[key] = item
c.usedMemory += itemSize
// 标记为热点查询
c.markAsHot(key)
}
// smartEvict 智能驱逐策略
func (c *QueryCache) smartEvict(newKey string) {
if len(c.items) == 0 {
return
}
// LRU + LFU 混合策略
var evictKey string
var worstScore float64 = -1
for key, item := range c.items {
if key == newKey {
continue
}
score := c.calculateEvictionScore(key, item)
if score > worstScore {
worstScore = score
evictKey = key
}
}
if evictKey != "" {
if evicted, exists := c.items[evictKey]; exists {
c.usedMemory -= c.estimateItemSize(evicted)
}
c.cooldowns[evictKey] = time.Now().Add(1 * time.Minute)
delete(c.items, evictKey)
c.evictionCount++
}
}
// calculateEvictionScore 计算驱逐分数(越低越适合保留)
func (c *QueryCache) calculateEvictionScore(key string, item *CachedQuery) float64 {
now := time.Now()
// 基础分数
score := 1.0
// 热点查询加分(优先保留)
if c.isHotQuery(key) {
score -= 0.5
}
// 接近过期的加分(优先驱逐即将过期的)
if item.ExpiryTime.Sub(now) < c.ttl/2 {
score += 0.3
}
// 最近使用的加分(优先保留最近使用的)
if !item.LastUsed.IsZero() {
recency := now.Sub(item.LastUsed)
if recency < 5*time.Minute {
score -= 0.2
}
}
return score
}
// isHotQuery 检查是否为热点查询
func (c *QueryCache) isHotQuery(key string) bool {
return c.hotQueries[key]
}
// markAsHot 标记为热点查询
func (c *QueryCache) markAsHot(key string) {
c.hotQueries[key] = true
}
// cleanupHotMarkers 清理热点标记
func (c *QueryCache) cleanupHotMarkers() {
now := time.Now()
for key := range c.hotQueries {
// 清理超过10分钟未使用的热点标记
if item, exists := c.items[key]; exists {
if now.Sub(item.LastUsed) > 10*time.Minute {
delete(c.hotQueries, key)
}
} else {
delete(c.hotQueries, key)
}
}
}
// recordQueryAttempt 记录查询尝试
func (c *QueryCache) recordQueryAttempt(key string) {
// 更新命中率
c.updateHitRate()
// 更新最后使用时间
if item, exists := c.items[key]; exists {
item.LastUsed = time.Now()
}
}
// updateHitRate 更新命中率
func (c *QueryCache) updateHitRate() {
total := c.hitCount + c.missCount
if total > 0 {
c.hitRate = float64(c.hitCount) / float64(total)
}
}
// Delete 从缓存中删除指定查询
func (c *QueryCache) Delete(params QueryParams) {
key := c.generateKey(params)
c.mu.Lock()
defer c.mu.Unlock()
if item, exists := c.items[key]; exists {
c.usedMemory -= c.estimateItemSize(item)
delete(c.items, key)
}
}
// Clear 清空整个缓存
func (c *QueryCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = make(map[string]*CachedQuery)
c.usedMemory = 0
}
// Size 获取缓存大小
func (c *QueryCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
// CleanupExpired 清理过期的缓存条目
func (c *QueryCache) CleanupExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, item := range c.items {
if now.After(item.ExpiryTime) {
c.usedMemory -= c.estimateItemSize(item)
delete(c.items, key)
}
}
}
// Keys 获取缓存中所有的键
func (c *QueryCache) Keys() []string {
c.mu.RLock()
defer c.mu.RUnlock()
keys := make([]string, 0, len(c.items))
for key := range c.items {
keys = append(keys, key)
}
return keys
}
// Stats 获取缓存统计信息
func (c *QueryCache) Stats() CacheStats {
c.mu.RLock()
defer c.mu.RUnlock()
now := time.Now()
expired := 0
active := 0
for _, item := range c.items {
if now.After(item.ExpiryTime) {
expired++
} else {
active++
}
}
return CacheStats{
TotalItems: len(c.items),
ActiveItems: active,
ExpiredItems: expired,
Size: c.size,
TTL: c.ttl,
HitRate: c.hitRate,
HitCount: c.hitCount,
MissCount: c.missCount,
EvictionCount: c.evictionCount,
HotQueries: len(c.hotQueries),
}
}
// generateKey 生成缓存键
func (c *QueryCache) generateKey(params QueryParams) string {
key := fmt.Sprintf("%s|%s|%d|%d|%s|%s|%s|%v",
params.SQL, params.Database, params.Limit, params.Offset,
params.Table, params.Where, params.SortBy, params.IsReadOnly)
h := sha256.Sum256([]byte(key))
return fmt.Sprintf("%x", h)
}
// evictOldest 删除最老的缓存条目
func (c *QueryCache) evictOldest() {
var oldestKey string
var oldestTime time.Time
for key, item := range c.items {
if oldestKey == "" || item.CreatedAt.Before(oldestTime) {
oldestKey = key
oldestTime = item.CreatedAt
}
}
if oldestKey != "" {
delete(c.items, oldestKey)
}
}
// StartCleanup 启动清理协程
func (c *QueryCache) StartCleanup() {
c.wg.Add(1)
go func() {
defer c.wg.Done()
ticker := time.NewTicker(c.ttl / 2) // 每 TTL/2 时间检查一次
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.CleanupExpired()
c.cleanupCooldowns() // 清理冷却时间
case <-c.stopCh:
return
}
}
}()
}
// StartStatsCollection 启动统计收集协程
func (c *QueryCache) StartStatsCollection() {
c.wg.Add(1)
go func() {
defer c.wg.Done()
ticker := time.NewTicker(1 * time.Minute) // 每分钟收集一次统计
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.updateHitRate()
c.cleanupHotMarkers()
case <-c.stopCh:
return
}
}
}()
}
// cleanupCooldowns 清理冷却时间
func (c *QueryCache) cleanupCooldowns() {
now := time.Now()
for key, cooldown := range c.cooldowns {
if now.After(cooldown) {
delete(c.cooldowns, key)
}
}
}
// Stop 停止缓存清理
func (c *QueryCache) Stop() {
close(c.stopCh)
c.wg.Wait()
}
// CacheStats 缓存统计信息
type CacheStats struct {
TotalItems int
ActiveItems int
ExpiredItems int
Size int
TTL time.Duration
HitRate float64
HitCount int64
MissCount int64
EvictionCount int64
HotQueries int
}
// 缓存错误定义
var (
ErrCacheNotFound = &CacheError{Message: "缓存未找到"}
ErrCacheExpired = &CacheError{Message: "缓存已过期"}
ErrCacheCooldown = &CacheError{Message: "查询在冷却中"}
)
// CacheError 缓存错误
type CacheError struct {
Message string
}
func (e *CacheError) Error() string {
return e.Message
}
// estimateSize 估算缓存条目的内存大小(字节)
func (c *QueryCache) estimateSize(params QueryParams, item *CachedQuery) int64 {
size := int64(len(params.SQL) + len(params.Database) + len(params.Table) +
len(params.Where) + len(params.SortBy))
if item != nil && item.Result != nil {
size += c.estimateItemSize(item)
}
return size
}
// estimateItemSize 估算 CachedQuery 的内存大小
func (c *QueryCache) estimateItemSize(item *CachedQuery) int64 {
if item == nil || item.Result == nil {
return 128 // 基础结构体大小
}
size := int64(128) // CachedQuery 结构体基础大小
for _, row := range item.Result.Data {
for _, v := range row {
switch val := v.(type) {
case string:
size += int64(len(val))
case []byte:
size += int64(len(val))
case nil:
// 无额外开销
default:
size += 64 // 其他类型的估算值
}
}
}
size += int64(len(item.Result.Columns)) * 64 // 列名估算
return size
}

View File

@@ -1,825 +0,0 @@
package dbclient
import (
"context"
"fmt"
"net/url"
"u-desk/internal/common"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/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(common.TimeoutConnect).
SetServerSelectionTimeout(common.TimeoutConnect)
// 创建客户端 (v2: 移除了 context 参数)
client, err := mongo.Connect(clientOptions)
// 创建 context 用于其他操作
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutConnect)
defer cancel()
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(), common.TimeoutConnect)
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
}
// 构建索引选项,并跟踪 unique 状态v2: IndexOptionsBuilder 无 Unique 字段可读)
indexOptions := options.Index()
indexOptions.SetName(name)
isUnique := false
if unique, ok := idx["unique"].(bool); ok && unique {
indexOptions.SetUnique(true)
isUnique = true
} else if nonUnique, ok := idx["Non_unique"].(float64); ok && nonUnique == 0 {
indexOptions.SetUnique(true)
isUnique = 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 isUnique {
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] {
// v2: DropOne 只返回 error不再返回 bson.Raw
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] {
// v2: DropOne 只返回 error不再返回 bson.Raw
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
}

View File

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

View File

@@ -1,393 +0,0 @@
package dbclient
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"u-desk/internal/common"
"u-desk/internal/crypto"
"u-desk/internal/storage/models"
)
// ConnectionPool 连接池管理器
type ConnectionPool struct {
mysqlClients map[uint]*MySQLClient
redisClients map[uint]*RedisClient
mongoClients map[uint]*MongoClient
// 新增MySQL 真连接池
mysqlPool *MySQLConnectionPool
// 查询优化器
queryOptimizer *QueryOptimizer
mu sync.RWMutex
}
var (
globalPool *ConnectionPool
poolOnce sync.Once
)
// GetPool 获取全局连接池实例
func GetPool() *ConnectionPool {
poolOnce.Do(func() {
// 创建 MySQL 连接池
poolConfig := DefaultPoolConfig()
mysqlPool := NewMySQLConnectionPool(poolConfig)
// 启动维护协程
mysqlPool.StartMaintenance()
// 创建查询优化器
queryOptimizer := NewQueryOptimizer(nil)
globalPool = &ConnectionPool{
mysqlClients: make(map[uint]*MySQLClient),
redisClients: make(map[uint]*RedisClient),
mongoClients: make(map[uint]*MongoClient),
mysqlPool: mysqlPool,
queryOptimizer: queryOptimizer,
}
})
return globalPool
}
// PooledClient 带释放语义的客户端包装
type PooledClient struct {
Client *MySQLClient
entry *MySQLPoolEntry
pool *MySQLConnectionPool
fromPool bool
}
// Release 释放连接回连接池
func (pc *PooledClient) Release() {
if pc.fromPool && pc.pool != nil && pc.entry != nil {
pc.pool.Release(pc.entry)
}
}
// GetMySQLClient 获取或创建 MySQL 客户端(使用连接池)
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) *PooledClient {
p.mu.Lock()
defer p.mu.Unlock()
// 尝试从连接池获取连接
if p.mysqlPool != nil {
entry, err := p.mysqlPool.Acquire(conn)
if err == nil {
return &PooledClient{Client: entry.Client, entry: entry, pool: p.mysqlPool, fromPool: true}
}
p.logPoolError("Acquire failed", err)
}
// 降级到原有逻辑
client, err := p.getMySQLClientLegacy(conn)
if err != nil {
return &PooledClient{Client: nil, fromPool: false}
}
return &PooledClient{Client: client, fromPool: false}
}
// logPoolError 记录连接池错误
func (p *ConnectionPool) logPoolError(operation string, err error) {
if p.queryOptimizer != nil {
// 通过查询优化器记录错误
p.queryOptimizer.RecordPoolError(operation, err)
}
}
// getMySQLClientLegacy 原有的 MySQL 客户端获取逻辑(向后兼容)
func (p *ConnectionPool) getMySQLClientLegacy(conn *models.DbConnection) (*MySQLClient, error) {
// 检查是否已存在
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
}
// GetMySQLPoolStats 获取 MySQL 连接池统计信息
func (p *ConnectionPool) GetMySQLPoolStats() *PoolStats {
if p.mysqlPool != nil {
stats := p.mysqlPool.Stats()
return &stats
}
return nil
}
// OptimizeQuery 优化查询执行
func (p *ConnectionPool) OptimizeQuery(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (*QueryResult, time.Duration, error) {
pc := p.GetMySQLClient(conn)
if pc.Client == nil {
return nil, 0, fmt.Errorf("获取 MySQL 连接失败")
}
defer pc.Release()
// 使用查询优化器
if p.queryOptimizer != nil {
return p.queryOptimizer.OptimizeQuery(ctx, pc.Client, sqlStr, database)
}
// 降级到普通查询
startTime := time.Now()
result, err := pc.Client.ExecuteQuery(ctx, sqlStr, database)
duration := time.Since(startTime)
return result, duration, err
}
// ExecuteOptimizedUpdate 执行优化的更新操作
func (p *ConnectionPool) ExecuteOptimizedUpdate(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (int64, time.Duration, error) {
pc := p.GetMySQLClient(conn)
if pc.Client == nil {
return 0, 0, fmt.Errorf("获取 MySQL 连接失败")
}
defer pc.Release()
// 使用查询优化器
if p.queryOptimizer != nil {
return p.queryOptimizer.ExecuteOptimizedUpdate(ctx, pc.Client, sqlStr, database)
}
// 降级到普通更新
startTime := time.Now()
result, err := pc.Client.ExecuteUpdate(ctx, sqlStr, database)
duration := time.Since(startTime)
return result, duration, err
}
// GetQueryStats 获取查询统计信息
func (p *ConnectionPool) GetQueryStats() QueryStats {
if p.queryOptimizer != nil {
return p.queryOptimizer.GetQueryStats()
}
return QueryStats{}
}
// GetSlowQueries 获取慢查询记录
func (p *ConnectionPool) GetSlowQueries(limit int) []SlowQuery {
if p.queryOptimizer != nil {
return p.queryOptimizer.GetSlowQueries(limit)
}
return []SlowQuery{}
}
// GetIndexSuggestions 获取索引建议
func (p *ConnectionPool) GetIndexSuggestions(table string) []IndexSuggestion {
if p.queryOptimizer != nil {
return p.queryOptimizer.GetIndexSuggestions(table)
}
return []IndexSuggestion{}
}
// GenerateIndexSuggestions 为表生成索引建议
func (p *ConnectionPool) GenerateIndexSuggestions(ctx context.Context, conn *models.DbConnection, database, table string) error {
pc := p.GetMySQLClient(conn)
if pc.Client == nil {
return fmt.Errorf("获取 MySQL 连接失败")
}
defer pc.Release()
// 使用查询优化器
if p.queryOptimizer != nil {
return p.queryOptimizer.GenerateIndexSuggestions(ctx, pc.Client, database, table)
}
return nil
}
// ClearQueryCache 清空查询缓存
func (p *ConnectionPool) ClearQueryCache() {
if p.queryOptimizer != nil {
p.queryOptimizer.ClearCache()
}
}
// 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(), common.TimeoutPing)
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(), common.TimeoutPing)
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)
}

View File

@@ -1,679 +0,0 @@
package dbclient
import (
"context"
"fmt"
"sync"
"time"
"u-desk/internal/crypto"
"u-desk/internal/storage/models"
)
// PoolConfig 连接池配置
type PoolConfig struct {
// 最大打开连接数(硬上限)
MaxOpenConns int
// 最大空闲连接数(超过此数量的空闲连接会被关闭)
MaxIdleConns int
// 连接最大生命周期(超过此时间的连接会被关闭)
ConnMaxLifetime time.Duration
// 连接最大空闲时间(超过此时间未使用的连接会被关闭)
ConnMaxIdleTime time.Duration
// 最小空闲连接数(保持此数量的空闲连接以快速响应)
MinIdleConns int
// 连接超时时间(建立连接的最长时间)
ConnTimeout time.Duration
// 健康检查间隔(定期 Ping 连接检查有效性)
HealthCheckInterval time.Duration
// 是否启用连接预热(启动时建立最小连接)
EnableWarmup bool
// 是否启用慢连接日志(记录建立时间超过阈值的连接)
EnableSlowConnLog bool
// 慢连接阈值(超过此时间记录为慢连接)
SlowConnThreshold time.Duration
// 连接池最大容量(防止资源耗尽)
MaxPoolCapacity int
// 动态连接池配置
EnableDynamicScaling bool // 是否启用动态连接池调整
DynamicScaleFactor float64 // 动态调整因子0.5-2.0
ScaleUpThreshold float64 // 扩容阈值0-1.0,当使用率超过此值时扩容)
ScaleDownThreshold float64 // 缩容阈值0-1.0,当使用率低于此值时缩容)
MinScaleUpInterval time.Duration // 最小扩容间隔(防止频繁调整)
MinScaleDownInterval time.Duration // 最小缩容间隔
MaxIdleTimeForScale time.Duration // 用于动态调整的最大空闲时间
}
// DefaultPoolConfig 返回默认连接池配置
func DefaultPoolConfig() *PoolConfig {
return &PoolConfig{
MaxOpenConns: 50, // 最大50个连接提高并发
MaxIdleConns: 20, // 最大20个空闲提高响应速度
ConnMaxLifetime: 60 * time.Minute, // 连接最长60分钟延长连接生命周期
ConnMaxIdleTime: 15 * time.Minute, // 空闲15分钟关闭更长的空闲时间
MinIdleConns: 5, // 保持5个最小空闲更好的响应性能
ConnTimeout: 3 * time.Second, // 连接超时3秒更快失败
HealthCheckInterval: 20 * time.Second, // 20秒健康检查一次更频繁的健康检查
EnableWarmup: true, // 启用预热
EnableSlowConnLog: true, // 启用慢连接日志
SlowConnThreshold: 200 * time.Millisecond, // 超过200ms算慢连接更严格的性能要求
MaxPoolCapacity: 100, // 连接池最大容量(支持更高并发)
// 动态连接池配置(更智能的调整策略)
EnableDynamicScaling: true, // 启用动态调整
DynamicScaleFactor: 1.8, // 调整因子1.8倍(更激进的扩容)
ScaleUpThreshold: 0.7, // 使用率超过70%扩容(更早扩容)
ScaleDownThreshold: 0.4, // 使用率低于40%缩容(避免频繁调整)
MinScaleUpInterval: 1 * time.Minute, // 最小扩容间隔1分钟更快的响应
MinScaleDownInterval: 3 * time.Minute, // 最小缩容间隔3分钟稳定缩容
MaxIdleTimeForScale: 20 * time.Minute, // 用于调整的最大空闲时间
}
}
// MySQLPoolEntry MySQL 连接池条目
type MySQLPoolEntry struct {
Client *MySQLClient
LastUsed time.Time
CreatedAt time.Time
InUse bool
mu sync.Mutex
}
// AcquireResult 连接获取结果
type AcquireResult struct {
Entry *MySQLPoolEntry
Err error
}
// ReleaseResult 连接释放结果
type ReleaseResult struct {
Success bool
Err error
}
// Stats 连接池统计信息
type PoolStats struct {
TotalConns int // 总连接数
ActiveConns int // 使用中的连接数
IdleConns int // 空闲连接数
WaitCount int64 // 等待连接的次数
WaitDuration time.Duration // 总等待时间
SlowConnCount int64 // 慢连接数量
}
// MySQLConnectionPool MySQL 连接池(真正的连接池)
type MySQLConnectionPool struct {
config *PoolConfig
configHash string // 配置哈希,用于检测配置变更
mu sync.RWMutex
entries []*MySQLPoolEntry // 连接池条目
connMap map[uint]*MySQLClient // 连接ID -> 客户端映射(兼容现有代码)
stats PoolStats
stopCh chan struct{}
wg sync.WaitGroup
// 动态调整相关
lastScaleUpTime time.Time // 上次扩容时间
lastScaleDownTime time.Time // 上次缩容时间
currentTargetSize int // 当前目标连接数
usageHistory []float64 // 使用率历史记录(用于智能调整)
adaptiveWeights map[uint]float64 // 连接权重(基于性能表现)
}
// NewMySQLConnectionPool 创建新的 MySQL 连接池
func NewMySQLConnectionPool(config *PoolConfig) *MySQLConnectionPool {
if config == nil {
config = DefaultPoolConfig()
}
pool := &MySQLConnectionPool{
config: config,
entries: make([]*MySQLPoolEntry, 0, config.MaxPoolCapacity),
connMap: make(map[uint]*MySQLClient),
stopCh: make(chan struct{}),
currentTargetSize: config.MinIdleConns,
usageHistory: make([]float64, 0, 100), // 保留最近100个使用率记录
adaptiveWeights: make(map[uint]float64),
}
return pool
}
// Acquire 获取一个连接(阻塞等待直到有可用连接)
func (p *MySQLConnectionPool) Acquire(conn *models.DbConnection) (*MySQLPoolEntry, error) {
p.mu.Lock()
defer p.mu.Unlock()
startTime := time.Now()
// 尝试获取最优连接(启用动态调整时)
if p.config.EnableDynamicScaling {
if entry, err := p.getOptimalConnection(); err == nil {
p.updateWaitStats(startTime)
return entry, nil
}
}
// 降级到标准逻辑 - 查找空闲连接
for _, entry := range p.entries {
entry.mu.Lock()
if !entry.InUse {
entry.InUse = true
entry.LastUsed = time.Now()
entry.mu.Unlock()
// 更新统计
p.updateWaitStats(startTime)
return entry, nil
}
entry.mu.Unlock()
}
// 没有可用连接,创建新连接
if len(p.entries) >= p.config.MaxOpenConns {
// 已达到最大连接数,等待
return p.waitForAvailableConnection(conn)
}
// 创建新连接(使用传入的连接配置)
newEntry, err := p.createNewEntry(conn)
if err != nil {
return nil, fmt.Errorf("创建连接失败: %v", err)
}
p.entries = append(p.entries, newEntry)
p.updateStats()
p.updateWaitStats(startTime)
return newEntry, nil
}
// Release 释放连接回池中
func (p *MySQLConnectionPool) Release(entry *MySQLPoolEntry) error {
if entry == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
entry.mu.Lock()
entry.InUse = false
entry.LastUsed = time.Now()
entry.mu.Unlock()
p.updateStats()
return nil
}
// Close 关闭连接池
func (p *MySQLConnectionPool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
// 发送停止信号
close(p.stopCh)
// 等待所有 goroutine 完成
p.wg.Wait()
// 关闭所有连接
var lastErr error
for _, entry := range p.entries {
entry.mu.Lock()
if err := entry.Client.Close(); err != nil {
lastErr = err
}
entry.InUse = false
entry.mu.Unlock()
}
p.entries = make([]*MySQLPoolEntry, 0, p.config.MaxPoolCapacity)
p.connMap = make(map[uint]*MySQLClient)
return lastErr
}
// Stats 获取连接池统计信息
func (p *MySQLConnectionPool) Stats() PoolStats {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats
}
// cleanupIdleConnections 清理空闲连接
func (p *MySQLConnectionPool) cleanupIdleConnections() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
keepEntries := make([]*MySQLPoolEntry, 0, len(p.entries))
for _, entry := range p.entries {
entry.mu.Lock()
isIdle := !entry.InUse
idleDuration := now.Sub(entry.LastUsed)
entry.mu.Unlock()
// 保留条件:正在使用 或 空闲时间未超过阈值 或 数量少于最小空闲数
keep := !isIdle ||
idleDuration < p.config.ConnMaxIdleTime ||
len(keepEntries) < p.config.MinIdleConns
if keep {
keepEntries = append(keepEntries, entry)
} else {
// 关闭连接
entry.Client.Close()
}
}
p.entries = keepEntries
p.updateStats()
}
// healthCheck 健康检查(增强版本)
func (p *MySQLConnectionPool) healthCheck() {
p.enhancedHealthCheck()
}
// StartMaintenance 启动维护协程(清理和健康检查)
func (p *MySQLConnectionPool) StartMaintenance() {
p.wg.Add(1)
go func() {
defer p.wg.Done()
// 健康检查Ticker
healthTicker := time.NewTicker(p.config.HealthCheckInterval)
defer healthTicker.Stop()
// 动态调整Ticker较短间隔
scaleTicker := time.NewTicker(1 * time.Minute)
defer scaleTicker.Stop()
for {
select {
case <-healthTicker.C:
// 清理空闲连接
p.cleanupIdleConnections()
// 健康检查
p.healthCheck()
case <-scaleTicker.C:
// 动态连接池调整
if p.config.EnableDynamicScaling {
p.adaptiveScaling()
}
case <-p.stopCh:
return
}
}
}()
}
// createNewEntry 创建新的连接池条目
func (p *MySQLConnectionPool) createNewEntry(conn *models.DbConnection) (*MySQLPoolEntry, error) {
startTime := time.Now()
client, err := createMySQLClient(conn)
if err != nil {
return nil, err
}
elapsed := time.Since(startTime)
// 慢连接日志
if p.config.EnableSlowConnLog && elapsed > p.config.SlowConnThreshold {
// 记录慢连接
p.mu.Lock()
p.stats.SlowConnCount++
p.mu.Unlock()
}
entry := &MySQLPoolEntry{
Client: client,
LastUsed: time.Now(),
CreatedAt: startTime,
InUse: true,
}
return entry, nil
}
// waitForAvailableConnection 等待可用连接并获取它
func (p *MySQLConnectionPool) waitForAvailableConnection(conn *models.DbConnection) (*MySQLPoolEntry, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ErrPoolExhausted
case <-ticker.C:
p.mu.Lock()
for _, entry := range p.entries {
entry.mu.Lock()
if !entry.InUse {
entry.InUse = true
entry.LastUsed = time.Now()
entry.mu.Unlock()
p.mu.Unlock()
return entry, nil
}
entry.mu.Unlock()
}
p.mu.Unlock()
}
}
}
// updateWaitStats 更新等待统计(调用方必须持有 p.mu
func (p *MySQLConnectionPool) updateWaitStats(startTime time.Time) {
p.stats.WaitCount++
p.stats.WaitDuration += time.Since(startTime)
}
// updateStats 更新连接池统计
func (p *MySQLConnectionPool) updateStats() {
total := len(p.entries)
active := 0
idle := 0
for _, entry := range p.entries {
entry.mu.Lock()
if entry.InUse {
active++
} else {
idle++
}
entry.mu.Unlock()
}
p.stats.TotalConns = total
p.stats.ActiveConns = active
p.stats.IdleConns = idle
}
// adaptiveScaling 自适应连接池调整
func (p *MySQLConnectionPool) adaptiveScaling() {
p.mu.Lock()
defer p.mu.Unlock()
// 计算当前使用率
if len(p.entries) == 0 {
return
}
usageRate := float64(p.stats.ActiveConns) / float64(len(p.entries))
// 记录使用率历史
p.usageHistory = append(p.usageHistory, usageRate)
if len(p.usageHistory) > 100 {
p.usageHistory = p.usageHistory[1:]
}
// 检查是否需要调整
now := time.Now()
// 扩容逻辑
if usageRate >= p.config.ScaleUpThreshold {
if now.Sub(p.lastScaleUpTime) >= p.config.MinScaleUpInterval {
p.scaleUp()
p.lastScaleUpTime = now
}
return
}
// 缩容逻辑
if usageRate <= p.config.ScaleDownThreshold && len(p.entries) > p.config.MinIdleConns {
if now.Sub(p.lastScaleDownTime) >= p.config.MinScaleDownInterval {
p.scaleDown()
p.lastScaleDownTime = now
}
}
}
// scaleUp 扩容
func (p *MySQLConnectionPool) scaleUp() {
// scaleUp 仅更新目标大小,实际连接在 Acquire 时按需创建
// 移除了创建无效虚拟连接的逻辑
currentSize := len(p.entries)
scaleFactor := p.config.DynamicScaleFactor
newSize := int(float64(currentSize) * scaleFactor)
newSize = min(newSize, p.config.MaxOpenConns)
newSize = max(newSize, currentSize+1)
p.currentTargetSize = newSize
p.updateStats()
}
// scaleDown 缩容
func (p *MySQLConnectionPool) scaleDown() {
// 计算新目标大小
currentSize := len(p.entries)
scaleFactor := 1.0 / p.config.DynamicScaleFactor
newSize := int(float64(currentSize) * scaleFactor)
newSize = max(newSize, p.config.MinIdleConns)
newSize = min(newSize, currentSize-1) // 至少减少1个连接
if newSize < currentSize {
// 关闭多余的空闲连接
p.closeIdleConnections(currentSize - newSize)
p.currentTargetSize = newSize
p.updateStats()
}
}
// closeIdleConnections 关闭指定数量的空闲连接
func (p *MySQLConnectionPool) closeIdleConnections(count int) {
// 收集空闲连接
idleEntries := make([]*MySQLPoolEntry, 0)
for _, entry := range p.entries {
entry.mu.Lock()
if !entry.InUse {
idleEntries = append(idleEntries, entry)
}
entry.mu.Unlock()
}
// 关闭指定数量的空闲连接
closedEntries := make(map[*MySQLPoolEntry]bool)
for i := 0; i < min(count, len(idleEntries)); i++ {
entry := idleEntries[i]
entry.mu.Lock()
entry.Client.Close()
entry.mu.Unlock()
closedEntries[entry] = true
}
// 重新构建连接池
remainingEntries := make([]*MySQLPoolEntry, 0, len(p.entries))
for _, entry := range p.entries {
if closedEntries[entry] {
continue // 跳过已关闭的连接
}
remainingEntries = append(remainingEntries, entry)
}
p.entries = remainingEntries
}
// enhancedHealthCheck 增强的健康检查
func (p *MySQLConnectionPool) enhancedHealthCheck() {
p.mu.RLock()
entriesCopy := make([]*MySQLPoolEntry, len(p.entries))
copy(entriesCopy, p.entries)
p.mu.RUnlock()
var healthyEntries []*MySQLPoolEntry
var performanceWeights []float64
for _, entry := range entriesCopy {
entry.mu.Lock()
isIdle := !entry.InUse
// 测试连接有效性
isHealthy := true
startTime := time.Now()
if isIdle {
// 空闲连接简单Ping测试
if err := entry.Client.sqlDB.Ping(); err != nil {
isHealthy = false
// 关闭失效连接
entry.Client.Close()
}
} else {
// 使用中的连接:快速测试(避免影响正常查询)
func() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if err := entry.Client.sqlDB.PingContext(ctx); err != nil {
isHealthy = false
}
}()
}
// 计算连接性能权重
if isHealthy {
healthyEntries = append(healthyEntries, entry)
// 基于连接性能计算权重
responseTime := time.Since(startTime).Microseconds()
weight := 1.0 / max(float64(responseTime)/1000.0, 1.0) // 转换为毫秒,避免除零
performanceWeights = append(performanceWeights, weight)
} else {
// 不健康的连接
if isIdle {
entry.Client.Close()
}
}
entry.mu.Unlock()
}
// 更新连接池
p.mu.Lock()
defer p.mu.Unlock()
p.entries = healthyEntries
// 更新自适应权重
if len(healthyEntries) > 0 {
for i := range healthyEntries {
if i < len(performanceWeights) {
p.adaptiveWeights[uint(i)] = performanceWeights[i]
}
}
}
p.updateStats()
}
// warmUp 连接池预热
func (p *MySQLConnectionPool) warmUp() {
if !p.config.EnableWarmup {
return
}
p.mu.Lock()
defer p.mu.Unlock()
currentIdle := 0
for _, entry := range p.entries {
entry.mu.Lock()
if !entry.InUse {
currentIdle++
}
entry.mu.Unlock()
}
targetIdle := p.config.MinIdleConns
needed := targetIdle - currentIdle
// warmUp 仅记录目标大小,不在无连接配置的情况下创建无效虚拟连接
// 实际连接在 Acquire 时按需创建
_ = needed
p.updateStats()
}
// getOptimalConnection 获取最优连接(基于性能权重)
// 注意:调用方必须已持有 p.mu
func (p *MySQLConnectionPool) getOptimalConnection() (*MySQLPoolEntry, error) {
var bestEntry *MySQLPoolEntry
var bestWeight float64
for i, entry := range p.entries {
entry.mu.Lock()
if !entry.InUse {
weight := 1.0 // 默认权重
if w, ok := p.adaptiveWeights[uint(i)]; ok {
weight = w
}
if bestEntry == nil || weight > bestWeight {
bestEntry = entry
bestWeight = weight
}
}
entry.mu.Unlock()
}
if bestEntry == nil {
return nil, ErrPoolExhausted
}
bestEntry.InUse = true
bestEntry.LastUsed = time.Now()
return bestEntry, nil
}
// createMySQLClient 创建 MySQL 客户端的辅助函数
func createMySQLClient(conn *models.DbConnection) (*MySQLClient, error) {
// 解密密码
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,
Database: conn.Database,
}
return NewMySQLClient(config)
}
// 错误定义
var (
ErrPoolExhausted = &PoolError{Message: "连接池已耗尽"}
ErrPoolClosed = &PoolError{Message: "连接池已关闭"}
)
// PoolError 连接池错误
type PoolError struct {
Message string
Err error
}
func (e *PoolError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}

View File

@@ -1,762 +0,0 @@
package dbclient
import (
"context"
"crypto/sha256"
"fmt"
"regexp"
"strings"
"sync"
"time"
)
var (
reLimitOffset = regexp.MustCompile(`limit\s+(\d+)(?:\s*,\s*(\d+))?`)
reFromTable = regexp.MustCompile(`(?i)from\s+([^\s,]+)`)
reWhereClause = regexp.MustCompile(`(?i)where\s+(.*?)(?:\s+order\s+by|\s+limit|\s+group\s+by|$)`)
reOrderBy = regexp.MustCompile(`(?i)order\s+by\s+(.*?)(?:\s+limit|$)`)
reBatchOperation = regexp.MustCompile(`(?i)^\s*(INSERT|UPDATE|DELETE).*VALUES\s*\(`)
)
// CachedQuery 缓存查询结果
type CachedQuery struct {
Result *QueryResult
ExpiryTime time.Time
CreatedAt time.Time
QueryHash string
QueryParams QueryParams
LastUsed time.Time // 最后使用时间用于LRU策略
AccessCount int64 // 访问次数用于LFU策略
}
// QueryParams 查询参数(用于缓存键生成)
type QueryParams struct {
SQL string
Database string
Limit int
Offset int
Table string
Where string
SortBy string
IsReadOnly bool
}
// QueryStats 查询统计信息
type QueryStats struct {
TotalQueries int64
CachedQueries int64
SlowQueries int64
TotalDuration time.Duration
AverageDuration time.Duration
CacheHitRate float64
LastCacheUpdate time.Time
}
// SlowQuery 慢查询记录
type SlowQuery struct {
Query string
Database string
Duration time.Duration
Timestamp time.Time
Params QueryParams
Table string
IndexUsed string
RowsAffected int64
Error error
}
// IndexSuggestion 索引建议
type IndexSuggestion struct {
Table string
Columns []string
IndexType string // "normal", "unique", "fulltext"
Priority string // "high", "medium", "low"
Query string
Justification string
CanBeApplied bool
}
// QueryOptimizer 查询优化器
type QueryOptimizer struct {
cache *QueryCache
stats *QueryStats
slowQueries []SlowQuery
indexSuggestions []IndexSuggestion
mu sync.RWMutex
config *OptimizerConfig
stopCh chan struct{}
wg sync.WaitGroup
}
// OptimizerConfig 查询优化器配置
type OptimizerConfig struct {
// 缓存配置
CacheSize int // 最大缓存条目数
CacheTTL time.Duration // 缓存过期时间
EnableCache bool // 是否启用缓存
// 慢查询配置
SlowQueryThreshold time.Duration // 慢查询阈值
EnableSlowLog bool // 是否启用慢查询日志
MaxSlowLogs int // 最大慢查询记录数
// 索引建议配置
EnableIndexSuggestions bool // 是否启用索引建议
MaxSuggestions int // 最大索引建议数
// 查询分析配置
EnableQueryAnalysis bool // 是否启用查询分析
MaxAnalysisDepth int // 查询分析深度
}
// DefaultOptimizerConfig 返回默认的查询优化器配置
func DefaultOptimizerConfig() *OptimizerConfig {
return &OptimizerConfig{
CacheSize: 1000, // 最多缓存1000个查询
CacheTTL: 30 * time.Minute, // 缓存30分钟
EnableCache: true, // 启用缓存
SlowQueryThreshold: 100 * time.Millisecond, // 100ms以上为慢查询
EnableSlowLog: true, // 启用慢查询日志
MaxSlowLogs: 1000, // 最多记录1000条慢查询
EnableIndexSuggestions: true, // 启用索引建议
MaxSuggestions: 100, // 最多100个索引建议
EnableQueryAnalysis: true, // 启用查询分析
MaxAnalysisDepth: 3, // 分析深度3
}
}
// NewQueryOptimizer 创建新的查询优化器
func NewQueryOptimizer(config *OptimizerConfig) *QueryOptimizer {
if config == nil {
config = DefaultOptimizerConfig()
}
optimizer := &QueryOptimizer{
cache: NewQueryCache(config.CacheSize, config.CacheTTL),
stats: &QueryStats{},
config: config,
stopCh: make(chan struct{}),
slowQueries: make([]SlowQuery, 0),
indexSuggestions: make([]IndexSuggestion, 0),
}
// 启动维护协程
optimizer.StartMaintenance()
return optimizer
}
// OptimizeQuery 优化查询执行
func (o *QueryOptimizer) OptimizeQuery(ctx context.Context, client *MySQLClient, sqlStr string, database string) (*QueryResult, time.Duration, error) {
startTime := time.Now()
queryParams := o.parseQueryParams(sqlStr, database)
// 检查缓存
if o.config.EnableCache && queryParams.IsReadOnly {
cached, err := o.cache.Get(queryParams)
if err == nil && cached != nil {
o.recordCacheHit()
return cached.Result, time.Since(startTime), nil
}
}
// 执行查询
result, err := client.ExecuteQuery(ctx, sqlStr, database)
if err != nil {
duration := time.Since(startTime)
o.recordSlowQuery(sqlStr, database, duration, queryParams, result, err)
return nil, duration, err
}
duration := time.Since(startTime)
// 检查是否为慢查询
if duration > o.config.SlowQueryThreshold {
o.recordSlowQuery(sqlStr, database, duration, queryParams, result, err)
}
// 缓存只读查询结果
if o.config.EnableCache && queryParams.IsReadOnly && err == nil {
cachedResult := &CachedQuery{
Result: result,
ExpiryTime: time.Now().Add(o.config.CacheTTL),
CreatedAt: time.Now(),
QueryHash: o.generateQueryHash(queryParams),
QueryParams: queryParams,
LastUsed: time.Now(),
AccessCount: 1,
}
o.cache.Set(queryParams, cachedResult)
}
o.recordQuery(duration)
return result, duration, err
}
// ExecuteOptimizedUpdate 执行优化的更新操作
func (o *QueryOptimizer) ExecuteOptimizedUpdate(ctx context.Context, client *MySQLClient, sqlStr string, database string) (int64, time.Duration, error) {
startTime := time.Now()
// 分析更新查询
queryParams := o.parseQueryParams(sqlStr, database)
// 检查是否为批量操作
if o.isBatchOperation(sqlStr) {
// 优化批量操作
rowsAffected, duration, err := o.optimizeBatchUpdate(ctx, client, sqlStr, database)
if err != nil {
o.recordSlowQuery(sqlStr, database, duration, queryParams, nil, err)
return 0, duration, err
}
o.recordQuery(duration)
return rowsAffected, duration, nil
}
// 执行普通更新
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, database)
duration := time.Since(startTime)
if duration > o.config.SlowQueryThreshold {
o.recordSlowQuery(sqlStr, database, duration, queryParams, nil, err)
}
o.recordQuery(duration)
return rowsAffected, duration, err
}
// GetIndexSuggestions 获取索引建议
func (o *QueryOptimizer) GetIndexSuggestions(table string) []IndexSuggestion {
o.mu.RLock()
defer o.mu.RUnlock()
var suggestions []IndexSuggestion
for _, suggestion := range o.indexSuggestions {
if suggestion.Table == table || table == "" {
suggestions = append(suggestions, suggestion)
}
}
return suggestions
}
// GenerateIndexSuggestions 为表生成索引建议
func (o *QueryOptimizer) GenerateIndexSuggestions(ctx context.Context, client *MySQLClient, database, table string) error {
// 获取表的慢查询记录
tableSlowQueries := o.getTableSlowQueries(database, table)
// 分析查询模式
for _, slowQuery := range tableSlowQueries {
suggestions := o.analyzeQueryForIndexes(slowQuery.Query, table)
o.mu.Lock()
o.indexSuggestions = append(o.indexSuggestions, suggestions...)
// 限制建议数量
if len(o.indexSuggestions) > o.config.MaxSuggestions {
o.indexSuggestions = o.indexSuggestions[:o.config.MaxSuggestions]
}
o.mu.Unlock()
}
return nil
}
// GetQueryStats 获取查询统计信息
func (o *QueryOptimizer) GetQueryStats() QueryStats {
o.mu.RLock()
defer o.mu.RUnlock()
return *o.stats
}
// GetSlowQueries 获取慢查询记录
func (o *QueryOptimizer) GetSlowQueries(limit int) []SlowQuery {
o.mu.RLock()
defer o.mu.RUnlock()
if limit <= 0 || limit > len(o.slowQueries) {
limit = len(o.slowQueries)
}
return o.slowQueries[:limit]
}
// ClearCache 清空缓存
func (o *QueryOptimizer) ClearCache() {
o.cache.Clear()
}
// Stop 停止优化器
func (o *QueryOptimizer) Stop() {
close(o.stopCh)
o.wg.Wait()
}
// parseQueryParams 解析查询参数
func (o *QueryOptimizer) parseQueryParams(sqlStr, database string) QueryParams {
params := QueryParams{
SQL: sqlStr,
Database: database,
}
// 解析LIMIT和OFFSET
limit, offset := o.parseLimitOffset(sqlStr)
params.Limit = limit
params.Offset = offset
// 解析表名
tables := o.parseTables(sqlStr)
if len(tables) > 0 {
params.Table = tables[0]
}
// 解析WHERE条件
where := o.parseWhereCondition(sqlStr)
params.Where = where
// 解析排序
sort := o.parseSortOrder(sqlStr)
params.SortBy = sort
// 判断是否为只读查询
params.IsReadOnly = o.isReadOnlyQuery(sqlStr)
return params
}
// parseLimitOffset 解析LIMIT和OFFSET
func (o *QueryOptimizer) parseLimitOffset(sqlStr string) (limit, offset int) {
sqlStr = strings.ToLower(sqlStr)
matches := reLimitOffset.FindStringSubmatch(sqlStr)
if len(matches) > 1 {
fmt.Sscanf(matches[1], "%d", &limit)
if len(matches) > 2 && matches[2] != "" {
fmt.Sscanf(matches[2], "%d", &offset)
}
}
// MySQL LIMIT offset, count: matches[1]=offset, matches[2]=count
if len(matches) > 2 && matches[2] != "" {
offset, limit = limit, offset
}
return limit, offset
}
// parseTables 解析查询中的表名
func (o *QueryOptimizer) parseTables(sqlStr string) []string {
// 简单实现解析FROM和JOIN中的表名
tables := make([]string, 0)
fromMatches := reFromTable.FindAllStringSubmatch(sqlStr, -1)
for _, match := range fromMatches {
if len(match) > 1 {
tableName := strings.Trim(match[1], "`\"'[]")
tables = append(tables, tableName)
}
}
return tables
}
// parseWhereCondition 解析WHERE条件
func (o *QueryOptimizer) parseWhereCondition(sqlStr string) string {
matches := reWhereClause.FindStringSubmatch(sqlStr)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// parseSortOrder 解析排序条件
func (o *QueryOptimizer) parseSortOrder(sqlStr string) string {
matches := reOrderBy.FindStringSubmatch(sqlStr)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// isReadOnlyQuery 判断是否为只读查询
func (o *QueryOptimizer) isReadOnlyQuery(sqlStr string) bool {
sqlStr = strings.ToUpper(strings.TrimSpace(sqlStr))
// SELECT只读查询
if strings.HasPrefix(sqlStr, "SELECT") {
return true
}
// 支持的只读查询类型
readOnlyQueries := []string{
"SHOW", "DESCRIBE", "DESC", "EXPLAIN",
"WITH", "UNION", "INTERSECT", "EXCEPT",
}
for _, query := range readOnlyQueries {
if strings.HasPrefix(sqlStr, query) {
return true
}
}
return false
}
// isBatchOperation 判断是否为批量操作
func (o *QueryOptimizer) isBatchOperation(sqlStr string) bool {
return reBatchOperation.MatchString(sqlStr)
}
// generateQueryHash 生成查询哈希
func (o *QueryOptimizer) generateQueryHash(params QueryParams) string {
hashData := fmt.Sprintf("%s|%s|%d|%d|%s|%s|%s|%v",
params.SQL, params.Database, params.Limit, params.Offset,
params.Table, params.Where, params.SortBy, params.IsReadOnly)
h := sha256.Sum256([]byte(hashData))
return fmt.Sprintf("%x", h)
}
// recordQuery 记录查询统计
func (o *QueryOptimizer) recordQuery(duration time.Duration) {
o.mu.Lock()
defer o.mu.Unlock()
o.stats.TotalQueries++
o.stats.TotalDuration += duration
o.stats.AverageDuration = time.Duration(int64(float64(o.stats.TotalDuration) / float64(o.stats.TotalQueries)))
now := time.Now()
if o.stats.LastCacheUpdate.IsZero() || now.Sub(o.stats.LastCacheUpdate) > 5*time.Minute {
// 更新缓存命中率
total := o.stats.TotalQueries
hit := o.stats.CachedQueries
o.stats.CacheHitRate = float64(hit) / float64(total) * 100
o.stats.LastCacheUpdate = now
}
}
// recordCacheHit 记录缓存命中
func (o *QueryOptimizer) recordCacheHit() {
o.mu.Lock()
defer o.mu.Unlock()
o.stats.CachedQueries++
}
// recordSlowQuery 记录慢查询
func (o *QueryOptimizer) recordSlowQuery(query, database string, duration time.Duration, params QueryParams, result *QueryResult, err error) {
if !o.config.EnableSlowLog {
return
}
slowQuery := SlowQuery{
Query: query,
Database: database,
Duration: duration,
Timestamp: time.Now(),
Params: params,
Table: params.Table,
IndexUsed: o.extractIndexUsed(query),
RowsAffected: o.extractRowsAffected(result),
Error: err,
}
o.mu.Lock()
defer o.mu.Unlock()
o.slowQueries = append(o.slowQueries, slowQuery)
// 限制慢查询记录数量
if len(o.slowQueries) > o.config.MaxSlowLogs {
o.slowQueries = o.slowQueries[1:]
}
o.stats.SlowQueries++
}
// extractIndexUsed 提取使用的索引
func (o *QueryOptimizer) extractIndexUsed(query string) string {
// 简单实现从EXPLAIN结果中提取索引信息
// 实际项目中应该执行EXPLAIN语句分析
return "unknown"
}
// extractRowsAffected 提取影响的行数
func (o *QueryOptimizer) extractRowsAffected(result *QueryResult) int64 {
if result != nil && len(result.Data) > 0 {
if rows, ok := result.Data[0]["rows_affected"].(int64); ok {
return rows
}
}
return 0
}
// analyzeQuery 分析查询性能
func (o *QueryOptimizer) analyzeQuery(query, database string, result *QueryResult, duration time.Duration) {
// 这里可以实现更复杂的查询分析逻辑
// 比如分析查询计划、检测N+1查询问题等
// 简单实现:记录查询到统计信息中
_ = query
_ = database
_ = result
_ = duration
}
// analyzeQueryForIndexes 分析查询为索引建议
func (o *QueryOptimizer) analyzeQueryForIndexes(query, table string) []IndexSuggestion {
var suggestions []IndexSuggestion
// 解析查询中的WHERE条件
where := o.parseWhereCondition(query)
if where != "" {
// 提取WHERE条件中的列
columns := o.extractColumnsFromWhere(where)
if len(columns) > 0 {
// 创建索引建议
suggestion := IndexSuggestion{
Table: table,
Columns: columns,
IndexType: "normal",
Priority: "medium",
Query: query,
Justification: fmt.Sprintf("查询经常使用WHERE条件 %s", where),
CanBeApplied: true,
}
suggestions = append(suggestions, suggestion)
}
}
// 解析ORDER BY条件
order := o.parseSortOrder(query)
if order != "" {
// 提取排序的列
columns := o.extractColumnsFromOrder(order)
if len(columns) > 0 {
// 创建排序索引建议
suggestion := IndexSuggestion{
Table: table,
Columns: columns,
IndexType: "normal",
Priority: "low",
Query: query,
Justification: fmt.Sprintf("查询经常使用ORDER BY %s", order),
CanBeApplied: true,
}
suggestions = append(suggestions, suggestion)
}
}
return suggestions
}
// extractColumnsFromWhere 从WHERE条件中提取列名
func (o *QueryOptimizer) extractColumnsFromWhere(where string) []string {
// 简单实现提取WHERE条件中的列名
columns := make([]string, 0)
// 这里可以实现更复杂的列名解析逻辑
// 目前只做简单处理
words := strings.Fields(where)
for _, word := range words {
// 去除运算符和引号
if !strings.Contains(word, "=") &&
!strings.Contains(word, ">") &&
!strings.Contains(word, "<") &&
!strings.Contains(word, "!=") &&
!strings.HasPrefix(word, "'") &&
!strings.HasPrefix(word, "\"") {
columns = append(columns, strings.Trim(word, " `\"'[]"))
}
}
return columns
}
// extractColumnsFromOrder 从ORDER BY条件中提取列名
func (o *QueryOptimizer) extractColumnsFromOrder(order string) []string {
// 简单实现提取ORDER BY中的列名
columns := strings.Split(order, ",")
for i, col := range columns {
columns[i] = strings.TrimSpace(strings.Split(col, " ")[0])
}
return columns
}
// getTableSlowQueries 获取表的慢查询记录
func (o *QueryOptimizer) getTableSlowQueries(database, table string) []SlowQuery {
o.mu.RLock()
defer o.mu.RUnlock()
var tableQueries []SlowQuery
for _, query := range o.slowQueries {
if (database == "" || query.Database == database) &&
(table == "" || query.Table == table) {
tableQueries = append(tableQueries, query)
}
}
return tableQueries
}
// optimizeBatchUpdate 优化批量更新操作
func (o *QueryOptimizer) optimizeBatchUpdate(ctx context.Context, client *MySQLClient, sqlStr string, database string) (int64, time.Duration, error) {
// 简单实现:执行原始查询
// 实际项目中可以实现批量操作优化
startTime := time.Now()
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, database)
duration := time.Since(startTime)
return rowsAffected, duration, err
}
// StartMaintenance 启动维护协程
func (o *QueryOptimizer) StartMaintenance() {
o.wg.Add(1)
go func() {
defer o.wg.Done()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 清理过期的缓存
o.cache.CleanupExpired()
// 分析慢查询生成新的索引建议
o.analyzeSlowQueriesForSuggestions()
case <-o.stopCh:
return
}
}
}()
}
// RecordPoolError 记录连接池错误
func (o *QueryOptimizer) RecordPoolError(operation string, err error) {
if !o.config.EnableSlowLog || err == nil {
return
}
poolError := SlowQuery{
Query: operation,
Database: "pool",
Duration: 0,
Timestamp: time.Now(),
Params: QueryParams{SQL: operation},
Table: "connection_pool",
IndexUsed: "N/A",
RowsAffected: 0,
Error: err,
}
o.mu.Lock()
defer o.mu.Unlock()
o.slowQueries = append(o.slowQueries, poolError)
// 限制慢查询记录数量
if len(o.slowQueries) > o.config.MaxSlowLogs {
o.slowQueries = o.slowQueries[1:]
}
}
// analyzeSlowQueriesForSuggestions 分析慢查询生成索引建议
func (o *QueryOptimizer) analyzeSlowQueriesForSuggestions() {
// 这里可以实现更复杂的慢查询分析逻辑
// 比如分析查询模式、统计索引使用情况等
// 分析慢查询模式
o.analyzeSlowQueryPatterns()
}
// analyzeSlowQueryPatterns 分析慢查询模式
func (o *QueryOptimizer) analyzeSlowQueryPatterns() {
o.mu.RLock()
queryTypes := make(map[string]int)
tableQueries := make(map[string]int)
for _, query := range o.slowQueries {
queryType := o.detectQueryType(query.Query)
queryTypes[queryType]++
if query.Table != "" {
tableQueries[query.Table]++
}
}
o.mu.RUnlock()
// 根据统计结果生成智能建议(在锁外执行,避免死锁)
o.generateSmartSuggestions(queryTypes, tableQueries)
}
// detectQueryType 检测查询类型
func (o *QueryOptimizer) detectQueryType(sqlStr string) string {
sqlStr = strings.ToUpper(strings.TrimSpace(sqlStr))
if strings.HasPrefix(sqlStr, "SELECT") {
if strings.Contains(sqlStr, "JOIN") {
return "SELECT_JOIN"
} else if strings.Contains(sqlStr, "GROUP BY") {
return "SELECT_GROUP"
} else {
return "SELECT_SIMPLE"
}
} else if strings.HasPrefix(sqlStr, "INSERT") {
return "INSERT"
} else if strings.HasPrefix(sqlStr, "UPDATE") {
return "UPDATE"
} else if strings.HasPrefix(sqlStr, "DELETE") {
return "DELETE"
}
return "OTHER"
}
// generateSmartSuggestions 生成智能建议
func (o *QueryOptimizer) generateSmartSuggestions(queryTypes map[string]int, tableQueries map[string]int) {
// 分析频繁执行的查询类型
var mostFrequentType string
var maxCount int
for queryType, count := range queryTypes {
if count > maxCount {
maxCount = count
mostFrequentType = queryType
}
}
// 生成针对性的索引建议
switch mostFrequentType {
case "SELECT_JOIN":
// 为JOIN查询建议复合索引
o.generateJoinSuggestions()
case "SELECT_GROUP":
// 为GROUP BY查询建议索引
o.generateGroupSuggestions()
case "INSERT":
// 为批量插入建议优化
o.generateInsertSuggestions()
}
}
// generateJoinSuggestions 生成JOIN查询建议
func (o *QueryOptimizer) generateJoinSuggestions() {
}
// generateGroupSuggestions 生成GROUP BY查询建议
func (o *QueryOptimizer) generateGroupSuggestions() {
}
// generateInsertSuggestions 生成批量插入建议
func (o *QueryOptimizer) generateInsertSuggestions() {
}

View File

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

View File

@@ -1,151 +0,0 @@
package dbclient
import (
"context"
"fmt"
"log"
"github.com/redis/go-redis/v9"
)
// RedisPipeline Redis Pipeline 操作
type RedisPipeline struct {
client *RedisClient
commands []RedisCommand
ctx context.Context
}
// RedisCommand Redis 命令结构
type RedisCommand struct {
Command string
Args []interface{}
Result interface{}
Error error
}
// NewRedisPipeline 创建新的 Redis Pipeline
func (r *RedisClient) NewPipeline(ctx context.Context) *RedisPipeline {
return &RedisPipeline{
client: r,
commands: make([]RedisCommand, 0),
ctx: ctx,
}
}
// AddCommand 添加命令到 Pipeline
func (p *RedisPipeline) AddCommand(command string, args ...interface{}) {
p.commands = append(p.commands, RedisCommand{
Command: command,
Args: args,
})
}
// Execute 使用 go-redis 原生 Pipeline 执行所有命令
func (p *RedisPipeline) Execute() ([]interface{}, error) {
if len(p.commands) == 0 {
return nil, nil
}
pipe := p.client.client.Pipeline()
cmds := make([]*redis.Cmd, len(p.commands))
for i, c := range p.commands {
cmds[i] = pipe.Do(p.ctx, append([]interface{}{c.Command}, c.Args...)...)
}
// 一次性发送所有命令
results := make([]interface{}, len(p.commands))
cmdResults, err := pipe.Exec(p.ctx)
if err != nil && err != redis.Nil {
log.Printf("[RedisPipeline] Exec 错误: %v", err)
}
for i, cmd := range cmds {
result, cmdErr := cmd.Result()
results[i] = result
p.commands[i].Result = result
p.commands[i].Error = cmdErr
}
// 如果 Exec 返回了命令结果(部分 Redis 版本),使用它们
for i, cr := range cmdResults {
if cr.Err() != nil && cr.Err() != redis.Nil {
p.commands[i].Error = cr.Err()
if i < len(results) {
results[i] = nil
}
}
}
_ = results // 已经通过 cmds 获取
return results, nil
}
// GetCommands 获取 Pipeline 中的命令列表
func (p *RedisPipeline) GetCommands() []RedisCommand {
return p.commands
}
// Len 获取 Pipeline 中的命令数量
func (p *RedisPipeline) Len() int {
return len(p.commands)
}
// Clear 清空 Pipeline
func (p *RedisPipeline) Clear() {
p.commands = make([]RedisCommand, 0)
}
// RedisTransaction Redis 事务支持
type RedisTransaction struct {
client *RedisClient
watch []string
cmds []RedisCommand
ctx context.Context
}
// NewRedisTransaction 创建新的 Redis 事务
func (r *RedisClient) NewTransaction(ctx context.Context, watch ...string) *RedisTransaction {
return &RedisTransaction{
client: r,
watch: watch,
ctx: ctx,
}
}
// AddCommand 添加命令到事务
func (tx *RedisTransaction) AddCommand(command string, args ...interface{}) {
tx.cmds = append(tx.cmds, RedisCommand{
Command: command,
Args: args,
})
}
// Exec 使用 go-redis Watch + TxPipeline 执行事务MULTI/EXEC
func (tx *RedisTransaction) Exec() ([]interface{}, error) {
pipe := tx.client.client.TxPipeline()
// 添加所有命令
cmds := make([]*redis.Cmd, len(tx.cmds))
for i, c := range tx.cmds {
cmds[i] = pipe.Do(tx.ctx, append([]interface{}{c.Command}, c.Args...)...)
}
// TxPipeline 自动发送 MULTI/EXEC
results := make([]interface{}, len(tx.cmds))
_, err := pipe.Exec(tx.ctx)
for i, cmd := range cmds {
result, cmdErr := cmd.Result()
results[i] = result
tx.cmds[i].Result = result
tx.cmds[i].Error = cmdErr
}
if err != nil && err != redis.Nil {
return results, fmt.Errorf("事务执行失败: %v", err)
}
return results, nil
}

View File

@@ -1,26 +0,0 @@
package model
// MemberInfo 用户信息表
type MemberInfo struct {
Memberid int `gorm:"primaryKey;column:memberid;type:int;comment:用户ID" json:"memberid"`
Membername string `gorm:"column:membername;type:varchar(100);comment:姓名" json:"membername"`
Account string `gorm:"column:account;type:varchar(100);comment:账号" json:"account"`
Password string `gorm:"column:password;type:varchar(100);comment:密码" json:"-"`
Contactphone string `gorm:"column:contactphone;type:varchar(50);comment:联系电话" json:"contactphone"`
Organid int `gorm:"column:organid;type:int;comment:所属机构ID" json:"organid"`
Createtime string `gorm:"column:createtime;type:varchar(50);comment:创建时间" json:"createtime"`
Updatetime string `gorm:"column:updatetime;type:varchar(50);comment:修改时间" json:"updatetime"`
Role int16 `gorm:"column:role;type:smallint;comment:角色类别" json:"role"`
Status int16 `gorm:"column:status;type:smallint;comment:状态 1正常 2停用 3删除" json:"status"`
Calluserid string `gorm:"column:calluserid;type:varchar(100);comment:坐席用户ID" json:"calluserid"`
Remainingexport int `gorm:"column:remainingexport;type:int;comment:本月剩余导出次数" json:"remainingexport"`
// 虚拟字段(关联查询)
Organname string `gorm:"-" json:"organname"` // 机构名称
Rolename string `gorm:"-" json:"rolename"` // 角色名称
}
// TableName 指定表名
func (MemberInfo) TableName() string {
return "member_info"
}

View File

@@ -42,11 +42,10 @@ type TabConfig struct {
var defaultTabConfig = TabConfig{
AvailableTabs: []TabDefinition{
{Key: "file-system", Title: "文件管理", Enabled: true},
{Key: "db-cli", Title: "数据库", Enabled: true},
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
{Key: "version", Title: "版本历史", Enabled: true},
},
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "version"},
VisibleTabs: []string{"file-system", "markdown-editor", "version"},
DefaultTab: "file-system",
}

View File

@@ -1,268 +0,0 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"u-desk/internal/crypto"
"u-desk/internal/dbclient"
"u-desk/internal/storage/models"
"u-desk/internal/storage/repository"
"gorm.io/gorm"
)
// 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 {
conn, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil // 连接不存在视为成功
}
return fmt.Errorf("获取连接配置失败: %v", err)
}
// 关闭连接池中的连接
dbclient.GetPool().CloseConnection(id, conn.Type)
// 删除连接记录
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)
}
}
// LoadAllDatabases 加载全部数据库列表
func (s *ConnectionService) LoadAllDatabases(dbType, host string, port int, username, password, database, options string, existingId uint) ([]string, error) {
// 如果是编辑模式且密码为空,尝试获取已保存的密码
actualPassword := password
if existingId > 0 && password == "" {
conn, err := s.repo.FindByID(existingId)
if err != nil {
return nil, fmt.Errorf("获取原连接配置失败: %v", err)
}
actualPassword, err = crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
}
// 解析 MongoDB 选项
authSource := ""
authMechanism := ""
if options != "" {
var opts map[string]interface{}
if err := json.Unmarshal([]byte(options), &opts); err == nil {
authSource, _ = opts["authSource"].(string)
authMechanism, _ = opts["authMechanism"].(string)
}
}
switch dbType {
case "mysql":
return loadDatabasesForMySQL(host, port, username, actualPassword, database)
case "mongo":
return loadDatabasesForMongo(host, port, username, actualPassword, database, authSource, authMechanism)
case "redis":
return []string{}, nil
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", dbType)
}
}
func loadDatabasesForMySQL(host string, port int, username, password, defaultDatabase string) ([]string, error) {
config := &dbclient.MySQLConfig{
Host: host, Port: port, Username: username,
Password: password, Database: defaultDatabase,
}
client, err := dbclient.NewMySQLClient(config)
if err != nil {
return nil, err
}
defer client.Close()
return client.ListDatabases(context.Background())
}
func loadDatabasesForMongo(host string, port int, username, password, defaultDatabase, authSource, authMechanism string) ([]string, error) {
config := &dbclient.MongoConfig{
Host: host, Port: port, Username: username,
Password: password, Database: defaultDatabase,
AuthSource: authSource, AuthMechanism: authMechanism,
}
client, err := dbclient.NewMongoClient(config)
if err != nil {
return nil, err
}
defer client.Close()
return client.ListDatabases(context.Background())
}

View File

@@ -1,475 +0,0 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"u-desk/internal/common"
"u-desk/internal/dbclient"
"u-desk/internal/storage/models"
"u-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(), common.TimeoutQuery)
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) {
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
defer pc.Release()
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 := pc.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 := pc.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(), common.TimeoutFastQuery)
defer cancel()
switch conn.Type {
case "mysql":
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
defer pc.Release()
return pc.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(), common.TimeoutFastQuery)
defer cancel()
switch conn.Type {
case "mysql":
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
defer pc.Release()
return pc.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 = byte(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(), common.TimeoutQuery)
defer cancel()
switch conn.Type {
case "mysql":
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
defer pc.Release()
structure, err := pc.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(), common.TimeoutQuery)
defer cancel()
switch conn.Type {
case "mysql":
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
defer pc.Release()
return pc.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(), common.TimeoutLongOp)
defer cancel()
switch conn.Type {
case "mysql":
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
defer pc.Release()
return pc.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(), common.TimeoutLongOp)
defer cancel()
switch conn.Type {
case "mysql":
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
defer pc.Release()
return pc.Client.UpdateTableStructure(ctx, database, tableName, structure)
case "mongo":
client, err := s.pool.GetMongoClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
}
return client.UpdateCollectionIndexes(ctx, database, tableName, structure)
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
}
}

View File

@@ -1,31 +0,0 @@
package service
import (
"fmt"
"u-desk/internal/storage/models"
"u-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()
}

View File

@@ -15,7 +15,7 @@ import (
// ==================== 常量定义 ====================
// AppVersion 应用版本号(发布时直接修改此处)
const AppVersion = "0.3.3"
const AppVersion = "0.4.0"
// 版本号缓存
var (

View File

@@ -1,26 +0,0 @@
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格式
VisibleDatabases string `gorm:"type:text" json:"visible_databases"` // 可见数据库列表JSON数组为空则全部可见
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 指定表名
func (DbConnection) TableName() string {
return "db_connection"
}

View File

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

View File

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

View File

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

View File

@@ -1,90 +0,0 @@
package repository
import (
"encoding/json"
"u-desk/internal/storage"
"u-desk/internal/storage/models"
"gorm.io/gorm"
)
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)
Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error)
Delete(id uint) 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) 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
}

View File

@@ -1,51 +0,0 @@
package repository
import (
"u-desk/internal/storage"
"u-desk/internal/storage/models"
"gorm.io/gorm"
)
type TabRepository interface {
SaveAll(tabs []models.SqlTab) error
FindAll() ([]models.SqlTab, error)
Delete(id uint) 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
}

View File

@@ -62,9 +62,6 @@ func InitFast() (*gorm.DB, error) {
// AutoMigrate 在启动时执行,但只在表结构不存在时创建
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
if err := db.AutoMigrate(
&models.DbConnection{},
&models.SqlTab{},
&models.SqlResultHistory{},
&models.AppConfig{},
); err != nil {
return nil, err

View File

@@ -1,7 +1,7 @@
{
"name": "u-desk",
"outputfilename": "u-desk",
"version": "0.3.3",
"version": "0.4.0",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"author": {

View File

@@ -57,7 +57,7 @@
<a-layout-content class="content">
<!-- 动态渲染 Tab 内容 -->
<!-- 使用 KeepAlive 缓存组件状态避免切换时重新加载 -->
<KeepAlive include="FileSystem,DbCli">
<KeepAlive include="FileSystem">
<component :is="getComponent(activeTab)"/>
</KeepAlive>
</a-layout-content>
@@ -94,7 +94,6 @@
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
import MarkdownEditor from './views/markdown-editor/index.vue'
import DbCli from './views/db-cli/index.vue'
import VersionHistory from './views/version/index.vue'
import ThemeToggle from './components/ThemeToggle.vue'
import FileSystem from './components/FileSystem/index.vue'
@@ -152,7 +151,6 @@ const loadConfig = async () => {
const getComponent = (key: string) => {
const components = {
'file-system': FileSystem,
'db-cli': DbCli,
'markdown-editor': MarkdownEditor
}
return components[key] || null

View File

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

View File

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

View File

@@ -3,9 +3,4 @@
*/
export * from './types'
export * from './connection'
export * from './database'
export * from './structure'
export * from './query'
export * from './tab'
export * from './system'

View File

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

View File

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

View File

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

View File

@@ -2,75 +2,6 @@
* API 类型定义
*/
// 连接
export interface Connection {
id: number
name: string
dbType: string
host: string
port: number
username: string
database?: string
createdAt?: string
}
// 数据库和表
export interface Database {
name: string
tableCount?: number
}
export interface Table {
name: string
type?: string
}
// 表结构
export interface Column {
Field: string
Type: string
Null: string
Key: string
Default: string | null
Comment: string
Extra?: string
}
export interface Index {
Key_name: string
Column_name: string
Non_unique: number
Seq_in_index: number
Index_type: string
}
export interface Structure {
database: string
table: string
type: 'mysql' | 'mongo' | 'redis'
columns?: Column[]
indexes?: Index[]
structure?: any
info?: any
}
// SQL 查询
export interface QueryResult {
columns: string[]
data: any[]
rowsAffected: number
executionTime: number
}
// 标签页
export interface Tab {
id?: number
title: string
content: string
connectionId?: number | null
order?: number
}
// 系统信息
export interface SystemInfo {
os: string

View File

@@ -1,50 +0,0 @@
/**
* 可见数据库管理 Composable
* 封装 visible_databases 字段的解析和过滤逻辑
*/
/**
* 解析可见数据库 JSON 字符串
* @param jsonStr - JSON 字符串或 null
* @returns 解析后的数据库数组,解析失败返回空数组
*/
export function parseVisibleDatabases(jsonStr: string | null): string[] {
if (!jsonStr) return []
try {
const parsed = JSON.parse(jsonStr)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/**
* 根据可见数据库配置过滤数据库列表
* @param databases - 完整的数据库列表
* @param visibleJson - 可见数据库 JSON 字符串
* @returns 过滤后的数据库列表(如果未配置过滤则返回全部)
*/
export function filterDatabases(databases: string[], visibleJson: string | null): string[] {
const visible = parseVisibleDatabases(visibleJson)
return visible.length > 0 ? databases.filter(db => visible.includes(db)) : databases
}
/**
* 将数据库数组序列化为 JSON 字符串(空数组返回空字符串)
* @param databases - 数据库数组
* @returns JSON 字符串或空字符串
*/
export function serializeVisibleDatabases(databases: string[]): string {
return databases.length > 0 ? JSON.stringify(databases) : ''
}
/**
* 可见数据库管理 Composable
*/
export function useVisibleDatabases() {
return {
parse: parseVisibleDatabases,
filter: filterDatabases,
serialize: serializeVisibleDatabases,
}
}

View File

@@ -44,8 +44,7 @@ export const useConfigStore = defineStore('config', () => {
if (!tabs?.length) {
return [
{ key: 'file-system', title: '文件管理' },
{ key: 'db-cli', title: '数据库' }
{ key: 'file-system', title: '文件管理' }
]
}
@@ -93,8 +92,8 @@ export const useConfigStore = defineStore('config', () => {
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data
// 一级 Tab 只有文件管理和数据库其他功能Markdown、版本历史不作为独立 Tab
const allKeys = ['file-system', 'db-cli']
const tabTitles: Record<string, string> = { 'file-system': '文件管理', 'db-cli': '数据库' }
const allKeys = ['file-system']
const tabTitles: Record<string, string> = { 'file-system': '文件管理' }
const mergedTabs = allKeys.map(key => tabs.find(t => t.key === key) || { key, title: tabTitles[key] || key, enabled: true })
const mergedVisible = visibleTabs.length
? visibleTabs.filter(k => allKeys.includes(k))
@@ -119,10 +118,9 @@ export const useConfigStore = defineStore('config', () => {
const useDefaultConfig = () => {
appConfig.value = {
tabs: [
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
{ key: 'db-cli', title: '数据库', visible: true, enhanced: true }
{ key: 'file-system', title: '文件管理', visible: true, enabled: true }
],
visibleTabs: ['file-system', 'db-cli'],
visibleTabs: ['file-system'],
defaultTab: 'file-system'
}
}

View File

@@ -1,114 +0,0 @@
/**
* 数据库错误提示工具
* 将技术性错误信息转换为用户友好的提示
*/
/**
* 解析数据库连接错误,返回友好的提示信息
*/
export function getFriendlyDatabaseError(error: Error | string | unknown): string {
const errorMsg = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error)
// MySQL 错误码处理
if (errorMsg.includes('Error 1045') || errorMsg.includes('28000')) {
return '用户名或密码错误,请检查连接配置'
}
if (errorMsg.includes('Error 2002') || errorMsg.includes('Error 2003')) {
return '无法连接到数据库服务器,请检查主机地址和端口是否正确'
}
if (errorMsg.includes('Error 2003')) {
return '无法连接到 MySQL 服务器,请检查:\n1. 主机地址和端口是否正确\n2. MySQL 服务是否已启动\n3. 防火墙是否允许连接'
}
if (errorMsg.includes('Error 1045') || errorMsg.includes('Access denied')) {
return '认证失败,请检查:\n1. 用户名是否正确\n2. 密码是否正确\n3. 用户是否有访问权限'
}
if (errorMsg.includes('Error 1130') || errorMsg.includes('host is not allowed')) {
return '当前 IP 不被允许连接,请检查 MySQL 用户的主机访问权限配置'
}
if (errorMsg.includes('Error 10061') || errorMsg.includes('Connection refused')) {
return '连接被拒绝,请检查:\n1. 端口是否正确\n2. 数据库服务是否已启动'
}
if (errorMsg.includes('Error 10060') || errorMsg.includes('timeout')) {
return '连接超时,请检查:\n1. 网络连接是否正常\n2. 主机地址是否正确\n3. 防火墙设置'
}
if (errorMsg.includes('No connection') || errorMsg.includes('closed')) {
return '数据库连接已断开,请尝试重新连接'
}
// MongoDB 错误处理
if (errorMsg.includes('Authentication failed')) {
return 'MongoDB 认证失败,请检查用户名和密码'
}
if (errorMsg.includes('connection refused') || errorMsg.includes('ECONNREFUSED')) {
return '无法连接到 MongoDB 服务器,请检查服务是否已启动'
}
if (errorMsg.includes('ENOTFOUND') || errorMsg.includes('no such host')) {
return '主机地址无法解析,请检查主机名是否正确'
}
// Redis 错误处理
if (errorMsg.includes('NOAUTH')) {
return 'Redis 需要密码认证'
}
if (errorMsg.includes('WRONGPASS')) {
return 'Redis 密码错误'
}
if (errorMsg.includes('connection')) {
return '无法连接到 Redis 服务器,请检查:\n1. 主机地址和端口是否正确\n2. Redis 服务是否已启动'
}
// 网络相关
if (errorMsg.includes('network') || errorMsg.includes('ENETUNREACH')) {
return '网络连接失败,请检查网络设置'
}
// 通用错误
if (errorMsg.includes('获取 MySQL 客户端失败')) {
return '数据库连接初始化失败,请检查连接配置'
}
if (errorMsg.includes('连接.*失败')) {
return '数据库连接失败,请检查连接配置'
}
// 返回原始错误信息(去除技术性前缀)
const friendly = errorMsg
.replace(/^获取 MySQL 客户端失败:\s*/, '')
.replace(/^连接 MySQL 失败:\s*/, '')
.replace(/^连接 MongoDB 失败:\s*/, '')
.replace(/^连接 Redis 失败:\s*/, '')
.replace(/^Error \d+:\s*/, '')
return friendly || '未知错误,请检查连接配置'
}
/**
* 获取连接失败的友好提示
*/
export function getConnectionFailedTip(error: Error | string | unknown, dbType: string = 'mysql'): string {
const friendlyError = getFriendlyDatabaseError(error)
const dbTypeName = dbType === 'mysql' ? 'MySQL' : dbType === 'mongo' ? 'MongoDB' : 'Redis'
return `${dbTypeName} 连接失败:${friendlyError}`
}
/**
* 获取加载数据失败的友好提示
*/
export function getLoadFailedTip(error: Error | string | unknown, loadType: 'databases' | 'tables' | 'keys'): string {
const friendlyError = getFriendlyDatabaseError(error)
const typeText = loadType === 'databases' ? '数据库列表' : loadType === 'tables' ? '表列表' : '键列表'
return `加载${typeText}失败:${friendlyError}`
}

View File

@@ -1,713 +0,0 @@
<template>
<a-modal
v-model:visible="visible"
title="数据库连接配置"
width="600px"
:body-style="{ padding: '16px 20px' }"
@cancel="handleCancel"
>
<!-- 错误提示区域 -->
<a-alert
v-if="errorMessage"
type="error"
show-icon
closable
@close="errorMessage = ''"
class="error-alert"
>
{{ errorMessage }}
</a-alert>
<a-form :model="form" :rules="rules" ref="formRef" layout="horizontal" :label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }" size="small">
<a-form-item label="连接名称" field="name">
<a-input v-model="form.name" placeholder="请输入连接名称" size="small"/>
</a-form-item>
<a-form-item label="数据库类型" field="type">
<a-select v-model="form.type" placeholder="请选择数据库类型" @change="handleTypeChange" size="small">
<a-option value="mysql">MySQL</a-option>
<a-option value="redis">Redis</a-option>
<a-option value="mongo">MongoDB</a-option>
</a-select>
</a-form-item>
<a-form-item label="主机地址" field="host">
<a-input v-model="form.host" placeholder="请输入主机地址" size="small"/>
</a-form-item>
<a-form-item label="端口" field="port">
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="请输入端口" style="width: 100%"
size="small"/>
</a-form-item>
<a-form-item label="用户名" field="username" v-if="form.type !== 'redis'">
<a-input v-model="form.username" placeholder="请输入用户名" size="small"/>
</a-form-item>
<a-form-item label="密码" field="password">
<div v-if="props.connectionId && !isPasswordChanged" class="password-display">
<a-input
value="已保存的密码"
disabled
class="password-input"
size="small"
/>
<a-button type="text" size="mini" @click="isPasswordChanged = true">
修改密码
</a-button>
</div>
<a-input-password
v-else
v-model="form.password"
:placeholder="getPasswordPlaceholder()"
size="small"
/>
</a-form-item>
<a-form-item :label="form.type === 'redis' ? '数据库编号' : '数据库名'" field="database">
<a-input v-model="form.database"
:placeholder="form.type === 'redis' ? 'Redis DB 编号 (0-15默认为0)' : '可选,留空则连接所有数据库'"
:max-length="100"
size="small"/>
</a-form-item>
<!-- 数据库过滤选项 MySQL MongoDB -->
<template v-if="form.type === 'mysql' || form.type === 'mongo'">
<a-form-item label="可见数据库" field="visibleDatabases">
<div class="database-list-container">
<!-- 顶部工具栏 -->
<div class="list-toolbar">
<a-button
type="outline"
size="small"
@click="loadAllDatabases"
:loading="loadingDatabases"
:disabled="!canLoadDatabases"
>
<template #icon>
<icon-refresh />
</template>
{{ allDatabases.length > 0 ? '重新加载' : '加载数据库列表' }}
</a-button>
<template v-if="allDatabases.length > 0">
<div class="toolbar-stats">
已选 {{ selectedDatabases.length }} / {{ allDatabases.length }}
</div>
<a-button size="small" @click="handleSelectAll(true)">全选</a-button>
<a-button size="small" @click="handleInvertSelection">反选</a-button>
<a-button size="small" @click="handleSelectAll(false)">清空</a-button>
</template>
</div>
<!-- 空状态 -->
<div v-if="!loadingDatabases && allDatabases.length === 0" class="list-empty">
<icon-storage />
<span>点击上方按钮加载数据库列表</span>
</div>
<!-- 数据库列表复选框 -->
<div v-else class="database-checkbox-list">
<div
v-for="db in allDatabases"
:key="db"
class="database-checkbox-item"
>
<a-checkbox
:model-value="selectedDatabases.includes(db)"
:disabled="loadingDatabases"
@change="(checked: boolean) => toggleDatabase(db, checked)"
>
{{ db }}
</a-checkbox>
</div>
</div>
<!-- 提示信息 -->
<div v-if="allDatabases.length > 0" class="list-tip">
<icon-info-circle />
未选择任何数据库时将展示所有数据库
</div>
</div>
</a-form-item>
</template>
<!-- MongoDB 专用选项 -->
<template v-if="form.type === 'mongo'">
<a-form-item label="认证数据库" field="options.authSource">
<a-input v-model="optionsForm.authSource" placeholder="留空则使用 admin" size="small"/>
<template #extra>
<span class="form-item-extra">MongoDB 用户所在的数据库通常为 admin可选</span>
</template>
</a-form-item>
</template>
</a-form>
<template #footer>
<a-space size="small">
<a-button @click="handleTest" :loading="testing" size="small">测试连接</a-button>
<a-button @click="handleCancel" size="small">取消</a-button>
<a-button type="primary" @click="handleSubmit" :loading="saving" size="small">保存</a-button>
</a-space>
</template>
</a-modal>
</template>
<script setup lang="ts">
import {reactive, ref, watch, computed, nextTick} from 'vue'
import {Message} from '@arco-design/web-vue'
import {
IconCheckCircle,
IconClose,
IconInfoCircle,
IconRefresh,
IconStorage
} from '@arco-design/web-vue/es/icon'
import {
ListDbConnections,
SaveDbConnection
} from '../../../wailsjs/wailsjs/go/main/App'
import { getConnectionFailedTip, getLoadFailedTip } from '@/utils/database-error'
import { useVisibleDatabases } from '@/composables/useVisibleDatabases'
// 使用 defineModel 简化 v-model:visible 双向绑定Vue 3.5+
const visible = defineModel('visible', { type: Boolean, default: false })
// 使用 TypeScript 泛型语法Vue 3.5+
const props = defineProps<{
connectionId?: number | null
}>()
const emit = defineEmits<{
success: []
}>()
const formRef = ref<any>(null)
const saving = ref(false)
const testing = ref(false)
const errorMessage = ref('')
// 是否修改密码(编辑模式下)
const isPasswordChanged = ref(false)
// 数据库过滤相关
const { parse: parseVisibleDatabases, filter: filterVisibleDatabases } = useVisibleDatabases()
const loadingDatabases = ref(false)
const allDatabases = ref<string[]>([])
const selectedDatabases = ref<string[]>([])
// 是否可以加载数据库列表
const canLoadDatabases = computed(() =>
!!(form.host && form.port && form.username && (form.password || props.connectionId))
)
const form = reactive({
name: '',
type: 'mysql',
host: '127.0.0.1',
port: 3306,
username: '',
password: '',
database: '',
options: '',
visibleDatabases: ''
})
// 选项表单(用于表单输入)
const optionsForm = reactive({
authSource: ''
})
// 将 options JSON 字符串解析为 optionsForm
const parseOptionsToForm = (optionsStr: string) => {
if (!optionsStr || optionsStr.trim() === '') {
optionsForm.authSource = ''
return
}
try {
const opts = JSON.parse(optionsStr)
optionsForm.authSource = opts.authSource || ''
// 认证机制使用自动检测,不需要从选项读取
} catch (error) {
console.warn('解析 Options JSON 失败:', error)
// 解析失败时,清空表单选项
optionsForm.authSource = ''
}
}
// 将 optionsForm 和 form.options 合并为 JSON 字符串
const mergeOptionsToJson = (): string => {
let customOptions: any = {}
// 先解析已有的 JSON可能包含其他自定义选项
if (form.options && form.options.trim() !== '') {
try {
customOptions = JSON.parse(form.options)
} catch (error) {
console.warn('解析自定义 Options JSON 失败:', error)
}
}
// 根据数据库类型合并表单选项(仅 MongoDB
if (form.type === 'mongo') {
// 只有认证数据库不为空时才添加
if (optionsForm.authSource && optionsForm.authSource.trim() !== '') {
customOptions.authSource = optionsForm.authSource.trim()
}
// 认证机制使用自动检测,不需要添加到选项
}
// 如果没有任何选项,返回空字符串
if (Object.keys(customOptions).length === 0) {
return ''
}
return JSON.stringify(customOptions)
}
// 表单验证规则
const rules = {
name: [
{required: true, message: '请输入连接名称'},
{maxLength: 100, message: '连接名称长度不能超过100个字符'}
],
type: [{required: true, message: '请选择数据库类型'}],
host: [
{required: true, message: '请输入主机地址'},
{maxLength: 255, message: '主机地址长度不能超过255个字符'}
],
port: [
{required: true, message: '请输入端口'},
{
validator: (value, callback) => {
if (!value || value < 1 || value > 65535) {
callback('端口号必须在1-65535之间')
} else {
callback()
}
}
}
],
database: [
{
validator: (value, callback) => {
// MySQL 类型时数据库名为可选(允许为空)
// MongoDB 和 Redis 也为可选
callback()
}
}
]
}
// 获取密码输入框的占位符
const getPasswordPlaceholder = () => {
if (props.connectionId) return '请输入新密码'
const placeholders = { redis: '可选,留空则无密码连接', mongo: '可选,留空则无认证连接' }
return placeholders[form.type] || '请输入密码'
}
// 监听类型变化,设置默认端口、主机和用户名
const handleTypeChange = (type) => {
// 如果主机为空,设置默认值
if (!form.host || form.host.trim() === '') {
form.host = '127.0.0.1'
}
// 根据类型设置默认端口和用户名
switch (type) {
case 'mysql':
form.port = 3306
if (!form.username) {
form.username = 'root'
}
// 清空 MongoDB 专用选项
optionsForm.authSource = ''
form.options = ''
break
case 'redis':
form.port = 6379
form.username = '' // Redis 不需要用户名
if (!form.database) {
form.database = '0' // Redis 默认 DB 0
}
// 清空其他数据库的选项
optionsForm.authSource = ''
form.options = ''
break
case 'mongo':
case 'mongodb':
form.port = 27017
if (!form.username) {
form.username = 'admin' // MongoDB 常用默认用户名
}
break
}
// 类型变化时,同步更新 options JSON
form.options = mergeOptionsToJson()
}
// 加载连接详情
const loadConnection = async () => {
if (!props.connectionId) {
resetForm()
// 新建模式:不自动加载,等用户手动点击
return
}
isLoading.value = true
try {
if (!(window as any).go?.main?.App?.ListDbConnections) {
return
}
const connections = await (window as any).go.main.App.ListDbConnections()
const conn = connections.find(c => c.id === props.connectionId)
if (conn) {
form.name = conn.name
form.type = conn.type
form.host = conn.host || '127.0.0.1'
form.port = conn.port || (conn.type === 'mysql' ? 3306 : conn.type === 'redis' ? 6379 : 27017)
form.username = conn.username || ''
form.database = conn.database || ''
// 先解析 options 到表单
parseOptionsToForm(conn.options || '')
// 然后设置 form.options这样不会触发 watch
form.options = conn.options || ''
// 设置可见数据库
form.visibleDatabases = conn.visible_databases || ''
// 编辑模式下,默认不修改密码
form.password = ''
isPasswordChanged.value = false
// 恢复数据库选择
selectedDatabases.value = parseVisibleDatabases(conn.visible_databases || null)
// 编辑模式:自动加载数据库列表
nextTick(() => {
loadAllDatabases()
})
}
} catch (error) {
console.error('加载连接详情失败:', error)
} finally {
isLoading.value = false
}
}
// 获取要使用的密码(编辑模式下未修改密码时为空)
const getPasswordToUse = () =>
(props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
// 表单验证(返回 true 表示验证通过)
const validateForm = async () => {
if (!formRef.value) {
console.error('formRef 未初始化')
return false
}
errorMessage.value = ''
try {
await formRef.value.validate()
return true
} catch (error) {
const errorMsg = error?.fields?.[Object.keys(error.fields)[0]]?.[0]?.message || '请检查表单填写是否正确'
errorMessage.value = errorMsg
Message.warning(errorMsg)
return false
}
}
// 重置表单
const resetForm = () => {
form.name = ''
form.type = 'mysql'
form.host = '127.0.0.1'
form.port = 3306
form.username = 'root' // MySQL 默认用户名
form.password = ''
form.database = ''
form.options = ''
form.visibleDatabases = ''
optionsForm.authSource = ''
isPasswordChanged.value = false
loadingDatabases.value = false
allDatabases.value = []
selectedDatabases.value = []
}
// 测试连接(不保存数据)
const handleTest = async () => {
if (!(await validateForm())) return
// 检查 Go 后端是否可用
if (!(window as any).go?.main?.App) {
const msg = 'Go 后端未就绪,请确保应用已启动'
errorMessage.value = msg
Message.error(msg)
return
}
testing.value = true
try {
await (window as any).go.main.App.TestDbConnectionWithParams({
id: props.connectionId || 0,
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: getPasswordToUse(),
database: form.database || '',
options: mergeOptionsToJson()
})
Message.success('连接测试成功')
errorMessage.value = ''
} catch (error) {
const friendlyMsg = getConnectionFailedTip(error, form.type)
errorMessage.value = friendlyMsg
Message.error(friendlyMsg)
} finally {
testing.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!(await validateForm())) return
// 检查 Go 后端是否可用
if (!(window as any).go?.main?.App) {
const msg = 'Go 后端未就绪,请确保应用已启动'
errorMessage.value = msg
Message.error(msg)
return
}
saving.value = true
try {
await (window as any).go.main.App.SaveDbConnection({
id: props.connectionId || 0,
name: form.name,
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: getPasswordToUse(),
database: form.database || '',
options: mergeOptionsToJson(),
visible_databases: form.visibleDatabases || ''
})
Message.success(props.connectionId ? '更新成功' : '保存成功')
errorMessage.value = ''
emit('success')
visible.value = false
} catch (error) {
const errorMsg = error?.message || error?.toString() || '未知错误'
errorMessage.value = '保存失败: ' + errorMsg
Message.error('保存失败: ' + errorMsg)
} finally {
saving.value = false
}
}
// 取消
const handleCancel = () => {
errorMessage.value = ''
visible.value = false
resetForm()
}
// 是否正在加载连接(用于避免加载时触发 watch
const isLoading = ref(false)
// 监听 optionsForm 变化,自动同步到 form.options仅 MongoDB
watch(
() => [optionsForm.authSource, form.type],
() => {
// 如果正在加载,不触发更新
if (isLoading.value) {
return
}
// 仅 MongoDB 需要同步选项
if (visible.value && form.type === 'mongo') {
form.options = mergeOptionsToJson()
}
},
{ deep: true }
)
// 加载全部数据库列表
const loadAllDatabases = async () => {
if (!canLoadDatabases.value) {
Message.warning('请先填写连接信息')
return
}
loadingDatabases.value = true
try {
const databases = await (window as any).go.main.App.LoadAllDatabases({
id: props.connectionId || 0,
type: form.type,
host: form.host,
port: form.port,
username: form.username || '',
password: getPasswordToUse(),
database: form.database || '',
options: mergeOptionsToJson()
})
allDatabases.value = databases || []
// 从已保存的 visibleDatabases 中恢复选择(使用 composable
selectedDatabases.value = filterVisibleDatabases(databases, form.visibleDatabases || null)
Message.success(`成功加载 ${databases.length} 个数据库`)
} catch (error) {
Message.error(getLoadFailedTip(error, 'databases'))
} finally {
loadingDatabases.value = false
}
}
// 切换单个数据库选择
const toggleDatabase = (dbName: string, checked: boolean) => {
const list = selectedDatabases.value
if (checked && !list.includes(dbName)) {
list.push(dbName)
} else if (!checked) {
selectedDatabases.value = list.filter(db => db !== dbName)
}
}
// 全选/全不选
const handleSelectAll = (selectAll: boolean) => {
if (selectAll) {
selectedDatabases.value = [...allDatabases.value]
} else {
selectedDatabases.value = []
}
}
// 反选
const handleInvertSelection = () => {
const selectedSet = new Set(selectedDatabases.value)
selectedDatabases.value = allDatabases.value.filter(db => !selectedSet.has(db))
}
// 监听数据库选择变化,同步到表单
watch(selectedDatabases, (newVal) => {
if (newVal.length === 0) {
form.visibleDatabases = ''
} else {
form.visibleDatabases = JSON.stringify(newVal)
}
}, { deep: true })
// 监听 visible 变化
watch(visible, (val) => {
if (val) {
errorMessage.value = ''
loadConnection()
} else {
errorMessage.value = ''
resetForm()
}
})
</script>
<style scoped>
.error-alert {
margin-bottom: 12px;
}
.password-display {
display: flex;
align-items: center;
gap: 8px;
}
.password-input {
flex: 1;
}
.options-item {
margin-bottom: 8px;
}
.form-item-extra {
font-size: 12px;
color: var(--color-text-3);
margin-top: 4px;
display: block;
}
.database-list-container {
width: 100%;
}
/* 顶部工具栏 */
.list-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-stats {
font-size: 13px;
color: var(--color-text-2);
margin: 0 8px;
}
/* 空状态 */
.list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 20px;
color: var(--color-text-3);
font-size: 13px;
}
.list-empty svg {
font-size: 32px;
}
/* 复选框列表 */
.database-checkbox-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
max-height: 240px;
overflow-y: auto;
padding: 12px;
background: var(--color-fill-2);
border: 1px solid var(--color-border-2);
border-radius: 6px;
}
.database-checkbox-item {
display: flex;
align-items: center;
}
.database-checkbox-item :deep(.arco-checkbox) {
width: 100%;
font-size: 13px;
}
/* 底部提示 */
.list-tip {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: var(--color-fill-1);
border-radius: 4px;
font-size: 12px;
color: var(--color-text-3);
}
.list-tip svg {
color: var(--color-text-3);
font-size: 14px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,247 +0,0 @@
<template>
<div class="query-history-panel">
<div class="panel-header">
<h3>查询历史</h3>
<a-space>
<a-input-search
v-model="searchKeyword"
placeholder="搜索历史..."
style="width: 200px"
@search="handleSearch"
/>
<a-button
type="text"
size="small"
status="danger"
@click="handleClearAll"
>
<template #icon>
<icon-delete/>
</template>
清空
</a-button>
</a-space>
</div>
<div class="history-list">
<a-list
:data="displayedHistory"
:bordered="false"
size="small"
>
<template #item="{ item }">
<a-list-item class="history-item">
<div class="history-content">
<div class="history-header">
<a-tag size="small" :color="getDbTypeColor(item.dbType)">
{{ item.dbType.toUpperCase() }}
</a-tag>
<span class="history-time">{{ formatTime(item.timestamp) }}</span>
</div>
<div class="history-query" @click="handleUseQuery(item.query)">
{{ item.queryPreview }}
</div>
</div>
<template #actions>
<a-button
type="text"
size="small"
@click="handleUseQuery(item.query)"
>
<template #icon>
<icon-arrow-right/>
</template>
使用
</a-button>
<a-button
type="text"
size="small"
status="danger"
@click="handleDelete(item.id)"
>
<template #icon>
<icon-delete/>
</template>
</a-button>
</template>
</a-list-item>
</template>
</a-list>
<a-empty
v-if="displayedHistory.length === 0"
description="暂无查询历史"
:style="{ padding: '40px 0' }"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDelete, IconArrowRight } from '@arco-design/web-vue/es/icon'
import { useQueryHistory } from '../composables/useQueryHistory'
const props = defineProps({
connectionId: {
type: [String, Number],
default: null
}
})
const emit = defineEmits(['use-query'])
const queryHistory = useQueryHistory()
const searchKeyword = ref('')
const history = ref([])
// 显示的历史记录(搜索过滤后)
const displayedHistory = computed(() => {
if (!searchKeyword.value) {
return history.value
}
return queryHistory.searchHistory(searchKeyword.value)
})
onMounted(() => {
loadHistory()
})
const loadHistory = () => {
history.value = queryHistory.getHistory()
}
const handleSearch = () => {
// 搜索会自动触发 computed 更新
}
const handleUseQuery = (query) => {
emit('use-query', query)
Message.success('已加载查询语句')
}
const handleDelete = (id) => {
const success = queryHistory.deleteHistory(id)
if (success) {
loadHistory()
Message.success('删除成功')
}
}
const handleClearAll = () => {
if (confirm('确定要清空所有查询历史吗?')) {
const success = queryHistory.clearHistory()
if (success) {
loadHistory()
Message.success('已清空历史记录')
}
}
}
const getDbTypeColor = (dbType) => {
const colors = {
mysql: 'blue',
redis: 'red',
mongodb: 'green',
mongo: 'green'
}
return colors[dbType] || 'gray'
}
const formatTime = (timestamp) => {
const date = new Date(timestamp)
const now = new Date()
const diff = now - date
// 小于1分钟
if (diff < 60000) {
return '刚刚'
}
// 小于1小时
if (diff < 3600000) {
return `${Math.floor(diff / 60000)} 分钟前`
}
// 小于24小时
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)} 小时前`
}
// 大于24小时
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// 暴露方法供父组件调用
defineExpose({
addHistory: (query) => {
const record = queryHistory.addHistory(query, props.connectionId)
if (record) {
loadHistory()
}
}
})
</script>
<style scoped>
.query-history-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
flex-shrink: 0;
padding: 12px;
border-bottom: 1px solid var(--color-border-2);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.history-item {
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.history-item:hover {
background: var(--color-fill-2);
}
.history-content {
flex: 1;
}
.history-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.history-time {
font-size: 11px;
color: var(--color-text-3);
}
.history-query {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: var(--color-text-1);
line-height: 1.5;
word-break: break-all;
}
</style>

View File

@@ -1,330 +0,0 @@
<template>
<div class="query-templates-panel">
<div class="panel-header">
<h3>查询模板</h3>
<a-space>
<a-button
type="primary"
size="small"
@click="handleCreateTemplate"
>
<template #icon>
<icon-plus/>
</template>
新建模板
</a-button>
</a-space>
</div>
<div class="templates-list">
<a-collapse
:default-active-key="expandedCategories"
:bordered="false"
>
<a-collapse-item
v-for="(categoryTemplates, category) in groupedTemplates"
:key="category"
:header="category"
>
<template #extra>
<a-tag size="small">{{ categoryTemplates.length }}</a-tag>
</template>
<div class="template-grid">
<div
v-for="template in categoryTemplates"
:key="template.id"
class="template-card"
@click="handleUseTemplate(template)"
>
<div class="template-header">
<span class="template-name">{{ template.name }}</span>
<a-dropdown
trigger="click"
@click.stop
>
<a-button type="text" size="mini">
<icon-more/>
</a-button>
<template #content>
<a-doption @click="handleEditTemplate(template)">
<icon-edit/>
编辑
</a-doption>
<a-doption
style="color: var(--color-danger-6)"
@click="handleDeleteTemplate(template.id)"
>
<icon-delete/>
删除
</a-doption>
</template>
</a-dropdown>
</div>
<div class="template-description">
{{ template.description || '无描述' }}
</div>
<div class="template-query">
<code>{{ template.query.substring(0, 80) }}{{ template.query.length > 80 ? '...' : '' }}</code>
</div>
</div>
</div>
</a-collapse-item>
</a-collapse>
<a-empty
v-if="Object.keys(groupedTemplates).length === 0"
description="暂无模板"
:style="{ padding: '40px 0' }"
/>
</div>
<!-- 新建/编辑模板弹窗 -->
<a-modal
v-model:visible="showTemplateModal"
:title="isEditing ? '编辑模板' : '新建模板'"
:width="600"
@ok="handleSaveTemplate"
>
<a-form :model="templateForm" layout="vertical">
<a-form-item label="模板名称" required>
<a-input
v-model="templateForm.name"
placeholder="例如:分页查询"
/>
</a-form-item>
<a-form-item label="分类">
<a-select
v-model="templateForm.category"
placeholder="选择分类"
:options="categoryOptions"
allow-create
/>
</a-form-item>
<a-form-item label="描述">
<a-textarea
v-model="templateForm.description"
placeholder="简短描述模板用途"
:max-length="100"
show-word-limit
/>
</a-form-item>
<a-form-item label="SQL 语句" required>
<a-textarea
v-model="templateForm.query"
placeholder="输入 SQL 语句"
:auto-size="{ minRows: 6, maxRows: 12 }"
style="font-family: 'Monaco', 'Menlo', monospace"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconPlus, IconMore, IconEdit, IconDelete
} from '@arco-design/web-vue/es/icon'
import { useQueryTemplates } from '../composables/useQueryTemplates'
const props = defineProps({
dbType: {
type: String,
default: 'mysql'
}
})
const emit = defineEmits(['use-template'])
const queryTemplates = useQueryTemplates()
const templates = ref([])
const expandedCategories = ref(['基础查询'])
const showTemplateModal = ref(false)
const isEditing = ref(false)
const templateForm = ref({
id: null,
name: '',
category: '自定义',
description: '',
query: ''
})
const categoryOptions = [
'基础查询', '分页', '统计', '插入', '更新', '删除',
'Join', '聚合', '子查询', 'Redis', 'MongoDB', '自定义'
]
// 按分类分组
const groupedTemplates = computed(() => {
const filtered = queryTemplates.getTemplatesByType(props.dbType)
const groups = {}
filtered.forEach(template => {
const category = template.category || '自定义'
if (!groups[category]) {
groups[category] = []
}
groups[category].push(template)
})
return groups
})
onMounted(() => {
loadTemplates()
})
const loadTemplates = () => {
templates.value = queryTemplates.getTemplates()
}
const handleUseTemplate = (template) => {
emit('use-template', template.query)
Message.success(`已应用模板:${template.name}`)
}
const handleCreateTemplate = () => {
isEditing.value = false
templateForm.value = {
id: null,
name: '',
category: '自定义',
description: '',
query: ''
}
showTemplateModal.value = true
}
const handleEditTemplate = (template) => {
isEditing.value = true
templateForm.value = { ...template }
showTemplateModal.value = true
}
const handleSaveTemplate = () => {
if (!templateForm.value.name.trim()) {
Message.warning('请输入模板名称')
return
}
if (!templateForm.value.query.trim()) {
Message.warning('请输入 SQL 语句')
return
}
let result
if (isEditing.value) {
result = queryTemplates.updateTemplate(templateForm.value.id, templateForm.value)
} else {
result = queryTemplates.saveTemplate({
...templateForm.value,
dbType: props.dbType
})
}
if (result) {
loadTemplates()
showTemplateModal.value = false
Message.success(isEditing.value ? '模板已更新' : '模板已创建')
}
}
const handleDeleteTemplate = (id) => {
if (confirm('确定要删除此模板吗?')) {
const success = queryTemplates.deleteTemplate(id)
if (success) {
loadTemplates()
Message.success('模板已删除')
}
}
}
</script>
<style scoped>
.query-templates-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
flex-shrink: 0;
padding: 12px;
border-bottom: 1px solid var(--color-border-2);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.templates-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
padding: 8px 0;
}
.template-card {
padding: 12px;
border: 1px solid var(--color-border-2);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.template-card:hover {
border-color: var(--color-primary-6);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.template-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.template-name {
font-weight: 600;
font-size: 13px;
color: var(--color-text-1);
}
.template-description {
font-size: 12px;
color: var(--color-text-3);
margin-bottom: 8px;
}
.template-query {
font-size: 11px;
color: var(--color-text-2);
}
.template-query code {
background: var(--color-fill-2);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', monospace;
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,267 +0,0 @@
<template>
<div class="sql-editor-toolbar">
<div class="toolbar-left">
<a-space>
<a-button type="primary" @click="handleExecute">
<template #icon>
<icon-play-arrow/>
</template>
{{ executeButtonText }} (F5)
</a-button>
<a-button type="outline" @click="handleExecuteSelected">
<template #icon>
<icon-code/>
</template>
执行选中 (Ctrl+Enter)
</a-button>
</a-space>
</div>
<div class="toolbar-center">
<a-space>
<a-button-group>
<a-button type="text" size="small" @click="handleFormat">
<template #icon>
<icon-thunderbolt/>
</template>
格式化
</a-button>
<a-button type="text" size="small" @click="handleShowHistory">
<template #icon>
<icon-history/>
</template>
历史
</a-button>
<a-button type="text" size="small" @click="handleShowTemplates">
<template #icon>
<icon-book/>
</template>
模板
</a-button>
</a-button-group>
<a-dropdown trigger="click">
<a-button type="text" size="small">
<template #icon>
<icon-download/>
</template>
导出
<icon-down/>
</a-button>
<template #content>
<a-doption @click="handleExport('csv')">
<icon-file/>
CSV 格式
</a-doption>
<a-doption @click="handleExport('json')">
<icon-file/>
JSON 格式
</a-doption>
<a-doption @click="handleExport('excel')">
<icon-file/>
Excel 格式
</a-doption>
<a-doption @click="handleExport('markdown')">
<icon-file/>
Markdown 表格
</a-doption>
</template>
</a-dropdown>
</a-space>
</div>
<div class="toolbar-right">
<a-space v-if="currentConnection">
<a-tag color="blue" size="small">
<template #icon>
<icon-storage/>
</template>
{{ currentConnection.name }}
</a-tag>
<span class="connection-info">
{{ currentConnection.host }}:{{ currentConnection.port }}
<span v-if="currentConnection.database" class="database-name">
/ {{ currentConnection.database }}
</span>
</span>
<!-- 执行时间显示 -->
<a-tag
v-if="executionTime !== null"
:color="getExecutionTimeColor(executionTime)"
size="small"
>
<template #icon>
<icon-clock-circle/>
</template>
{{ executionTime }}ms
</a-tag>
</a-space>
<span v-else class="connection-info-empty">
未选择连接
</span>
</div>
<!-- 历史记录抽屉 -->
<a-drawer
v-model:visible="showHistoryDrawer"
title="查询历史"
:width="400"
placement="right"
:footer="false"
>
<QueryHistoryPanel
ref="historyPanelRef"
:connection-id="currentConnection?.id"
@use-query="handleUseHistoryQuery"
/>
</a-drawer>
<!-- 模板抽屉 -->
<a-drawer
v-model:visible="showTemplatesDrawer"
title="查询模板"
:width="600"
placement="right"
:footer="false"
>
<QueryTemplatesPanel
:db-type="dbType"
@use-template="handleUseTemplate"
/>
</a-drawer>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconPlayArrow, IconCode, IconStorage, IconHistory, IconBook,
IconDownload, IconDown, IconFile, IconClockCircle, IconThunderbolt
} from '@arco-design/web-vue/es/icon'
import { formatSQL } from '../utils/sqlFormatter'
import QueryHistoryPanel from './QueryHistoryPanel.vue'
import QueryTemplatesPanel from './QueryTemplatesPanel.vue'
const props = defineProps({
currentConnection: {
type: Object,
default: null
},
executionTime: {
type: Number,
default: null
}
})
const emit = defineEmits([
'execute',
'execute-selected',
'format',
'export'
])
const showHistoryDrawer = ref(false)
const showTemplatesDrawer = ref(false)
const historyPanelRef = ref(null)
const dbType = computed(() =>
props.currentConnection?.type?.toLowerCase() || 'mysql'
)
const executeButtonText = computed(() => {
const type = dbType.value
if (type === 'redis') return '执行命令'
if (type === 'mongodb' || type === 'mongo') return '执行查询'
return '执行'
})
const handleExecute = () => {
emit('execute')
}
const handleExecuteSelected = () => {
emit('execute-selected')
}
const handleFormat = () => {
emit('format')
}
const handleShowHistory = () => {
showHistoryDrawer.value = true
}
const handleShowTemplates = () => {
showTemplatesDrawer.value = true
}
const handleUseHistoryQuery = (query) => {
showHistoryDrawer.value = false
emit('execute', query)
}
const handleUseTemplate = (query) => {
showTemplatesDrawer.value = false
emit('execute', query)
}
const handleExport = (format) => {
emit('export', format)
}
const getExecutionTimeColor = (ms) => {
if (ms < 100) return 'green'
if (ms < 500) return 'orange'
return 'red'
}
// 暴露方法供父组件调用
defineExpose({
addHistory: (query) => {
historyPanelRef.value?.addHistory(query)
}
})
</script>
<style scoped>
.sql-editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-2);
background: var(--color-bg-1);
}
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
}
.toolbar-center {
flex: 1;
justify-content: center;
}
.connection-info {
font-size: 12px;
font-family: 'Monaco', 'Menlo', monospace;
color: var(--color-text-1);
}
.database-name {
margin-left: 8px;
color: var(--color-text-2);
}
.connection-info-empty {
font-size: 12px;
color: var(--color-text-3);
font-style: italic;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,108 +0,0 @@
/**
* 查询历史管理
* 用于存储和快速重用之前的查询
*/
const STORAGE_KEY = 'db-cli:query-history'
const MAX_HISTORY = 50 // 最多保存50条历史
export function useQueryHistory() {
/**
* 获取查询历史
*/
const getHistory = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return []
return JSON.parse(stored)
} catch (e) {
console.error('Failed to load query history:', e)
return []
}
}
/**
* 添加查询到历史
*/
const addHistory = (query, connectionId = null, dbType = 'mysql') => {
if (!query || !query.trim()) return
const history = getHistory()
const trimmedQuery = query.trim()
// 移除重复项
const filtered = history.filter(
item => item.query !== trimmedQuery || item.connectionId !== connectionId
)
// 添加新记录到开头
const newRecord = {
id: Date.now(),
query: trimmedQuery,
connectionId,
dbType,
timestamp: new Date().toISOString(),
queryPreview: trimmedQuery.substring(0, 100) + (trimmedQuery.length > 100 ? '...' : '')
}
const updated = [newRecord, ...filtered].slice(0, MAX_HISTORY)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
return newRecord
} catch (e) {
console.error('Failed to save query history:', e)
return null
}
}
/**
* 清除历史
*/
const clearHistory = () => {
try {
localStorage.removeItem(STORAGE_KEY)
return true
} catch (e) {
console.error('Failed to clear query history:', e)
return false
}
}
/**
* 删除单条历史
*/
const deleteHistory = (id) => {
const history = getHistory()
const filtered = history.filter(item => item.id !== id)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
return true
} catch (e) {
console.error('Failed to delete query history:', e)
return false
}
}
/**
* 搜索历史
*/
const searchHistory = (keyword) => {
const history = getHistory()
if (!keyword) return history
const lowerKeyword = keyword.toLowerCase()
return history.filter(item =>
item.query.toLowerCase().includes(lowerKeyword) ||
item.queryPreview.toLowerCase().includes(lowerKeyword)
)
}
return {
getHistory,
addHistory,
clearHistory,
deleteHistory,
searchHistory
}
}

View File

@@ -1,195 +0,0 @@
/**
* 查询模板管理
* 用于保存常用查询模板
*/
const STORAGE_KEY = 'db-cli:query-templates'
// 默认模板
const DEFAULT_TEMPLATES = [
{
id: 'template-1',
name: '查询所有数据',
description: '查询表中所有数据',
query: 'SELECT * FROM table_name WHERE 1=1;',
category: '基础查询'
},
{
id: 'template-2',
name: '分页查询',
description: '带分页的数据查询',
query: 'SELECT * FROM table_name WHERE 1=1 LIMIT 10 OFFSET 0;',
category: '分页'
},
{
id: 'template-3',
name: '统计查询',
description: '统计数据行数',
query: 'SELECT COUNT(*) as total FROM table_name WHERE 1=1;',
category: '统计'
},
{
id: 'template-4',
name: '插入数据',
description: '插入单条数据',
query: 'INSERT INTO table_name (column1, column2) VALUES (value1, value2);',
category: '插入'
},
{
id: 'template-5',
name: '更新数据',
description: '更新指定条件的数据',
query: 'UPDATE table_name SET column1 = value1 WHERE id = 1;',
category: '更新'
},
{
id: 'template-6',
name: '删除数据',
description: '删除指定条件的数据',
query: 'DELETE FROM table_name WHERE id = 1;',
category: '删除'
},
{
id: 'template-redis-1',
name: 'Redis - 设置键值',
description: 'SET 命令',
query: 'SET key value',
category: 'Redis',
dbType: 'redis'
},
{
id: 'template-redis-2',
name: 'Redis - 获取键值',
description: 'GET 命令',
query: 'GET key',
category: 'Redis',
dbType: 'redis'
},
{
id: 'template-mongo-1',
name: 'MongoDB - 查询数据',
description: 'find 查询',
query: 'db.collection.find({ field: value })',
category: 'MongoDB',
dbType: 'mongodb'
}
]
export function useQueryTemplates() {
/**
* 获取模板列表
*/
const getTemplates = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) {
// 首次使用,保存默认模板
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_TEMPLATES))
return DEFAULT_TEMPLATES
}
return JSON.parse(stored)
} catch (e) {
console.error('Failed to load query templates:', e)
return DEFAULT_TEMPLATES
}
}
/**
* 保存模板
*/
const saveTemplate = (template) => {
const templates = getTemplates()
const newTemplate = {
id: `template-${Date.now()}`,
name: template.name || '未命名模板',
description: template.description || '',
query: template.query,
category: template.category || '自定义',
dbType: template.dbType || 'mysql',
createdAt: new Date().toISOString()
}
const updated = [...templates, newTemplate]
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
return newTemplate
} catch (e) {
console.error('Failed to save query template:', e)
return null
}
}
/**
* 更新模板
*/
const updateTemplate = (id, updates) => {
const templates = getTemplates()
const index = templates.findIndex(t => t.id === id)
if (index === -1) return null
templates[index] = {
...templates[index],
...updates,
updatedAt: new Date().toISOString()
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates))
return templates[index]
} catch (e) {
console.error('Failed to update query template:', e)
return null
}
}
/**
* 删除模板
*/
const deleteTemplate = (id) => {
const templates = getTemplates()
const filtered = templates.filter(t => t.id !== id)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
return true
} catch (e) {
console.error('Failed to delete query template:', e)
return false
}
}
/**
* 根据数据库类型筛选模板
*/
const getTemplatesByType = (dbType) => {
const templates = getTemplates()
if (!dbType) return templates
// 通用模板(无 dbType + 匹配当前 dbType 的模板
return templates.filter(t => !t.dbType || t.dbType === dbType.toLowerCase())
}
/**
* 重置为默认模板
*/
const resetToDefaults = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_TEMPLATES))
return true
} catch (e) {
console.error('Failed to reset query templates:', e)
return false
}
}
return {
getTemplates,
saveTemplate,
updateTemplate,
deleteTemplate,
getTemplatesByType,
resetToDefaults
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,135 +0,0 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { GetTableStructure } from '../../wailsjs/wailsjs/go/main/App'
/**
* 表结构状态管理 Composable
* 负责管理表结构查看相关的状态和数据
*/
export function useStructureState() {
// 状态
const structureLoading = ref(false)
const structureError = ref('')
const structureData = ref<any>(null)
const structureInfo = ref<{
connectionId: number
database: string
tableName: string
dbType: 'mysql' | 'mongo' | 'redis'
nodeType: string
} | null>(null)
/**
* 加载表结构
* @param connectionId 连接ID
* @param database 数据库名
* @param tableName 表名/集合名/Key名
* @param dbType 数据库类型
* @param nodeType 节点类型
*/
const loadStructure = async (
connectionId: number,
database: string,
tableName: string,
dbType: 'mysql' | 'mongo' | 'redis',
nodeType: string
) => {
// 对于连接和数据库节点,不需要加载结构
if (nodeType === 'connection' || nodeType === 'database') {
structureInfo.value = {
connectionId,
database,
tableName: '',
dbType,
nodeType
}
structureData.value = null
return
}
// 如果没有表名,不加载(但保留 structureInfo 用于显示提示)
if (!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 也设置了
structureInfo.value = {
connectionId,
database,
tableName,
dbType,
nodeType
}
} catch (error: unknown) {
console.error('加载表结构失败:', error)
const errorMessage = error instanceof Error ? error.message : '加载表结构失败'
structureError.value = errorMessage
Message.error('加载表结构失败: ' + errorMessage)
structureData.value = null
structureInfo.value = null
} finally {
structureLoading.value = false
}
}
/**
* 清空结构数据
*/
const clearStructure = () => {
structureData.value = null
structureInfo.value = null
structureError.value = ''
}
/**
* 刷新结构数据
*/
const refreshStructure = async () => {
if (!structureInfo.value) return
await loadStructure(
structureInfo.value.connectionId,
structureInfo.value.database,
structureInfo.value.tableName,
structureInfo.value.dbType,
structureInfo.value.nodeType
)
}
return {
// 状态
structureLoading,
structureError,
structureData,
structureInfo,
// 方法
loadStructure,
clearStructure,
refreshStructure
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
// 保留向后兼容,内部使用通用工具
export { createResizeHandler, type ResizeOptions } from '../../../utils/resize'

View File

@@ -1,219 +0,0 @@
/**
* 查询结果导出工具
* 支持 CSV、JSON、Excel 格式
*/
import { escapeHtml } from '@/utils/fileUtils'
/**
* 导出为 CSV
*/
export function exportToCSV(data, columns = [], filename = 'query-result.csv') {
if (!data || !Array.isArray(data) || data.length === 0) {
console.warn('No data to export')
return false
}
try {
// 自动检测列名
let headers = columns
if (headers.length === 0) {
headers = Object.keys(data[0])
}
// 构建 CSV 内容
const rows = []
// 表头
rows.push(headers.join(','))
// 数据行
data.forEach(row => {
const values = headers.map(header => {
const value = row[header]
// 处理 null/undefined
if (value === null || value === undefined) return ''
// 处理包含逗号或引号的值
const strValue = String(value)
if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
return `"${strValue.replace(/"/g, '""')}"`
}
return strValue
})
rows.push(values.join(','))
})
// 添加 BOM 使 Excel 正确识别 UTF-8
const BOM = '\uFEFF'
const csvContent = BOM + rows.join('\n')
// 创建下载
downloadFile(csvContent, filename, 'text/csv;charset=utf-8')
return true
} catch (e) {
console.error('Failed to export CSV:', e)
return false
}
}
/**
* 导出为 JSON
*/
export function exportToJSON(data, filename = 'query-result.json', pretty = true) {
if (!data || !Array.isArray(data)) {
console.warn('No data to export')
return false
}
try {
const jsonContent = pretty
? JSON.stringify(data, null, 2)
: JSON.stringify(data)
downloadFile(jsonContent, filename, 'application/json;charset=utf-8')
return true
} catch (e) {
console.error('Failed to export JSON:', e)
return false
}
}
/**
* 导出为 Markdown 表格
*/
export function exportToMarkdown(data, columns = [], filename = 'query-result.md') {
if (!data || !Array.isArray(data) || data.length === 0) {
console.warn('No data to export')
return false
}
try {
// 自动检测列名
let headers = columns
if (headers.length === 0) {
headers = Object.keys(data[0])
}
// 构建 Markdown 表格
const rows = []
// 表头
rows.push('| ' + headers.join(' | ') + ' |')
rows.push('| ' + headers.map(() => '---').join(' | ') + ' |')
// 数据行
data.forEach(row => {
const values = headers.map(header => {
const value = row[header]
if (value === null || value === undefined) return ''
// 转义管道符
return String(value).replace(/\|/g, '\\|')
})
rows.push('| ' + values.join(' | ') + ' |')
})
const mdContent = rows.join('\n')
downloadFile(mdContent, filename, 'text/markdown;charset=utf-8')
return true
} catch (e) {
console.error('Failed to export Markdown:', e)
return false
}
}
/**
* 导出为 Excel (HTML 表格格式)
*/
export function exportToExcel(data, columns = [], filename = 'query-result.xls') {
if (!data || !Array.isArray(data) || data.length === 0) {
console.warn('No data to export')
return false
}
try {
// 自动检测列名
let headers = columns
if (headers.length === 0) {
headers = Object.keys(data[0])
}
// 构建 HTML 表格
let html = '<table>\n'
// 表头
html += ' <thead>\n <tr>\n'
headers.forEach(header => {
html += ` <th><b>${escapeHtml(header)}</b></th>\n`
})
html += ' </tr>\n </thead>\n'
// 表体
html += ' <tbody>\n'
data.forEach(row => {
html += ' <tr>\n'
headers.forEach(header => {
const value = row[header]
const displayValue = value === null || value === undefined ? '' : String(value)
html += ` <td>${escapeHtml(displayValue)}</td>\n`
})
html += ' </tr>\n'
})
html += ' </tbody>\n'
html += '</table>'
downloadFile(html, filename, 'application/vnd.ms-excel;charset=utf-8')
return true
} catch (e) {
console.error('Failed to export Excel:', e)
return false
}
}
/**
* 下载文件
*/
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* 复制到剪贴板
*/
export async function copyToClipboard(data, format = 'json') {
if (!data) return false
try {
let content = ''
switch (format) {
case 'json':
content = JSON.stringify(data, null, 2)
break
case 'csv':
if (data.length === 0) return false
const headers = Object.keys(data[0])
content = headers.join(',') + '\n'
data.forEach(row => {
content += headers.map(h => row[h] ?? '').join(',') + '\n'
})
break
default:
content = String(data)
}
await navigator.clipboard.writeText(content)
return true
} catch (e) {
console.error('Failed to copy to clipboard:', e)
return false
}
}

View File

@@ -1,124 +0,0 @@
/**
* SQL 格式化工具
* 简单的 SQL 美化工具
*/
/**
* SQL 关键字列表
*/
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN',
'OUTER JOIN', 'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN',
'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET',
'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE',
'UNION', 'UNION ALL', 'DISTINCT', 'AS',
'ASC', 'DESC', 'NULL', 'IS NULL', 'IS NOT NULL',
'PRIMARY KEY', 'FOREIGN KEY', 'REFERENCES', 'UNIQUE',
'INDEX', 'CASCADE', 'RESTRICT', 'NO ACTION',
'CASE', 'WHEN', 'THEN', 'ELSE', 'END'
]
/**
* 简单的 SQL 格式化
* @param {string} sql - 原始 SQL
* @param {object} options - 格式化选项
* @returns {string} - 格式化后的 SQL
*/
export function formatSQL(sql, options = {}) {
const {
indent = ' ', // 缩进字符串
uppercase = true, // 关键字大写
linesBetweenQueries = 2 // 查询之间的空行数
} = options
if (!sql || typeof sql !== 'string') return ''
// 移除多余的空白
let formatted = sql.trim()
// 分割成多个查询
const queries = formatted.split(';').filter(q => q.trim())
const formattedQueries = queries.map(query => {
return formatSingleQuery(query.trim(), { indent, uppercase })
})
// 用空行连接查询
return formattedQueries.join('\n'.repeat(linesBetweenQueries + 1)) +
(formattedQueries.length > 0 ? ';\n' : '')
}
/**
* 格式化单个查询
*/
function formatSingleQuery(query, { indent, uppercase }) {
if (!query) return ''
// 关键字转大写/小写
let result = query
if (uppercase) {
SQL_KEYWORDS.forEach(keyword => {
const regex = new RegExp(`\\b${keyword}\\b`, 'gi')
result = result.replace(regex, keyword)
})
}
// 在关键字前后添加换行
const lineBreakKeywords = [
'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN',
'OUTER JOIN', 'ON', 'AND', 'OR', 'ORDER BY', 'GROUP BY', 'HAVING',
'LIMIT', 'OFFSET', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', 'UNION', 'UNION ALL'
]
lineBreakKeywords.forEach(keyword => {
const regex = new RegExp(`\\s+${keyword}\\s+`, 'gi')
result = result.replace(regex, `\n${keyword} `)
})
// 处理逗号
result = result.replace(/,\s*/g, ',\n' + indent)
// 移除开头的换行
result = result.replace(/^\n+/, '')
// 按行分割并处理缩进
const lines = result.split('\n')
let indentLevel = 0
const formattedLines = lines.map(line => {
line = line.trim()
if (!line) return ''
// 减少缩进的关键字
if (/^(FROM|WHERE|ORDER BY|GROUP BY|HAVING|LIMIT|OFFSET)/i.test(line)) {
indentLevel = 0
}
// 添加缩进
let formattedLine = indent.repeat(indentLevel) + line
// 增加缩进的关键字
if (/^(FROM|WHERE|JOIN|ON|AND|OR|ORDER BY|GROUP BY|HAVING|VALUES|SET)/i.test(line)) {
// 保持当前缩进级别
} else if (/^(INSERT INTO|UPDATE|DELETE FROM|CREATE TABLE)/i.test(line)) {
indentLevel = 1
}
return formattedLine
})
return formattedLines.filter(line => line.trim()).join('\n')
}
/**
* 简单格式化(单行压缩)
*/
export function minifySQL(sql) {
if (!sql || typeof sql !== 'string') return ''
return sql
.replace(/\s+/g, ' ')
.replace(/\s*,\s*/g, ',')
.replace(/\s*;\s*/g, ';')
.trim()
}