重构:移除数据库客户端模块 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:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)
|
||||
|
||||
### 变更说明
|
||||
- 顶部 Tab 仅保留「文件管理」,移除数据库入口
|
||||
- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响
|
||||
- 本地 SQLite 配置存储(AppConfig)保留不变
|
||||
|
||||
---
|
||||
|
||||
## [0.3.4] - 2026-04-22
|
||||
|
||||
### 新增 ✨
|
||||
|
||||
164
app.go
164
app.go
@@ -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 {
|
||||
|
||||
@@ -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 26MB,UPX 压缩后 7.5MB(压缩率 28.8%)\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响", "force_update": false, "release_date": "2026-04-25", "file_size": 7766016}
|
||||
@@ -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 降至 26MB,UPX 压缩后仅 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": ""}]}
|
||||
@@ -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
13
go.mod
@@ -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
49
go.sum
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 // 长时间操作超时
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 执行更新 SQL(INSERT/UPDATE/DELETE)
|
||||
// database 参数可选,如果提供且不为空则优先使用,否则使用配置中的数据库
|
||||
func (c *MySQLClient) ExecuteUpdate(ctx context.Context, sqlStr string, database string) (int64, error) {
|
||||
// 确定要使用的数据库
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
// 使用 Session 创建独立的数据库会话,避免影响其他查询
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
|
||||
// 如果指定了数据库,先切换到该数据库
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return 0, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
result := db.Exec(sqlStr)
|
||||
if result.Error != nil {
|
||||
return 0, fmt.Errorf("执行更新失败: %v", result.Error)
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// ListDatabases 获取数据库列表
|
||||
func (c *MySQLClient) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
var databases []string
|
||||
err := c.db.Raw("SHOW DATABASES").Scan(&databases).Error
|
||||
return databases, err
|
||||
}
|
||||
|
||||
// ListTables 获取表列表
|
||||
func (c *MySQLClient) ListTables(ctx context.Context, database string) ([]string, error) {
|
||||
var tables []string
|
||||
query := "SHOW TABLES"
|
||||
if database != "" {
|
||||
query = fmt.Sprintf("SHOW TABLES FROM `%s`", database)
|
||||
}
|
||||
err := c.db.Raw(query).Scan(&tables).Error
|
||||
return tables, err
|
||||
}
|
||||
|
||||
// GetTableStructure 获取表结构
|
||||
func (c *MySQLClient) GetTableStructure(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||||
// 使用 SHOW FULL COLUMNS 来获取包含 comment 的完整字段信息
|
||||
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
|
||||
if database != "" {
|
||||
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", database, tableName)
|
||||
}
|
||||
|
||||
rows, err := c.db.Raw(query).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取表结构失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else if val == nil {
|
||||
row[col] = nil
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 Comment 字段存在(SHOW FULL COLUMNS 返回的字段名是 Comment)
|
||||
if _, ok := row["Comment"]; !ok {
|
||||
row["Comment"] = ""
|
||||
}
|
||||
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetIndexes 获取索引列表
|
||||
func (c *MySQLClient) GetIndexes(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||||
query := "SHOW INDEX FROM "
|
||||
if database != "" {
|
||||
query += fmt.Sprintf("`%s`.", database)
|
||||
}
|
||||
query += fmt.Sprintf("`%s`", tableName)
|
||||
|
||||
rows, err := c.db.Raw(query).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取索引列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// PreviewTableStructure 预览表结构变更,只生成 SQL 语句不执行
|
||||
func (c *MySQLClient) PreviewTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 获取当前表结构
|
||||
currentColumns, err := c.GetTableStructure(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前表结构失败: %v", err)
|
||||
}
|
||||
|
||||
currentIndexes, err := c.GetIndexes(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前索引失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析新的结构数据
|
||||
var newColumns []map[string]interface{}
|
||||
var newIndexes []map[string]interface{}
|
||||
|
||||
if cols, ok := structure["columns"].([]interface{}); ok {
|
||||
for _, col := range cols {
|
||||
if colMap, ok := col.(map[string]interface{}); ok {
|
||||
newColumns = append(newColumns, colMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if idxs, ok := structure["indexes"].([]interface{}); ok {
|
||||
for _, idx := range idxs {
|
||||
if idxMap, ok := idx.(map[string]interface{}); ok {
|
||||
newIndexes = append(newIndexes, idxMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 ALTER TABLE 语句
|
||||
var alterStatements []string
|
||||
|
||||
// 处理字段变更
|
||||
alterStatements = append(alterStatements, c.buildColumnAlterStatements(tableName, currentColumns, newColumns)...)
|
||||
|
||||
// 处理索引变更
|
||||
alterStatements = append(alterStatements, c.buildIndexAlterStatements(tableName, currentIndexes, newIndexes)...)
|
||||
|
||||
return alterStatements, nil
|
||||
}
|
||||
|
||||
// UpdateTableStructure 更新表结构,返回生成的 SQL 语句列表
|
||||
func (c *MySQLClient) UpdateTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 先预览生成 SQL 语句
|
||||
alterStatements, err := c.PreviewTableStructure(ctx, database, tableName, structure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 执行所有 ALTER TABLE 语句
|
||||
if len(alterStatements) > 0 {
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return alterStatements, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, stmt := range alterStatements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return alterStatements, fmt.Errorf("执行 ALTER TABLE 失败: %v, SQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return alterStatements, nil
|
||||
}
|
||||
|
||||
// buildColumnAlterStatements 构建字段变更的 ALTER TABLE 语句
|
||||
func (c *MySQLClient) buildColumnAlterStatements(tableName string, currentColumns, newColumns []map[string]interface{}) []string {
|
||||
var statements []string
|
||||
|
||||
// 创建字段名映射和顺序映射
|
||||
currentFieldMap := make(map[string]map[string]interface{})
|
||||
currentFieldOrder := make([]string, 0, len(currentColumns))
|
||||
for _, col := range currentColumns {
|
||||
if field, ok := col["Field"].(string); ok {
|
||||
currentFieldMap[field] = col
|
||||
currentFieldOrder = append(currentFieldOrder, field)
|
||||
}
|
||||
}
|
||||
|
||||
newFieldMap := make(map[string]bool)
|
||||
newFieldOrder := make([]string, 0, len(newColumns))
|
||||
newColumnsMap := make(map[string]map[string]interface{})
|
||||
for _, col := range newColumns {
|
||||
if field, ok := col["Field"].(string); ok && field != "" {
|
||||
newFieldMap[field] = true
|
||||
newFieldOrder = append(newFieldOrder, field)
|
||||
newColumnsMap[field] = col
|
||||
}
|
||||
}
|
||||
|
||||
// 检测字段重命名:优先使用位置匹配,如果位置相同但字段名不同,认为是重命名
|
||||
renameMap := make(map[string]string) // oldName -> newName
|
||||
processedNewFields := make(map[string]bool)
|
||||
|
||||
// 第一步:使用位置匹配检测重命名(最可靠)
|
||||
for oldIndex, oldFieldName := range currentFieldOrder {
|
||||
if newFieldMap[oldFieldName] {
|
||||
continue // 字段名未改变,跳过
|
||||
}
|
||||
|
||||
// 检查新字段列表中相同位置是否有字段
|
||||
if oldIndex < len(newFieldOrder) {
|
||||
newFieldName := newFieldOrder[oldIndex]
|
||||
_, existsInCurrent := currentFieldMap[newFieldName]
|
||||
if !existsInCurrent && !processedNewFields[newFieldName] {
|
||||
// 新字段不在当前字段列表中,且位置相同,很可能是重命名
|
||||
// 进一步验证:检查类型是否相同(类型相同更可能是重命名)
|
||||
oldCol := currentFieldMap[oldFieldName]
|
||||
newCol := newColumnsMap[newFieldName]
|
||||
oldType := getStringValue(oldCol["Type"])
|
||||
newType := getStringValue(newCol["Type"])
|
||||
|
||||
// 如果类型相同,认为是重命名
|
||||
if oldType == newType {
|
||||
renameMap[oldFieldName] = newFieldName
|
||||
processedNewFields[newFieldName] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:对于未匹配的字段,使用属性匹配(兼容旧逻辑)
|
||||
for oldFieldName, oldCol := range currentFieldMap {
|
||||
if newFieldMap[oldFieldName] {
|
||||
continue // 字段名未改变,跳过
|
||||
}
|
||||
if renameMap[oldFieldName] != "" {
|
||||
continue // 已经通过位置匹配识别为重命名
|
||||
}
|
||||
|
||||
// 查找属性完全匹配的新字段
|
||||
var matchedNewField string
|
||||
for newFieldName, newCol := range newColumnsMap {
|
||||
if processedNewFields[newFieldName] {
|
||||
continue // 已经被匹配过了
|
||||
}
|
||||
_, existsInCurrent := currentFieldMap[newFieldName]
|
||||
if !existsInCurrent {
|
||||
// 这是一个新增字段,检查属性是否匹配
|
||||
if c.isColumnPropertiesEqual(oldCol, newCol) {
|
||||
if matchedNewField == "" {
|
||||
matchedNewField = newFieldName
|
||||
} else {
|
||||
// 有多个匹配,无法确定,不认为是重命名
|
||||
matchedNewField = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到唯一匹配,认为是重命名
|
||||
if matchedNewField != "" {
|
||||
renameMap[oldFieldName] = matchedNewField
|
||||
processedNewFields[matchedNewField] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理字段重命名
|
||||
for oldName, newName := range renameMap {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` RENAME COLUMN `%s` TO `%s`", tableName, oldName, newName)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
|
||||
// 处理字段添加、修改和位置调整(排除已重命名的字段)
|
||||
for i, newCol := range newColumns {
|
||||
field, _ := newCol["Field"].(string)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是重命名的字段
|
||||
isRenamed := false
|
||||
var oldName string
|
||||
for old, new := range renameMap {
|
||||
if new == field {
|
||||
isRenamed = true
|
||||
oldName = old
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isRenamed {
|
||||
// 重命名的字段:如果属性有变化,需要 MODIFY COLUMN
|
||||
oldCol := currentFieldMap[oldName]
|
||||
needsModify := c.isColumnChanged(oldCol, newCol)
|
||||
|
||||
// 检查顺序变化:使用旧字段名在 currentOrder 中查找位置,与新位置比较
|
||||
oldIndex := -1
|
||||
for idx, name := range currentFieldOrder {
|
||||
if name == oldName {
|
||||
oldIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
needsReorder := (oldIndex != -1 && oldIndex != i)
|
||||
|
||||
if needsModify || needsReorder {
|
||||
// 重命名后需要修改属性或位置
|
||||
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentCol, exists := currentFieldMap[field]; exists {
|
||||
// 修改现有字段
|
||||
needsModify := c.isColumnChanged(currentCol, newCol)
|
||||
needsReorder := c.isColumnOrderChanged(currentFieldOrder, newFieldOrder, field, i)
|
||||
|
||||
if needsModify || needsReorder {
|
||||
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加新字段(排除重命名的字段)
|
||||
stmt := c.buildAddColumnStatement(tableName, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的字段(排除已重命名的字段)
|
||||
for field := range currentFieldMap {
|
||||
if !newFieldMap[field] && renameMap[field] == "" {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP COLUMN `%s`", tableName, field)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
// buildIndexAlterStatements 构建索引变更的 ALTER TABLE 语句
|
||||
func (c *MySQLClient) buildIndexAlterStatements(tableName string, currentIndexes, newIndexes []map[string]interface{}) []string {
|
||||
var statements []string
|
||||
|
||||
// 创建索引名映射
|
||||
currentIndexMap := make(map[string]map[string]interface{})
|
||||
for _, idx := range currentIndexes {
|
||||
if keyName, ok := idx["Key_name"].(string); ok && keyName != "PRIMARY" {
|
||||
currentIndexMap[keyName] = idx
|
||||
}
|
||||
}
|
||||
|
||||
newIndexMap := make(map[string]bool)
|
||||
for _, idx := range newIndexes {
|
||||
if keyName, ok := idx["Key_name"].(string); ok && keyName != "" && keyName != "PRIMARY" {
|
||||
newIndexMap[keyName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理索引变更
|
||||
for _, newIdx := range newIndexes {
|
||||
keyName, _ := newIdx["Key_name"].(string)
|
||||
if keyName == "" || keyName == "PRIMARY" {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentIdx, exists := currentIndexMap[keyName]; exists {
|
||||
// 修改现有索引
|
||||
if c.isIndexChanged(currentIdx, newIdx) {
|
||||
dropStmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
|
||||
addStmt := c.buildAddIndexStatement(tableName, newIdx)
|
||||
if addStmt != "" {
|
||||
statements = append(statements, dropStmt)
|
||||
statements = append(statements, addStmt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加新索引
|
||||
stmt := c.buildAddIndexStatement(tableName, newIdx)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的索引
|
||||
for keyName := range currentIndexMap {
|
||||
if !newIndexMap[keyName] {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
// isColumnChanged 检查字段是否发生变化(不包括字段名)
|
||||
func (c *MySQLClient) isColumnChanged(oldCol, newCol map[string]interface{}) bool {
|
||||
fields := []string{"Type", "Null", "Default", "Extra", "Comment"}
|
||||
for _, field := range fields {
|
||||
oldVal := getStringValue(oldCol[field])
|
||||
newVal := getStringValue(newCol[field])
|
||||
if oldVal != newVal {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isColumnPropertiesEqual 检查字段属性是否完全相等(不包括字段名)
|
||||
func (c *MySQLClient) isColumnPropertiesEqual(oldCol, newCol map[string]interface{}) bool {
|
||||
fields := []string{"Type", "Null", "Default", "Extra", "Key", "Comment"}
|
||||
for _, field := range fields {
|
||||
oldVal := getStringValue(oldCol[field])
|
||||
newVal := getStringValue(newCol[field])
|
||||
if oldVal != newVal {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isColumnOrderChanged 检查字段顺序是否发生变化
|
||||
func (c *MySQLClient) isColumnOrderChanged(currentOrder, newOrder []string, fieldName string, newIndex int) bool {
|
||||
// 查找字段在当前顺序中的位置
|
||||
currentIndex := -1
|
||||
for i, name := range currentOrder {
|
||||
if name == fieldName {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果字段不存在于当前顺序中(新字段),不需要检查顺序
|
||||
if currentIndex == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果索引相同,检查前面的字段是否相同
|
||||
if newIndex == currentIndex {
|
||||
// 检查前面的字段集合是否相同
|
||||
if newIndex > 0 {
|
||||
currentPrevFields := make(map[string]bool)
|
||||
for i := 0; i < currentIndex; i++ {
|
||||
currentPrevFields[currentOrder[i]] = true
|
||||
}
|
||||
|
||||
newPrevFields := make(map[string]bool)
|
||||
for i := 0; i < newIndex; i++ {
|
||||
newPrevFields[newOrder[i]] = true
|
||||
}
|
||||
|
||||
// 如果前面的字段集合不同,说明顺序变了
|
||||
if len(currentPrevFields) != len(newPrevFields) {
|
||||
return true
|
||||
}
|
||||
for f := range currentPrevFields {
|
||||
if !newPrevFields[f] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 索引不同,说明顺序变了
|
||||
return true
|
||||
}
|
||||
|
||||
// isIndexChanged 检查索引是否发生变化
|
||||
func (c *MySQLClient) isIndexChanged(oldIdx, newIdx map[string]interface{}) bool {
|
||||
oldCol := getStringValue(oldIdx["Column_name"])
|
||||
newCol := getStringValue(newIdx["Column_name"])
|
||||
if oldCol != newCol {
|
||||
return true
|
||||
}
|
||||
|
||||
oldUnique := getIntValue(oldIdx["Non_unique"])
|
||||
newUnique := getIntValue(newIdx["Non_unique"])
|
||||
return oldUnique != newUnique
|
||||
}
|
||||
|
||||
// buildAddColumnStatement 构建添加字段的语句
|
||||
func (c *MySQLClient) buildAddColumnStatement(tableName string, col map[string]interface{}, fieldOrder []string, index int) string {
|
||||
field := getStringValue(col["Field"])
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
colDef := c.buildColumnDefinition(col)
|
||||
|
||||
// 确定字段位置
|
||||
position := c.buildColumnPosition(fieldOrder, index)
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN %s%s", tableName, colDef, position)
|
||||
}
|
||||
|
||||
// buildModifyColumnStatement 构建修改字段的语句
|
||||
func (c *MySQLClient) buildModifyColumnStatement(tableName, field string, col map[string]interface{}, fieldOrder []string, index int) string {
|
||||
colDef := c.buildColumnDefinition(col)
|
||||
|
||||
// 确定字段位置
|
||||
position := c.buildColumnPosition(fieldOrder, index)
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN %s%s", tableName, colDef, position)
|
||||
}
|
||||
|
||||
// buildColumnPosition 构建字段位置子句(AFTER 或 FIRST)
|
||||
func (c *MySQLClient) buildColumnPosition(fieldOrder []string, index int) string {
|
||||
if index < 0 || index >= len(fieldOrder) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
// 第一个字段使用 FIRST
|
||||
return " FIRST"
|
||||
}
|
||||
|
||||
// 其他字段使用 AFTER 前一个字段
|
||||
prevField := fieldOrder[index-1]
|
||||
return fmt.Sprintf(" AFTER `%s`", prevField)
|
||||
}
|
||||
|
||||
// buildColumnDefinition 构建字段定义
|
||||
func (c *MySQLClient) buildColumnDefinition(col map[string]interface{}) string {
|
||||
field := getStringValue(col["Field"])
|
||||
colType := getStringValue(col["Type"])
|
||||
null := getStringValue(col["Null"])
|
||||
defaultVal := col["Default"]
|
||||
extra := getStringValue(col["Extra"])
|
||||
comment := getStringValue(col["Comment"])
|
||||
|
||||
def := fmt.Sprintf("`%s` %s", field, colType)
|
||||
|
||||
if null == "NO" {
|
||||
def += " NOT NULL"
|
||||
}
|
||||
|
||||
if defaultVal != nil {
|
||||
if defaultStr, ok := defaultVal.(string); ok {
|
||||
if defaultStr == "" {
|
||||
// 空字符串表示默认值为空字符串
|
||||
def += " DEFAULT ''"
|
||||
} else if defaultStr != "NULL" {
|
||||
// 转义单引号
|
||||
escapedDefault := strings.ReplaceAll(defaultStr, "'", "''")
|
||||
def += fmt.Sprintf(" DEFAULT '%s'", escapedDefault)
|
||||
}
|
||||
// 如果 defaultStr == "NULL",不添加 DEFAULT 子句(允许 NULL)
|
||||
} else {
|
||||
// 非字符串类型的默认值
|
||||
def += fmt.Sprintf(" DEFAULT %v", defaultVal)
|
||||
}
|
||||
}
|
||||
|
||||
if extra != "" {
|
||||
def += " " + extra
|
||||
}
|
||||
|
||||
if comment != "" {
|
||||
// 转义单引号
|
||||
escapedComment := strings.ReplaceAll(comment, "'", "''")
|
||||
def += fmt.Sprintf(" COMMENT '%s'", escapedComment)
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
// buildAddIndexStatement 构建添加索引的语句
|
||||
func (c *MySQLClient) buildAddIndexStatement(tableName string, idx map[string]interface{}) string {
|
||||
keyName := getStringValue(idx["Key_name"])
|
||||
columnName := getStringValue(idx["Column_name"])
|
||||
nonUnique := getIntValue(idx["Non_unique"])
|
||||
|
||||
if keyName == "" || columnName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
indexType := "INDEX"
|
||||
if nonUnique == 0 {
|
||||
indexType = "UNIQUE INDEX"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` ADD %s `%s` (`%s`)", tableName, indexType, keyName, columnName)
|
||||
}
|
||||
|
||||
// getStringValue 安全获取字符串值
|
||||
func getStringValue(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// getIntValue 安全获取整数值
|
||||
func getIntValue(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case int64:
|
||||
return int(val)
|
||||
case float64:
|
||||
return int(val)
|
||||
case string:
|
||||
var i int
|
||||
fmt.Sscanf(val, "%d", &i)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// AppVersion 应用版本号(发布时直接修改此处)
|
||||
const AppVersion = "0.3.3"
|
||||
const AppVersion = "0.4.0"
|
||||
|
||||
// 版本号缓存
|
||||
var (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 变成轻量的标签页容器
|
||||
- 只负责标签切换和状态管理
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// 保留向后兼容,内部使用通用工具
|
||||
export { createResizeHandler, type ResizeOptions } from '../../../utils/resize'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user