Private
Public Access
1
0

3 Commits

Author SHA1 Message Date
44847e0d40 新增:收藏夹折叠+帮助文档区块+拖拽排序修复
- Sidebar 双区块架构:收藏夹(可折叠) + 帮助文档(默认折叠)
- 帮助内容:5条常用快捷键静态展示
- 折叠动画:max-height + opacity 过渡,自适应视口高度
- 修复拖拽死锁:draggable 条件改为 pressedIndex || isDragging
- 修复长按误触:200ms 时延防单击触发 draggable
- 修复排序持久化:sortFavorites 仅分组保序,不再覆盖拖拽顺序
- 清理死代码:.sidebar-divider、dataTransfer.setData
2026-04-30 23:01:47 +08:00
3d5a1e5892 优化:工具栏高度对齐+面板统一+远程连接架构+自动恢复预览
- 工具栏:面包屑与右侧组件像素级等高(:deep 34px)、合并重复search handler、统一分隔符样式、删除死代码
- 面板对齐:三面板header统一padding/font-size、文件列表分页固定底部(自定义紧凑)、表头默认隐藏、滚动条统一样式
- 预览区:始终显示空白预览面板、重启自动恢复上次打开文件
- 收藏夹:简化计数显示(共N项)
- 远程连接:ConnectionIndicator自适应UI(无远程显示mini云图标)、ConnectionDialog支持编辑配置、transport抽象层(本地Wails/远程HTTP双模式)、agent后端模块
2026-04-30 22:25:27 +08:00
4f1d5f885f 重构:移除数据库客户端模块 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 仅保留文件管理
2026-04-26 00:03:22 +08:00
127 changed files with 2448 additions and 18599 deletions

View File

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

164
app.go
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

105
cmd/agent/main.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"u-desk/internal/agent/config"
agentmw "u-desk/internal/agent/middleware"
"u-desk/internal/agent/handler"
"u-desk/internal/filesystem"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
cfg, err := config.Load("configs/agent.yaml")
if err != nil {
log.Fatalf("[FATAL] 加载配置失败: %v", err)
}
fsConfig := filesystem.DefaultConfig()
fsSvc, err := filesystem.NewFileSystemService(fsConfig)
if err != nil {
log.Fatalf("[FATAL] 初始化文件服务失败: %v", err)
}
e := echo.New()
e.HideBanner = true
e.HidePort = true
e.Use(middleware.Recover())
e.Use(middleware.Logger())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: cfg.CORS.AllowedOrigins,
AllowMethods: []string{echo.GET, echo.PUT, echo.POST, echo.DELETE, echo.PATCH, echo.OPTIONS},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAuthorization, echo.HeaderAccept},
}))
if cfg.Auth.Token != "" {
e.Use(agentmw.Auth(cfg.Auth.Token))
}
h := handler.New(fsSvc, cfg)
api := e.Group("/api/v1")
{
api.GET("/ping", h.Ping)
api.GET("/info", h.Info)
// 文件操作 — 所有通过 ?path= 参数传递路径
api.GET("/fs", h.ListOrStat) // ?path=xxx [&action=stat]
api.GET("/fs/read", h.ReadFile) // ?path=xxx
api.PUT("/fs/write", h.WriteFile) // ?path=xxx & body={content}
api.POST("/fs/create", h.Create) // ?path=xxx & body={type,name}
api.DELETE("/fs/delete", h.Delete) // ?path=xxx
api.PATCH("/fs/rename", h.Rename) // ?path=xxx & body={new_path}
api.POST("/fs/upload", h.Upload) // ?path=xxx & body={content}
api.GET("/fs/detect", h.DetectType) // ?path=xxx
sys := api.Group("/system")
{
sys.GET("/common-paths", h.CommonPaths)
sys.GET("/drives", h.Drives)
}
proxy := api.Group("/proxy")
{
proxy.GET("/localfs/*", h.FileServerProxy)
proxy.GET("/html-preview", h.HTMLPreviewProxy)
}
}
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
go func() {
log.Printf("[INFO] u-fs-agent 启动于 %s", addr)
if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
log.Fatalf("[FATAL] HTTP 服务器错误: %v", err)
}
}()
go func() {
if _, err := filesystem.StartLocalFileServer(); err != nil {
log.Printf("[WARN] 文件服务器启动失败(媒体预览不可用): %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("[INFO] 正在关闭...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filesystem.ShutdownLocalFileServer()
e.Shutdown(ctx)
fsSvc.Close(ctx)
log.Println("[INFO] 已关闭")
}

View File

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

29
configs/agent.yaml Normal file
View File

@@ -0,0 +1,29 @@
# u-fs-agent 配置文件
# 部署到远端服务器后修改此文件
server:
port: 9876 # 监听端口
host: "0.0.0.0" # 监听地址
auth:
token: "" # API Token留空则不验证生产环境必须设置
# 生成随机 token: openssl rand -hex 32
cors:
allowed_origins:
- "*" # 开发模式允许所有来源
# 生产环境建议限定:
# - "http://localhost:5173"
# - "http://localhost:5174"
log:
level: "info" # debug / info / warn / error
format: "json" # json / text
file_server:
port: 8073 # 内置文件服务器端口(用于媒体预览代理)
max_file_size: 524288000 # 最大文件大小 500MB
security:
allow_symlinks: false # 是否允许符号链接
check_system_paths: true # 检查系统关键目录

17
go.mod
View File

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

53
go.sum
View File

@@ -1,15 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= 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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= 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/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= 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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= 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.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
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 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 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/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.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 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 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/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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -129,72 +113,43 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= 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/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 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/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 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 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 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= 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 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= 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-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 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= 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/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= 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/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

View File

@@ -0,0 +1,108 @@
package config
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Auth AuthConfig `yaml:"auth"`
CORS CORSConfig `yaml:"cors"`
Log LogConfig `yaml:"log"`
FileServer FileServerConfig `yaml:"file_server"`
Security SecurityConfig `yaml:"security"`
}
type ServerConfig struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
}
type AuthConfig struct {
Token string `yaml:"token"`
}
type CORSConfig struct {
AllowedOrigins []string `yaml:"allowed_origins"`
}
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
type FileServerConfig struct {
Port int `yaml:"port"`
MaxFileSize int64 `yaml:"max_file_size"`
}
type SecurityConfig struct {
AllowSymlinks bool `yaml:"allow_symlinks"`
CheckSystemPaths bool `yaml:"check_system_paths"`
}
// FileServerAddr 返回文件服务器的完整地址
func (c *Config) FileServerAddr() string {
return fmt.Sprintf("http://localhost:%d", c.FileServer.Port)
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
// 配置文件不存在时使用默认值
if os.IsNotExist(err) {
return Default(), nil
}
return nil, err
}
cfg := Default()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
// 清理 origins 中的空格并去重
seen := make(map[string]bool, len(cfg.CORS.AllowedOrigins))
uniques := cfg.CORS.AllowedOrigins[:0]
for _, origin := range cfg.CORS.AllowedOrigins {
o := strings.TrimSpace(origin)
if o != "" && !seen[o] {
seen[o] = true
uniques = append(uniques, o)
}
}
cfg.CORS.AllowedOrigins = uniques
return cfg, nil
}
func Default() *Config {
return &Config{
Server: ServerConfig{
Port: 9876,
Host: "0.0.0.0",
},
Auth: AuthConfig{
Token: "",
},
CORS: CORSConfig{
AllowedOrigins: []string{"*"},
},
Log: LogConfig{
Level: "info",
Format: "json",
},
FileServer: FileServerConfig{
Port: 8073,
MaxFileSize: 500 * 1024 * 1024,
},
Security: SecurityConfig{
AllowSymlinks: false,
CheckSystemPaths: true,
},
}
}

View File

@@ -0,0 +1,176 @@
package handler
import (
"net/http"
"path/filepath"
"strings"
"u-desk/internal/agent/model"
"u-desk/internal/filesystem"
"github.com/labstack/echo/v4"
)
type writeFileReq struct {
Content string `json:"content"`
}
type createReq struct {
Type string `json:"type"` // "file" or "dir"
Name string `json:"name"`
}
type renameReq struct {
NewPath string `json:"new_path"`
}
type uploadReq struct {
Content string `json:"content"` // base64 编码内容
}
// ListOrStat 列出目录或获取文件信息(?get=stat 时返回单文件信息)
func (h *Handler) ListOrStat(c echo.Context) error {
path := getPath(c)
action := c.QueryParam("get")
if action == "stat" {
info, err := h.fsSvc.GetFileInfo(path)
if err != nil {
return c.JSON(http.StatusOK, model.NotFound(err.Error()))
}
return c.JSON(http.StatusOK, model.OK(info))
}
files, err := h.fsSvc.ListDir(path)
if err != nil {
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
}
// 限制返回数量,避免大目录导致前端卡顿
limit := c.QueryParam("limit")
if limit != "" {
n := 0
for i, f := range files {
if n >= 500 { // 硬限制 500 条
break
}
files[i] = f
n++
}
files = files[:n]
}
return c.JSON(http.StatusOK, model.OK(files))
}
// ReadFile 读取文件文本内容
func (h *Handler) ReadFile(c echo.Context) error {
path := getPath(c)
content, err := h.fsSvc.ReadFile(path)
if err != nil {
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
}
return c.JSON(http.StatusOK, model.OK(map[string]string{
"content": content,
}))
}
// WriteFile 写入文件文本内容
func (h *Handler) WriteFile(c echo.Context) error {
path := getPath(c)
var req writeFileReq
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
}
if err := h.fsSvc.WriteFile(path, req.Content); err != nil {
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
}
return c.JSON(http.StatusOK, model.NoContent())
}
// Create 创建文件或目录
func (h *Handler) Create(c echo.Context) error {
parentPath := getPath(c)
var req createReq
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return c.JSON(http.StatusBadRequest, model.BadRequest("名称不能为空"))
}
var result *filesystem.FileOperationResult
var err error
fullPath := filepath.Join(parentPath, req.Name)
switch req.Type {
case "dir":
result, err = h.fsSvc.CreateDir(fullPath)
default:
result, err = h.fsSvc.CreateFile(fullPath)
}
if err != nil {
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
}
return c.JSON(http.StatusCreated, model.OK(result))
}
// Delete 删除文件或目录
func (h *Handler) Delete(c echo.Context) error {
path := getPath(c)
result, err := h.fsSvc.DeletePath(path)
if err != nil {
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
}
return c.JSON(http.StatusOK, model.OK(result))
}
// Rename 重命名文件或目录
func (h *Handler) Rename(c echo.Context) error {
oldPath := getPath(c)
var req renameReq
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
}
req.NewPath = strings.TrimSpace(req.NewPath)
if req.NewPath == "" {
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不能为空"))
}
cleanNew := filepath.Clean(req.NewPath)
if strings.Contains(cleanNew, "..") {
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不允许包含 .."))
}
result, err := h.fsSvc.RenamePath(oldPath, cleanNew)
if err != nil {
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
}
return c.JSON(http.StatusOK, model.OK(result))
}
// Upload 上传 Base64 编码的二进制文件
func (h *Handler) Upload(c echo.Context) error {
path := getPath(c)
var req uploadReq
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
}
if req.Content == "" {
return c.JSON(http.StatusBadRequest, model.BadRequest("内容不能为空"))
}
if err := h.fsSvc.SaveBase64File(path, req.Content); err != nil {
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
}
return c.JSON(http.StatusOK, model.NoContent())
}
// DetectType 通过文件内容检测类型
func (h *Handler) DetectType(c echo.Context) error {
path := getPath(c)
info, err := h.fsSvc.DetectFileTypeByContent(path)
if err != nil {
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
}
return c.JSON(http.StatusOK, model.OK(info))
}

View File

@@ -0,0 +1,37 @@
package handler
import (
"net/http/httputil"
"net/url"
"path/filepath"
"u-desk/internal/agent/config"
"u-desk/internal/filesystem"
"github.com/labstack/echo/v4"
)
type Handler struct {
fsSvc *filesystem.FileSystemService
cfg *config.Config
fileProxy *httputil.ReverseProxy
}
func New(fsSvc *filesystem.FileSystemService, cfg *config.Config) *Handler {
fileTarget, _ := url.Parse(cfg.FileServerAddr() + "/localfs/")
return &Handler{
fsSvc: fsSvc,
cfg: cfg,
fileProxy: httputil.NewSingleHostReverseProxy(fileTarget),
}
}
// getPath 从 query 参数提取并规范化文件路径
func getPath(c echo.Context) string {
raw := c.QueryParam("path")
if raw == "" {
return ""
}
// URL 已被 Echo 自动 decode只需转换路径分隔符
return filepath.FromSlash(raw)
}

View File

@@ -0,0 +1,64 @@
package handler
import (
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
)
// FileServerProxy 反向代理到内置文件服务器(用于媒体预览)
func (h *Handler) FileServerProxy(c echo.Context) error {
rawPath := c.Param("*")
if rawPath == "" {
return c.String(http.StatusBadRequest, "缺少文件路径")
}
clean := filepath.Clean(rawPath)
if strings.Contains(clean, "..") {
return c.String(http.StatusForbidden, "路径不允许包含 ..")
}
// 防止多重 /localfs/ 前缀(循环去除所有)
targetPath := filepath.ToSlash(clean)
for strings.HasPrefix(targetPath, "localfs/") || strings.HasPrefix(targetPath, "localfs\\") {
targetPath = strings.TrimPrefix(targetPath, "localfs/")
targetPath = strings.TrimPrefix(targetPath, "localfs\\")
}
c.Request().URL.Path = "/localfs/" + targetPath
h.fileProxy.ServeHTTP(c.Response(), c.Request())
return nil
}
// HTMLPreviewProxy 代理 HTML 预览请求(直连内部服务器,避免 ReverseProxy 路径拼接问题)
func (h *Handler) HTMLPreviewProxy(c echo.Context) error {
rawPath := c.QueryParam("path")
if rawPath == "" {
return c.String(http.StatusBadRequest, "缺少 path 参数")
}
clean := filepath.Clean(rawPath)
if strings.Contains(clean, "..") {
return c.String(http.StatusForbidden, "路径不允许包含 ..")
}
theme := c.QueryParam("theme")
targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s",
url.QueryEscape(clean), url.QueryEscape(theme))
resp, err := http.Get(targetURL)
if err != nil {
return c.String(http.StatusBadGateway, "内部服务器不可用")
}
defer resp.Body.Close()
for k, v := range resp.Header {
c.Response().Header()[k] = v
}
c.Response().WriteHeader(resp.StatusCode)
io.Copy(c.Response(), resp.Body)
return nil
}

View File

@@ -0,0 +1,113 @@
package handler
import (
"net/http"
"os"
"runtime"
"strings"
"u-desk/internal/agent/model"
"github.com/labstack/echo/v4"
)
// Ping 健康检查
func (h *Handler) Ping(c echo.Context) error {
return c.JSON(http.StatusOK, model.OK(map[string]string{
"status": "ok",
}))
}
// Info 返回 Agent 信息
func (h *Handler) Info(c echo.Context) error {
hostname, _ := os.Hostname()
return c.JSON(http.StatusOK, model.OK(map[string]interface{}{
"version": "0.1.0",
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"hostname": hostname,
}))
}
// CommonPaths 返回常用系统路径
func (h *Handler) CommonPaths(c echo.Context) error {
paths := map[string]string{}
home, _ := os.UserHomeDir()
if home != "" {
paths["home"] = home
paths["desktop"] = home + "/Desktop"
paths["documents"] = home + "/Documents"
paths["downloads"] = home + "/Downloads"
}
// 根据平台添加盘符/根路径
if runtime.GOOS == "windows" {
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
_, err := os.Stat(string(drive) + ":\\")
if err == nil {
paths["drive_"+strings.ToLower(string(drive))] = string(drive) + ":\\"
}
}
} else {
paths["root"] = "/"
_, err := os.Stat("/home")
if err == nil {
paths["users"] = "/home"
}
}
return c.JSON(http.StatusOK, model.OK(paths))
}
// Drives 返回可用磁盘列表
func (h *Handler) Drives(c echo.Context) error {
type DriveInfo struct {
Name string `json:"name"`
Path string `json:"path"`
FsType string `json:"fs_type,omitempty"`
Total uint64 `json:"total"`
Free uint64 `json:"free"`
}
var drives []DriveInfo
if runtime.GOOS == "windows" {
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
drivePath := string(drive) + ":\\"
if _, err := os.Stat(drivePath); err != nil {
continue
}
drives = append(drives, DriveInfo{
Name: strings.ToLower(string(drive)),
Path: drivePath,
Total: 0,
Free: 0,
})
}
} else {
parts, err := os.ReadDir("/")
if err == nil {
for _, p := range parts {
name := p.Name()
if len(name) == 2 && name[0] != '.' && name[1] != '.' && p.IsDir() {
// 可能是挂载点
fullPath := "/" + name
if stat, err := os.Stat(fullPath); err == nil && stat.IsDir() {
drives = append(drives, DriveInfo{
Name: name,
Path: fullPath,
})
_ = stat
}
}
}
}
// 至少返回根目录
if len(drives) == 0 {
drives = append(drives, DriveInfo{Name: "/", Path: "/"})
}
}
return c.JSON(http.StatusOK, model.OK(drives))
}

View File

@@ -0,0 +1,61 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
)
const cookieName = "fs_token"
func Auth(token string) echo.MiddlewareFunc {
if token == "" {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 1. Authorization headerAPI 调用,首选)
auth := c.Request().Header.Get("Authorization")
if len(auth) >= 7 && strings.HasPrefix(auth, "Bearer ") &&
subtle.ConstantTimeCompare([]byte(auth[7:]), []byte(token)) == 1 {
setAuthCookie(c, token)
return next(c)
}
// 2. Cookie<img>/<video> 等浏览器自动携带)
if ck, err := c.Cookie(cookieName); err == nil &&
subtle.ConstantTimeCompare([]byte(ck.Value), []byte(token)) == 1 {
return next(c)
}
// 3. 查询参数(兼容旧版,可后续移除)
if qt := c.QueryParam("token"); qt != "" &&
subtle.ConstantTimeCompare([]byte(qt), []byte(token)) == 1 {
setAuthCookie(c, token)
return next(c)
}
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "unauthorized",
})
}
}
}
// setAuthCookie 首次认证成功后设置 Cookie供 <img> 等浏览器请求自动携带)
func setAuthCookie(c echo.Context, token string) {
c.SetCookie(&http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
MaxAge: int(24 * time.Hour / time.Second),
HttpOnly: true,
Secure: c.Request().TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}

View File

@@ -0,0 +1,41 @@
package model
import "net/http"
type Response struct {
Code int `json:"code"`
Data interface{} `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
func OK(data interface{}) Response {
return Response{Code: http.StatusOK, Data: data}
}
func Created(data interface{}) Response {
return Response{Code: http.StatusCreated, Data: data}
}
func NoContent() Response {
return Response{Code: http.StatusNoContent}
}
func BadRequest(msg string) Response {
return Response{Code: http.StatusBadRequest, Message: msg}
}
func Unauthorized(msg string) Response {
return Response{Code: http.StatusUnauthorized, Message: msg}
}
func Forbidden(msg string) Response {
return Response{Code: http.StatusForbidden, Message: msg}
}
func NotFound(msg string) Response {
return Response{Code: http.StatusNotFound, Message: msg}
}
func InternalError(msg string) Response {
return Response{Code: http.StatusInternalServerError, Message: msg}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,393 +0,0 @@
package dbclient
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"u-desk/internal/common"
"u-desk/internal/crypto"
"u-desk/internal/storage/models"
)
// ConnectionPool 连接池管理器
type ConnectionPool struct {
mysqlClients map[uint]*MySQLClient
redisClients map[uint]*RedisClient
mongoClients map[uint]*MongoClient
// 新增MySQL 真连接池
mysqlPool *MySQLConnectionPool
// 查询优化器
queryOptimizer *QueryOptimizer
mu sync.RWMutex
}
var (
globalPool *ConnectionPool
poolOnce sync.Once
)
// GetPool 获取全局连接池实例
func GetPool() *ConnectionPool {
poolOnce.Do(func() {
// 创建 MySQL 连接池
poolConfig := DefaultPoolConfig()
mysqlPool := NewMySQLConnectionPool(poolConfig)
// 启动维护协程
mysqlPool.StartMaintenance()
// 创建查询优化器
queryOptimizer := NewQueryOptimizer(nil)
globalPool = &ConnectionPool{
mysqlClients: make(map[uint]*MySQLClient),
redisClients: make(map[uint]*RedisClient),
mongoClients: make(map[uint]*MongoClient),
mysqlPool: mysqlPool,
queryOptimizer: queryOptimizer,
}
})
return globalPool
}
// PooledClient 带释放语义的客户端包装
type PooledClient struct {
Client *MySQLClient
entry *MySQLPoolEntry
pool *MySQLConnectionPool
fromPool bool
}
// Release 释放连接回连接池
func (pc *PooledClient) Release() {
if pc.fromPool && pc.pool != nil && pc.entry != nil {
pc.pool.Release(pc.entry)
}
}
// GetMySQLClient 获取或创建 MySQL 客户端(使用连接池)
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) *PooledClient {
p.mu.Lock()
defer p.mu.Unlock()
// 尝试从连接池获取连接
if p.mysqlPool != nil {
entry, err := p.mysqlPool.Acquire(conn)
if err == nil {
return &PooledClient{Client: entry.Client, entry: entry, pool: p.mysqlPool, fromPool: true}
}
p.logPoolError("Acquire failed", err)
}
// 降级到原有逻辑
client, err := p.getMySQLClientLegacy(conn)
if err != nil {
return &PooledClient{Client: nil, fromPool: false}
}
return &PooledClient{Client: client, fromPool: false}
}
// logPoolError 记录连接池错误
func (p *ConnectionPool) logPoolError(operation string, err error) {
if p.queryOptimizer != nil {
// 通过查询优化器记录错误
p.queryOptimizer.RecordPoolError(operation, err)
}
}
// getMySQLClientLegacy 原有的 MySQL 客户端获取逻辑(向后兼容)
func (p *ConnectionPool) getMySQLClientLegacy(conn *models.DbConnection) (*MySQLClient, error) {
// 检查是否已存在
if client, ok := p.mysqlClients[conn.ID]; ok {
// 测试连接是否有效
if err := client.sqlDB.Ping(); err == nil {
return client, nil
}
// 连接已断开,移除并重新创建
client.Close()
delete(p.mysqlClients, conn.ID)
}
// 解密密码
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
// 创建新客户端
config := &MySQLConfig{
Host: conn.Host,
Port: conn.Port,
Username: conn.Username,
Password: password, // 如果密码为空MySQL会尝试无密码连接
Database: conn.Database,
}
client, err := NewMySQLClient(config)
if err != nil {
return nil, err
}
p.mysqlClients[conn.ID] = client
return client, nil
}
// GetMySQLPoolStats 获取 MySQL 连接池统计信息
func (p *ConnectionPool) GetMySQLPoolStats() *PoolStats {
if p.mysqlPool != nil {
stats := p.mysqlPool.Stats()
return &stats
}
return nil
}
// OptimizeQuery 优化查询执行
func (p *ConnectionPool) OptimizeQuery(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (*QueryResult, time.Duration, error) {
pc := p.GetMySQLClient(conn)
if pc.Client == nil {
return nil, 0, fmt.Errorf("获取 MySQL 连接失败")
}
defer pc.Release()
// 使用查询优化器
if p.queryOptimizer != nil {
return p.queryOptimizer.OptimizeQuery(ctx, pc.Client, sqlStr, database)
}
// 降级到普通查询
startTime := time.Now()
result, err := pc.Client.ExecuteQuery(ctx, sqlStr, database)
duration := time.Since(startTime)
return result, duration, err
}
// ExecuteOptimizedUpdate 执行优化的更新操作
func (p *ConnectionPool) ExecuteOptimizedUpdate(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (int64, time.Duration, error) {
pc := p.GetMySQLClient(conn)
if pc.Client == nil {
return 0, 0, fmt.Errorf("获取 MySQL 连接失败")
}
defer pc.Release()
// 使用查询优化器
if p.queryOptimizer != nil {
return p.queryOptimizer.ExecuteOptimizedUpdate(ctx, pc.Client, sqlStr, database)
}
// 降级到普通更新
startTime := time.Now()
result, err := pc.Client.ExecuteUpdate(ctx, sqlStr, database)
duration := time.Since(startTime)
return result, duration, err
}
// GetQueryStats 获取查询统计信息
func (p *ConnectionPool) GetQueryStats() QueryStats {
if p.queryOptimizer != nil {
return p.queryOptimizer.GetQueryStats()
}
return QueryStats{}
}
// GetSlowQueries 获取慢查询记录
func (p *ConnectionPool) GetSlowQueries(limit int) []SlowQuery {
if p.queryOptimizer != nil {
return p.queryOptimizer.GetSlowQueries(limit)
}
return []SlowQuery{}
}
// GetIndexSuggestions 获取索引建议
func (p *ConnectionPool) GetIndexSuggestions(table string) []IndexSuggestion {
if p.queryOptimizer != nil {
return p.queryOptimizer.GetIndexSuggestions(table)
}
return []IndexSuggestion{}
}
// GenerateIndexSuggestions 为表生成索引建议
func (p *ConnectionPool) GenerateIndexSuggestions(ctx context.Context, conn *models.DbConnection, database, table string) error {
pc := p.GetMySQLClient(conn)
if pc.Client == nil {
return fmt.Errorf("获取 MySQL 连接失败")
}
defer pc.Release()
// 使用查询优化器
if p.queryOptimizer != nil {
return p.queryOptimizer.GenerateIndexSuggestions(ctx, pc.Client, database, table)
}
return nil
}
// ClearQueryCache 清空查询缓存
func (p *ConnectionPool) ClearQueryCache() {
if p.queryOptimizer != nil {
p.queryOptimizer.ClearCache()
}
}
// GetRedisClient 获取或创建 Redis 客户端
func (p *ConnectionPool) GetRedisClient(conn *models.DbConnection) (*RedisClient, error) {
p.mu.Lock()
defer p.mu.Unlock()
// 检查是否已存在
if client, ok := p.redisClients[conn.ID]; ok {
// 测试连接是否有效
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
defer cancel()
if err := client.client.Ping(ctx).Err(); err == nil {
return client, nil
}
// 连接已断开,移除并重新创建
client.Close()
delete(p.redisClients, conn.ID)
}
// 解密密码
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
// 解析 Redis DB 编号(从 Database 字段,默认为 0
dbNum := 0
if conn.Database != "" {
// 尝试解析 Database 字段为数字
_, err := fmt.Sscanf(conn.Database, "%d", &dbNum)
if err != nil {
// 如果解析失败,使用默认值 0
dbNum = 0
}
// 限制 DB 编号在 0-15 之间
if dbNum < 0 || dbNum > 15 {
dbNum = 0
}
}
// 创建新客户端
config := &RedisConfig{
Host: conn.Host,
Port: conn.Port,
Password: password,
DB: dbNum,
}
client, err := NewRedisClient(config)
if err != nil {
return nil, err
}
p.redisClients[conn.ID] = client
return client, nil
}
// GetMongoClient 获取或创建 MongoDB 客户端
func (p *ConnectionPool) GetMongoClient(conn *models.DbConnection) (*MongoClient, error) {
p.mu.Lock()
defer p.mu.Unlock()
// 检查是否已存在
if client, ok := p.mongoClients[conn.ID]; ok {
// 测试连接是否有效
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
defer cancel()
if err := client.client.Ping(ctx, nil); err == nil {
return client, nil
}
// 连接已断开,移除并重新创建
client.Close()
delete(p.mongoClients, conn.ID)
}
// 解密密码
password, err := crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
// 解析 Options 获取 MongoDB 连接参数
authSource := ""
authMechanism := ""
if conn.Options != "" {
var opts map[string]interface{}
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
if as, ok := opts["authSource"].(string); ok && as != "" {
authSource = as
}
if am, ok := opts["authMechanism"].(string); ok && am != "" {
authMechanism = am
}
}
}
// 创建新客户端
config := &MongoConfig{
Host: conn.Host,
Port: conn.Port,
Username: conn.Username,
Password: password,
Database: conn.Database,
AuthSource: authSource,
AuthMechanism: authMechanism,
}
client, err := NewMongoClient(config)
if err != nil {
return nil, err
}
p.mongoClients[conn.ID] = client
return client, nil
}
// CloseConnection 关闭指定连接
func (p *ConnectionPool) CloseConnection(connID uint, dbType string) {
p.mu.Lock()
defer p.mu.Unlock()
switch dbType {
case "mysql":
if client, ok := p.mysqlClients[connID]; ok {
client.Close()
delete(p.mysqlClients, connID)
}
case "redis":
if client, ok := p.redisClients[connID]; ok {
client.Close()
delete(p.redisClients, connID)
}
case "mongo":
if client, ok := p.mongoClients[connID]; ok {
client.Close()
delete(p.mongoClients, connID)
}
}
}
// CloseAll 关闭所有连接
func (p *ConnectionPool) CloseAll() {
p.mu.Lock()
defer p.mu.Unlock()
for _, client := range p.mysqlClients {
client.Close()
}
for _, client := range p.redisClients {
client.Close()
}
for _, client := range p.mongoClients {
client.Close()
}
p.mysqlClients = make(map[uint]*MySQLClient)
p.redisClients = make(map[uint]*RedisClient)
p.mongoClients = make(map[uint]*MongoClient)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,9 +68,22 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
return "", ErrPathTraversal return "", ErrPathTraversal
} }
filePath := strings.ReplaceAll(decodedPath, "/", "\\") // 去除代理引入的 /localfs/ 前缀(可能有多层)
clean := decodedPath
for strings.HasPrefix(clean, "/localfs/") || strings.HasPrefix(clean, "localfs/") {
clean = strings.TrimPrefix(clean, "/localfs/")
clean = strings.TrimPrefix(clean, "localfs/")
}
// 平台适配Windows 用反斜杠Linux/macOS 保持正斜杠
filePath := filepath.FromSlash(clean)
filePath = filepath.Clean(filePath) filePath = filepath.Clean(filePath)
// 确保绝对路径Linux 以 / 开头Windows 以盘符开头)
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
filePath = "/" + filePath
}
if !isSafePath(filePath) { if !isSafePath(filePath) {
return "", ErrPathUnsafe return "", ErrPathUnsafe
} }
@@ -153,8 +166,20 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path) log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀) // 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀)
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/") pathPart := r.URL.Path
for strings.HasPrefix(pathPart, "/localfs/") {
pathPart = strings.TrimPrefix(pathPart, "/localfs/")
}
if pathPart == "" || pathPart == r.URL.Path {
log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path)
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
// 仅对非绝对路径添加前导 /Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
pathPart = "/" + pathPart
}
if pathPart == "" || pathPart == r.URL.Path { if pathPart == "" || pathPart == r.URL.Path {
log.Printf("[LocalFileHandler] 路径前缀无效") log.Printf("[LocalFileHandler] 路径前缀无效")

View File

@@ -1,3 +1,6 @@
//go:build windows
// +build windows
package filesystem package filesystem
import ( import (

View File

@@ -0,0 +1,19 @@
//go:build !windows
// +build !windows
package filesystem
// FileLockChecker 文件锁检查器Linux 空实现)
type FileLockChecker struct{}
func NewFileLockChecker() *FileLockChecker {
return &FileLockChecker{}
}
func (c *FileLockChecker) IsFileLocked(_path string) (bool, string, error) {
return false, "", nil
}
func (c *FileLockChecker) SafeDeleteWithLockCheck(_path string) error {
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,199 @@
/**
* 连接管理器 — 管理本地/远程传输层切换
*/
import type { FsTransport } from './transport'
import { WailsTransport } from './wails-transport'
import { HttpTransport } from './http-transport'
export type ConnectionType = 'local' | 'remote'
export interface ConnectionProfile {
id: string
name: string
host: string
port: number
token: string
type: ConnectionType
lastConnected?: number
}
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
const PROFILES_KEY = 'fs_connection_profiles'
const ACTIVE_KEY = 'fs_active_connection'
class ConnectionManagerImpl {
private _transport: FsTransport | null = null
private _profiles: ConnectionProfile[] = []
private _activeId: string | null = null
private _state: ConnectionState = 'disconnected'
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
private _connectSeq = 0
constructor() {
this.loadProfiles()
this.initDefaultLocal()
}
private initDefaultLocal() {
const localProfile: ConnectionProfile = {
id: 'local-default',
name: '本地',
host: '',
port: 0,
token: '',
type: 'local',
}
if (!this._profiles.find(p => p.id === localProfile.id)) {
this._profiles.unshift(localProfile)
}
// 默认连接本地
if (!this._activeId) {
this._activeId = localProfile.id
}
this.applyActive()
}
private loadProfiles() {
try {
const raw = localStorage.getItem(PROFILES_KEY)
if (raw) this._profiles = JSON.parse(raw)
this._activeId = localStorage.getItem(ACTIVE_KEY)
} catch { /* 首次使用 */ }
}
private saveProfiles() {
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
if (this._activeId) {
localStorage.setItem(ACTIVE_KEY, this._activeId)
}
}
private setState(state: ConnectionState) {
this._state = state
this.notifyChange()
}
private notifyChange() {
this._stateChangeCallbacks.forEach(cb => cb(this._state))
}
onStateChange(cb: (state: ConnectionState) => void) {
this._stateChangeCallbacks.push(cb)
}
get state(): ConnectionState {
return this._state
}
get profiles(): ConnectionProfile[] {
return [...this._profiles]
}
get activeProfile(): ConnectionProfile | null {
return this._profiles.find(p => p.id === this._activeId) ?? null
}
getTransport(): FsTransport {
if (!this._transport) {
this.applyActive()
}
return this._transport!
}
getFileServerBaseURL(): string {
if (this._transport instanceof HttpTransport) {
const profile = this.activeProfile
if (!profile) return ''
const scheme = profile.port === 443 ? 'https' : 'http'
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
return `${scheme}://${profile.host}${port}`
}
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
return ''
}
isRemote(): boolean {
return this.activeProfile?.type === 'remote'
}
connect(profileId: string): void {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return
this._activeId = profileId
this.saveProfiles()
this.applyActive()
}
disconnect(): void {
this._activeId = 'local-default'
this.saveProfiles()
this.applyActive()
}
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
const newProfile: ConnectionProfile = {
...profile,
id: crypto.randomUUID(),
}
this._profiles.push(newProfile)
this.saveProfiles()
this.notifyChange()
return newProfile
}
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
const idx = this._profiles.findIndex(p => p.id === id)
if (idx >= 0) {
this._profiles[idx] = { ...this._profiles[idx], ...updates }
this.saveProfiles()
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
if (needsReapply && id === this._activeId) {
this.applyActive()
}
this.notifyChange()
}
}
removeProfile(id: string): void {
if (id === 'local-default') return // 不允许删除本地配置
this._profiles = this._profiles.filter(p => p.id !== id)
if (this._activeId === id) {
this._activeId = 'local-default'
}
this.saveProfiles()
this.applyActive()
this.notifyChange()
}
private applyActive() {
const profile = this.activeProfile
const seq = ++this._connectSeq
if (!profile || profile.type === 'local') {
this._transport = new WailsTransport()
this.setState('connected')
} else {
this.setState('connecting')
try {
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
// 快速连通性检查(用轻量 ping 代替 getCommonPaths
this._transport.getFileInfo('/').then(() => {
if (seq !== this._connectSeq) return // 已被后续连接覆盖
this.setState('connected')
this.updateProfile(profile.id!, { lastConnected: Date.now() })
}).catch(() => {
if (seq !== this._connectSeq) return
this.setState('error')
})
} catch {
this.setState('error')
}
}
}
}
export const connectionManager = new ConnectionManagerImpl()

View File

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

View File

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

View File

@@ -0,0 +1,136 @@
/**
* Http Transport — 远程文件操作(通过 u-fs-agent REST API
*/
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
const CONTENT_TYPE = 'application/json'
export class HttpTransport implements FsTransport {
private baseUrl: string
private token: string
constructor(host: string, port: number, token: string) {
const scheme = port === 443 ? 'https' : 'http'
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
this.token = token
}
private headers(): HeadersInit {
const h: Record<string, string> = { 'Content-Type': CONTENT_TYPE }
if (this.token) h['Authorization'] = `Bearer ${this.token}`
return h
}
private async request<T>(method: string, path: string, params?: Record<string, string>, body?: any): Promise<T> {
const url = `${this.baseUrl}${path}`
const searchParams = params ? '?' + new URLSearchParams(params).toString() : ''
const opts: RequestInit = {
method,
headers: this.headers(),
}
if (body !== undefined) opts.body = JSON.stringify(body)
const res = await fetch(url + searchParams, opts)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
}
const data = await res.json()
if (data.code >= 400) {
throw new Error(data.message || `请求失败 (code=${data.code})`)
}
return data.data ?? data
}
async listDir(path: string): Promise<FileItem[]> {
return this.request<FileItem[]>('GET', '/api/v1/fs', { path })
}
async getFileInfo(path: string): Promise<Record<string, any>> {
return this.request<Record<string, any>>('GET', '/api/v1/fs', { path, get: 'stat' })
}
async readFile(path: string): Promise<string> {
const data = await this.request<{ content: string }>('GET', '/api/v1/fs/read', { path })
return data.content
}
async writeFile(path: string, content: string): Promise<void> {
await this.request('PUT', '/api/v1/fs/write', { path }, { content })
}
async saveBase64File(path: string, content: string): Promise<void> {
if (!content) throw new Error('无效的 base64 内容')
await this.request('POST', '/api/v1/fs/upload', { path }, { content })
}
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: dirPath }, { type: 'file', name: filename })
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: parentPath }, { type: 'dir', name: dirname })
}
async deletePath(path: string): Promise<FileOperationResult> {
return this.request<FileOperationResult>('DELETE', '/api/v1/fs/delete', { path })
}
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
}
async listZipContents(zipPath: string): Promise<FileItem[]> {
// Wave 3 实现
throw new Error('ZIP 操作在远程模式暂未实现')
}
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
throw new Error('ZIP 操作在远程模式暂未实现')
}
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
throw new Error('ZIP 操作在远程模式暂未实现')
}
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
throw new Error('ZIP 操作在远程模式暂未实现')
}
async openPath(_path: string): Promise<void> {
throw new Error('远程模式不支持打开本地路径')
}
async getFileServerURL(): Promise<string> {
return `${this.baseUrl}/api/v1/proxy/localfs`
}
/** 远程模式预览用的认证 token拼接到 URL query */
getPreviewToken(): string {
return this.token
}
async resolveShortcut(_lnkPath: string): Promise<any> {
return null
}
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
return this.request<DetectTypeResult>('GET', '/api/v1/fs/detect', { path })
}
async getCommonPaths(): Promise<Record<string, string>> {
return this.request<Record<string, string>>('GET', '/api/v1/system/common-paths')
}
async getRecycleBinEntries(): Promise<any[]> {
return []
}
async restoreFromRecycleBin(_path: string): Promise<void> {}
async deletePermanently(_path: string): Promise<void> {}
async emptyRecycleBin(): Promise<void> {}
}

View File

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

View File

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

View File

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

View File

@@ -1,306 +1,110 @@
/** /**
* 系统信息相关 API * 系统信息相关 API — 委托给 Transport 层
* 本地模式走 Wails IPC远程模式走 HTTP REST API
*/ */
import type { SystemInfo, CPU, Memory, Disk, File } from './types' import type { File } from './types'
import { debugError } from '@/utils/debugLog' import { connectionManager } from './connection-manager'
/** /**
* 转换后端文件数据格式(蛇形 → 驼峰) * 转换后端文件数据格式(蛇形 → 驼峰)
* 后端返回 is_dir前端使用 isDir
*/ */
function transformFile(file: any): File { function transformFile(file: any): File {
return { return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
...file,
isDir: file.is_dir,
modified_time: file.mod_time
}
} }
/**
* 批量转换文件列表
*/
function transformFileList(files: any[]): File[] { function transformFileList(files: any[]): File[] {
return files.map(transformFile) return files.map(transformFile)
} }
/** const t = () => connectionManager.getTransport()
* 获取系统信息
*/ export async function getSystemInfo() { return t().getFileInfo('/') }
export async function getSystemInfo(): Promise<SystemInfo> {
if (!window.go?.main?.App?.GetSystemInfo) { export async function getCPUInfo() {
throw new Error('GetSystemInfo API 不可用') if (connectionManager.isRemote()) return {}
} try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
return await window.go.main.App.GetSystemInfo()
} }
/** export async function getMemoryInfo() {
* 获取 CPU 信息 if (connectionManager.isRemote()) return {}
*/ try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
export async function getCPUInfo(): Promise<CPU> {
if (!window.go?.main?.App?.GetCPUInfo) {
throw new Error('GetCPUInfo API 不可用')
}
return await window.go.main.App.GetCPUInfo()
} }
/** export async function getDiskInfo() {
* 获取内存信息 if (connectionManager.isRemote()) return {}
*/ try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
export async function getMemoryInfo(): Promise<Memory> {
if (!window.go?.main?.App?.GetMemoryInfo) {
throw new Error('GetMemoryInfo API 不可用')
}
return await window.go.main.App.GetMemoryInfo()
} }
/**
* 获取磁盘信息
*/
export async function getDiskInfo(): Promise<Disk> {
if (!window.go?.main?.App?.GetDiskInfo) {
throw new Error('GetDiskInfo API 不可用')
}
return await window.go.main.App.GetDiskInfo()
}
/**
* 列出目录文件
*/
export async function listDir(path: string): Promise<File[]> { export async function listDir(path: string): Promise<File[]> {
if (!window.go?.main?.App?.ListDir) { return transformFileList(await t().listDir(path))
throw new Error('ListDir API 不可用')
}
const files = await window.go.main.App.ListDir(path)
return transformFileList(files)
} }
/**
* 读取文件
*/
export async function readFile(path: string): Promise<string> { export async function readFile(path: string): Promise<string> {
if (!window.go?.main?.App?.ReadFile) { return t().readFile(path)
throw new Error('ReadFile API 不可用')
}
return await window.go.main.App.ReadFile(path)
} }
/**
* 写入文件
*/
export async function writeFile(path: string, content: string): Promise<void> { export async function writeFile(path: string, content: string): Promise<void> {
if (!window.go?.main?.App?.WriteFile) { await t().writeFile(path, String(content))
throw new Error('WriteFile API 不可用')
}
// 确保传递的是字符串类型
await window.go.main.App.WriteFile({
path: String(path),
content: String(content)
})
} }
/**
* 保存 Base64 编码的二进制文件(图片等)
*/
export async function saveBase64File(path: string, base64Content: string): Promise<void> { export async function saveBase64File(path: string, base64Content: string): Promise<void> {
if (!window.go?.main?.App?.SaveBase64File) { if (!base64Content) throw new Error('无效的 base64 内容')
throw new Error('SaveBase64File API 不可用') await t().saveBase64File(path, base64Content)
}
if (!base64Content) {
throw new Error('无效的 base64 内容')
}
await window.go.main.App.SaveBase64File({
path: String(path),
content: base64Content
})
} }
/**
* 删除文件或目录
*/
export async function deletePath(path: string): Promise<any> { export async function deletePath(path: string): Promise<any> {
if (!window.go?.main?.App?.DeletePath) { return t().deletePath(path)
throw new Error('DeletePath API 不可用')
}
return await window.go.main.App.DeletePath(path)
} }
/**
* 创建目录parentPath + dirname 拼接为完整路径)
*/
export async function createDir(parentPath: string, dirname: string): Promise<any> { export async function createDir(parentPath: string, dirname: string): Promise<any> {
if (!window.go?.main?.App?.CreateDir) { return t().createDir(parentPath, dirname)
throw new Error('CreateDir API 不可用')
}
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return await window.go.main.App.CreateDir(fullPath)
} }
/**
* 创建文件dirPath + filename 拼接为完整路径)
*/
export async function createFile(dirPath: string, filename: string): Promise<any> { export async function createFile(dirPath: string, filename: string): Promise<any> {
if (!window.go?.main?.App?.CreateFile) { return t().createFile(dirPath, filename)
throw new Error('CreateFile API 不可用')
}
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return await window.go.main.App.CreateFile(fullPath)
} }
/**
* 重命名文件或目录
*/
export async function renamePath(oldPath: string, newPath: string): Promise<any> { export async function renamePath(oldPath: string, newPath: string): Promise<any> {
if (!window.go?.main?.App?.RenamePath) { return t().renamePath(oldPath, String(newPath))
throw new Error('RenamePath API 不可用')
}
return await window.go.main.App.RenamePath({
oldPath: String(oldPath),
newPath: String(newPath)
})
} }
/**
* 获取环境变量
*/
export async function getEnvVars(): Promise<Record<string, string>> { export async function getEnvVars(): Promise<Record<string, string>> {
if (!window.go?.main?.App?.GetEnvVars) { try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
throw new Error('GetEnvVars API 不可用')
}
return await window.go.main.App.GetEnvVars()
} }
/**
* 列出 zip 文件内容
*/
export async function listZipContents(zipPath: string): Promise<File[]> { export async function listZipContents(zipPath: string): Promise<File[]> {
if (!window.go?.main?.App?.ListZipContents) { return transformFileList(await t().listZipContents(zipPath))
throw new Error('ListZipContents API 不可用')
}
try {
const result = await window.go.main.App.ListZipContents(zipPath)
return transformFileList(result)
} catch (error) {
debugError('[API] listZipContents 错误:', error)
throw error
}
} }
/**
* 从 zip 文件中提取单个文件内容
*/
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> { export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
if (!window.go?.main?.App?.ExtractFileFromZip) { return t().extractFileFromZip(zipPath, filePath)
throw new Error('ExtractFileFromZip API 不可用')
}
try {
const result = await window.go.main.App.ExtractFileFromZip(zipPath, filePath)
return result
} catch (error) {
debugError('[API] extractFileFromZip 错误:', error)
throw error
}
} }
/**
* 从 zip 文件中提取单个文件到临时目录
* 返回临时文件的完整路径,适用于图片等二进制文件
*/
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> { export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
if (!window.go?.main?.App?.ExtractFileFromZipToTemp) { return t().extractFileFromZipToTemp(zipPath, filePath)
throw new Error('ExtractFileFromZipToTemp API 不可用')
}
try {
const result = await window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
return result
} catch (error) {
debugError('[API] extractFileFromZipToTemp 错误:', error)
throw error
}
} }
/**
* 获取 zip 文件中特定文件的信息
*/
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> { export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
if (!window.go?.main?.App?.GetZipFileInfo) { return transformFile(await t().getZipFileInfo(zipPath, filePath))
throw new Error('GetZipFileInfo API 不可用')
}
try {
const result = await window.go.main.App.GetZipFileInfo(zipPath, filePath)
return transformFile(result)
} catch (error) {
debugError('[API] getZipFileInfo 错误:', error)
throw error
}
} }
/**
* 使用系统默认程序打开文件或目录
*/
export async function openPath(path: string): Promise<void> { export async function openPath(path: string): Promise<void> {
if (!window.go?.main?.App?.OpenPath) { await t().openPath(path)
throw new Error('OpenPath API 不可用')
}
try {
await window.go.main.App.OpenPath(path)
} catch (error) {
debugError('[API] openPath 错误:', error)
throw error
}
} }
/**
* 获取本地文件服务器URL
*/
export async function getFileServerURL(): Promise<string> { export async function getFileServerURL(): Promise<string> {
if (!window.go?.main?.App?.GetFileServerURL) { return t().getFileServerURL()
throw new Error('GetFileServerURL API 不可用')
}
return await window.go.main.App.GetFileServerURL()
} }
/** export async function resolveShortcut(lnkPath: string): Promise<any> {
* 解析快捷方式文件,返回目标路径信息 return t().resolveShortcut(lnkPath)
*/
export async function resolveShortcut(lnkPath: string): Promise<{
success: boolean
message?: string
targetPath?: string
targetExists?: boolean
targetAccessible?: boolean
targetInfo?: any
}> {
if (!window.go?.main?.App?.ResolveShortcut) {
throw new Error('ResolveShortcut API 不可用')
}
try {
const result = await window.go.main.App.ResolveShortcut(lnkPath)
return result
} catch (error) {
debugError('[API] resolveShortcut 错误:', error)
throw error
}
} }
/** export async function detectFileTypeByContent(path: string) {
* 通过文件内容检测文件类型用于小文件500KB以内 return t().detectFileTypeByContent(path)
*/ }
export async function detectFileTypeByContent(path: string): Promise<{
extension: string export async function getCommonPaths() {
category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown' return t().getCommonPaths()
mime_type: string
confidence: number
}> {
if (!window.go?.main?.App?.DetectFileTypeByContent) {
throw new Error('DetectFileTypeByContent API 不可用')
}
try {
const result = await window.go.main.App.DetectFileTypeByContent(path)
return result as any
} catch (error) {
debugError('[API] detectFileTypeByContent 错误:', error)
throw error
}
} }

View File

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

71
web/src/api/transport.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* 文件系统传输层接口
* 本地模式走 Wails IPC远程模式走 HTTP REST API
* Composable 和组件不感知底层差异
*/
export type FileItem = {
name: string
path: string
size: number
size_str?: string
is_dir: boolean
mod_time?: string
mode?: string
}
export type FileOperationResult = {
path: string
name: string
size: number
size_str?: string
is_dir: boolean
mod_time?: string
mode?: string
old_path?: string
deleted?: boolean
}
export type DetectTypeResult = {
extension: string
category: string
mime_type: string
confidence: number
}
export interface FsTransport {
// 文件列表与信息
listDir(path: string): Promise<FileItem[]>
getFileInfo(path: string): Promise<Record<string, any>>
// 文件读写
readFile(path: string): Promise<string>
writeFile(path: string, content: string): Promise<void>
saveBase64File(path: string, content: string): Promise<void>
// 文件操作
createFile(dirPath: string, filename: string): Promise<FileOperationResult>
createDir(parentPath: string, dirname: string): Promise<FileOperationResult>
deletePath(path: string): Promise<FileOperationResult>
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
// ZIP 操作Wave 3
listZipContents(zipPath: string): Promise<FileItem[]>
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string>
getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem>
// 系统操作
openPath(path: string): Promise<void>
getFileServerURL(): Promise<string>
getPreviewToken(): string
resolveShortcut(lnkPath: string): Promise<any>
detectFileTypeByContent(path: string): Promise<DetectTypeResult>
getCommonPaths(): Promise<Record<string, string>>
// 回收站Wave 3
getRecycleBinEntries(): Promise<any[]>
restoreFromRecycleBin(path: string): Promise<void>
deletePermanently(path: string): Promise<void>
emptyRecycleBin(): Promise<void>
}

View File

@@ -2,75 +2,6 @@
* API 类型定义 * 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 { export interface SystemInfo {
os: string os: string

View File

@@ -0,0 +1,139 @@
/**
* Wails Transport — 本地文件操作(通过 Wails IPC
*/
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
export class WailsTransport implements FsTransport {
private checkAvailable(method: string) {
if (!window.go?.main?.App?.[method]) {
throw new Error(`${method} API 不可用`)
}
}
async listDir(path: string): Promise<FileItem[]> {
this.checkAvailable('ListDir')
return transformFileList(await window.go.main.App.ListDir(path))
}
async getFileInfo(path: string): Promise<Record<string, any>> {
this.checkAvailable('GetFileInfo')
return window.go.main.App.GetFileInfo(path)
}
async readFile(path: string): Promise<string> {
this.checkAvailable('ReadFile')
return window.go.main.App.ReadFile(path)
}
async writeFile(path: string, content: string): Promise<void> {
this.checkAvailable('WriteFile')
await window.go.main.App.WriteFile({ path: String(path), content: String(content) })
}
async saveBase64File(path: string, content: string): Promise<void> {
this.checkAvailable('SaveBase64File')
if (!content) throw new Error('无效的 base64 内容')
await window.go.main.App.SaveBase64File({ path: String(path), content })
}
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
this.checkAvailable('CreateFile')
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return window.go.main.App.CreateFile(fullPath)
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
this.checkAvailable('CreateDir')
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return window.go.main.App.CreateDir(fullPath)
}
async deletePath(path: string): Promise<FileOperationResult> {
this.checkAvailable('DeletePath')
return window.go.main.App.DeletePath(path)
}
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
this.checkAvailable('RenamePath')
return window.go.main.App.RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
}
async listZipContents(zipPath: string): Promise<FileItem[]> {
this.checkAvailable('ListZipContents')
return transformFileList(await window.go.main.App.ListZipContents(zipPath))
}
async extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
this.checkAvailable('ExtractFileFromZip')
return window.go.main.App.ExtractFileFromZip(zipPath, filePath)
}
async extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
this.checkAvailable('ExtractFileFromZipToTemp')
return window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
}
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
this.checkAvailable('GetZipFileInfo')
return transformFile(await window.go.main.App.GetZipFileInfo(zipPath, filePath))
}
async openPath(path: string): Promise<void> {
this.checkAvailable('OpenPath')
await window.go.main.App.OpenPath(path)
}
async getFileServerURL(): Promise<string> {
this.checkAvailable('GetFileServerURL')
return window.go.main.App.GetFileServerURL()
}
getPreviewToken(): string {
return '' // 本地模式无需 token
}
async resolveShortcut(lnkPath: string): Promise<any> {
this.checkAvailable('ResolveShortcut')
return window.go.main.App.ResolveShortcut(lnkPath)
}
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
this.checkAvailable('DetectFileTypeByContent')
const result = await window.go.main.App.DetectFileTypeByContent(path)
return result as unknown as DetectTypeResult
}
async getCommonPaths(): Promise<Record<string, string>> {
this.checkAvailable('GetCommonPaths')
return window.go.main.App.GetCommonPaths()
}
async getRecycleBinEntries(): Promise<any[]> {
this.checkAvailable('GetRecycleBinEntries')
return window.go.main.App.GetRecycleBinEntries()
}
async restoreFromRecycleBin(path: string): Promise<void> {
this.checkAvailable('RestoreFromRecycleBin')
await window.go.main.App.RestoreFromRecycleBin(path)
}
async deletePermanently(path: string): Promise<void> {
this.checkAvailable('DeletePermanently')
await window.go.main.App.DeletePermanently(path)
}
async emptyRecycleBin(): Promise<void> {
this.checkAvailable('EmptyRecycleBin')
await window.go.main.App.EmptyRecycleBin()
}
}

View File

@@ -0,0 +1,77 @@
<template>
<a-modal :visible="props.visible" :title="editingId ? '编辑连接' : '添加服务器'" unmount-on-close @cancel="emit('update:visible', false)" @before-ok="handleOk" :ok-loading="submitting">
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 400px">
<div>
<div style="margin-bottom: 4px; font-size: 14px">名称</div>
<a-input v-model="form.name" placeholder="如:生产服务器" />
</div>
<div>
<div style="margin-bottom: 4px; font-size: 14px">地址</div>
<a-input v-model="form.host" placeholder="192.168.1.100" />
</div>
<div>
<div style="margin-bottom: 4px; font-size: 14px">端口</div>
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="9876" style="width: 100%" />
</div>
<div>
<div style="margin-bottom: 4px; font-size: 14px">
Token <span style="color: var(--color-text-3); font-size: 12px">API 认证令牌与服务器配置一致</span>
</div>
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { connectionManager } from '@/api/connection-manager'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
const editingId = ref<string | null>(null)
const submitting = ref(false)
const form = reactive({
name: '',
host: '',
port: 9876,
token: '',
})
watch(() => props.visible, (val) => {
if (!val) return
editingId.value = null
Object.assign(form, { name: '', host: '', port: 9876, token: '' })
})
async function handleOk(): Promise<boolean> {
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
if (!form.host?.trim()) { Message.warning('请输入地址'); return false }
submitting.value = true
try {
if (editingId.value) {
connectionManager.updateProfile(editingId.value, { ...form })
Message.success('已更新')
} else {
connectionManager.addProfile({ ...form, type: 'remote' })
Message.success('已添加')
}
return true
} finally {
submitting.value = false
}
}
function editProfile(id: string) {
const profile = connectionManager.profiles.find(p => p.id === id)
if (!profile) return
editingId.value = id
Object.assign(form, { name: profile.name, host: profile.host, port: profile.port, token: profile.token || '' })
}
defineExpose({ editProfile })
</script>

View File

@@ -0,0 +1,270 @@
<template>
<!-- 无远程配置极简入口按钮 -->
<div v-if="!hasRemote" class="connection-indicator mini" @click="$emit('add')">
<icon-cloud />
</div>
<!-- 有远程配置完整标签 + 下拉菜单 -->
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
<span :class="['dot', state]" />
<span class="label">{{ label }}</span>
<div v-if="showMenu" class="menu" @click.stop>
<div class="menu-header">远程连接</div>
<div
v-for="p in profiles"
:key="p.id"
:class="['menu-item', { active: p.id === activeId }]"
@click="handleSelect(p)"
>
<span :class="['dot', p.type === 'remote' ? 'remote' : 'local']"></span>
<span class="menu-name">{{ p.name }}</span>
<span
v-if="p.type === 'remote'"
class="more-btn"
title="更多操作"
@click.stop="toggleMore(p)"
>···</span>
<!-- 更多操作子菜单 -->
<div v-if="moreOpenId === p.id" class="more-menu" @click.stop>
<div class="more-item" @click="handleEdit(p)">编辑</div>
<div class="more-item danger" @click="handleDelete(p)">删除</div>
</div>
</div>
<div class="menu-divider" />
<button class="menu-item add-btn" @click="$emit('add')">
+ 添加服务器
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { IconCloud } from '@arco-design/web-vue/es/icon'
import { connectionManager } from '@/api/connection-manager'
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
const showMenu = ref(false)
const moreOpenId = ref<string | null>(null)
const profiles = shallowRef(connectionManager.profiles)
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
// 是否有远程 profile决定显示模式
const hasRemote = computed(() => profiles.value.some(p => p.type === 'remote'))
// 防抖:避免 connecting→connected 快速切换导致闪烁
const displayState = ref(connectionManager.state)
let _stateTimer: ReturnType<typeof setTimeout> | null = null
const state = computed(() => displayState.value)
const label = computed(() => {
const p = profiles.value.find(p => p.id === activeId.value)
if (!p || p.type === 'local') return '本地'
return p.name
})
// 监听连接变化,主动触发更新(带防抖)
connectionManager.onStateChange((newState) => {
profiles.value = connectionManager.profiles
activeId.value = connectionManager.activeProfile?.id ?? ''
if (_stateTimer) clearTimeout(_stateTimer)
if (newState === 'connecting') {
_stateTimer = setTimeout(() => { displayState.value = newState }, 300)
} else {
displayState.value = newState
}
})
// 点击外部关闭菜单
function handleClickOutside(e: MouseEvent) {
const el = e.target as HTMLElement
if (!el.closest('.connection-indicator')) {
showMenu.value = false
moreOpenId.value = null
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
function handleSelect(p: { id: string }) {
connectionManager.connect(p.id)
showMenu.value = false
emit('select', p.id)
}
function toggleMore(p: { id: string }) {
moreOpenId.value = moreOpenId.value === p.id ? null : p.id
}
function handleEdit(p: { id: string }) {
moreOpenId.value = null
showMenu.value = false
emit('edit', p.id)
}
function handleDelete(p: { id: string; name: string }) {
connectionManager.removeProfile(p.id)
moreOpenId.value = null
}
</script>
<style scoped>
.connection-indicator {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: var(--color-text-2);
transition: background 0.15s, border-color 0.15s;
border: 1px solid transparent;
white-space: nowrap;
flex-shrink: 0;
}
.connection-indicator:hover {
background: var(--color-fill-2);
border-color: var(--color-border-2);
}
/* 极简模式:仅图标 */
.connection-indicator.mini {
padding: 3px 6px;
border: none;
gap: 0;
}
.connection-indicator.mini:hover {
background: var(--color-fill-2);
border-color: transparent;
}
.connection-indicator.mini :deep(.arco-icon) {
font-size: 14px;
color: var(--color-text-3);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.connected { background: rgb(var(--green-6)); }
.dot.connecting { background: #f5a623; animation: pulse 1.5s infinite; }
.dot.disconnected { background: var(--color-danger-6); }
.dot.error { background: var(--color-danger-6); }
.dot.local { background: var(--color-text-3); }
.dot.remote { background: #165dff; }
.label {
max-width: 70px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
background: var(--color-bg-popup);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
z-index: 1000;
padding: 4px 0;
}
.menu-header {
padding: 8px 12px;
font-size: 12px;
color: var(--color-text-3);
border-bottom: 1px solid var(--color-fill-1);
}
.menu-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.menu-item:hover { background: var(--color-fill-1); }
.menu-item.active { background: var(--color-primary-light-1); color: var(--color-primary-6); }
.menu-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.more-btn {
margin-left: auto;
opacity: 0;
font-size: 14px;
color: var(--color-text-3);
cursor: pointer;
padding: 0 4px;
line-height: 1;
letter-spacing: 1px;
transition: opacity 0.15s;
user-select: none;
}
.menu-item:hover .more-btn { opacity: 1; }
/* 更多操作子菜单 */
.more-menu {
position: absolute;
right: 0;
top: 0;
transform: translateX(100%);
min-width: 90px;
background: var(--color-bg-popup);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
padding: 2px 0;
}
.more-item {
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.more-item:hover { background: var(--color-fill-1); }
.more-item.danger { color: var(--color-danger-6); }
.menu-divider {
height: 1px;
background: var(--color-fill-1);
margin: 4px 8px;
}
.add-btn {
width: 100%;
border: none;
background: transparent;
text-align: left;
color: var(--color-primary-6);
font-size: 13px;
padding: 8px 12px;
}
.add-btn:hover { background: var(--color-primary-light-1); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

View File

@@ -1,5 +1,7 @@
<template> <template>
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }"> <div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
<!-- 有选中文件时显示表头和内容 -->
<template v-if="config.currentFileName">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title"> <span class="panel-title">
<template v-if="config.isImageView">🖼 图片预览</template> <template v-if="config.isImageView">🖼 图片预览</template>
@@ -72,7 +74,8 @@
<!-- 视频预览 --> <!-- 视频预览 -->
<div v-else-if="config.isVideoView" class="media-preview"> <div v-else-if="config.isVideoView" class="media-preview">
<video :src="config.previewUrl" controls class="preview-video"></video> <video :src="config.previewUrl" controls class="preview-video" @error="handleMediaError('视频')"></video>
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
<div class="media-meta"> <div class="media-meta">
<a-tag color="arcoblue">🎬 视频</a-tag> <a-tag color="arcoblue">🎬 视频</a-tag>
</div> </div>
@@ -80,7 +83,8 @@
<!-- 音频预览 --> <!-- 音频预览 -->
<div v-else-if="config.isAudioView" class="media-preview"> <div v-else-if="config.isAudioView" class="media-preview">
<audio :src="config.previewUrl" controls class="preview-audio"></audio> <audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')"></audio>
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
<div class="media-meta"> <div class="media-meta">
<a-tag color="green">🎵 音频</a-tag> <a-tag color="green">🎵 音频</a-tag>
</div> </div>
@@ -88,7 +92,8 @@
<!-- PDF 预览 --> <!-- PDF 预览 -->
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf"> <div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
<iframe :src="config.previewUrl" class="preview-pdf"></iframe> <iframe :src="config.previewUrl" class="preview-pdf" @load="handlePdfLoad"></iframe>
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
<div class="media-meta"> <div class="media-meta">
<a-tag color="orangered">📕 PDF</a-tag> <a-tag color="orangered">📕 PDF</a-tag>
</div> </div>
@@ -354,6 +359,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
@@ -367,6 +373,7 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions' import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers' import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
import { connectionManager } from '@/api/connection-manager'
// 异步加载 CodeEditor 组件,减少初始包大小 // 异步加载 CodeEditor 组件,减少初始包大小
const AsyncCodeEditor = defineAsyncComponent({ const AsyncCodeEditor = defineAsyncComponent({
@@ -432,13 +439,27 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// HTML 预览 URL使用后端接口 // HTML 预览 URL实时从 connectionManager 读取,不缓存
function resolveHtmlPreviewBase(): string {
if (!connectionManager.isRemote()) return 'http://localhost:8073'
const base = connectionManager.getFileServerBaseURL()
if (!base) return 'http://localhost:8073'
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfshtmlPreviewUrl 会替换为 html-preview
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
}
const htmlPreviewUrl = computed(() => { const htmlPreviewUrl = computed(() => {
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) { if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return ''
return ''
}
const encodedPath = encodeURIComponent(props.config.currentFileFullPath) const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
return `http://localhost:8073/localfs/html-preview?path=${encodedPath}` const isRemote = connectionManager.isRemote()
const base = resolveHtmlPreviewBase()
if (isRemote) {
// 远程模式:走 /api/v1/proxy/html-preview 路由
const baseUrl = base.replace(/\/proxy\/localfs\/?$/, '')
return `${baseUrl}/proxy/html-preview?path=${encodedPath}`
}
// 本地模式:直连文件服务器
return `${base}/localfs/html-preview?path=${encodedPath}`
}) })
// 计算属性:判断文件是否在当前目录 // 计算属性:判断文件是否在当前目录
@@ -498,6 +519,30 @@ const handleImageError = () => {
emit('imageError') emit('imageError')
} }
const mediaErrorMsg = ref('')
const handleMediaError = (type: string) => {
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
}
const handlePdfLoad = (event: Event) => {
const iframe = event.target as HTMLIFrameElement
try {
// iframe 加载后检查内容是否为空401/404 等错误页面通常内容很少)
if (!iframe.contentDocument || iframe.contentDocument.body.innerHTML.length < 100) {
mediaErrorMsg.value = 'PDF 文件加载失败,请检查网络连接或文件权限'
}
} catch {
// 跨域时无法访问 contentDocument忽略
}
}
// 带认证的 fetch远程模式自动附加 Bearer token
const authFetch = async (url: string): Promise<Response> => {
const token = connectionManager.activeProfile?.token
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
return fetch(url, { headers })
}
// 打印窗口导出 PDF 公共函数 // 打印窗口导出 PDF 公共函数
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => { const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
const printWindow = window.open('', '_blank') const printWindow = window.open('', '_blank')
@@ -650,7 +695,7 @@ const loadExcelPreview = async (filePath: string) => {
// 直接从本地文件服务器获取(不走 base64 // 直接从本地文件服务器获取(不走 base64
const fileUrl = props.config.previewUrl const fileUrl = props.config.previewUrl
const response = await fetch(fileUrl) const response = await authFetch(fileUrl)
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' }) const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
@@ -679,7 +724,7 @@ const loadWordPreview = async (filePath: string) => {
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>' wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
const fileUrl = props.config.previewUrl const fileUrl = props.config.previewUrl
const response = await fetch(fileUrl) const response = await authFetch(fileUrl)
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' }) const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
@@ -709,7 +754,7 @@ const loadCsvPreview = async (filePath: string) => {
const blob = props.config.fileContent && !props.config.isBinaryFile const blob = props.config.fileContent && !props.config.isBinaryFile
? new Blob([props.config.fileContent], { type: 'text/csv' }) ? new Blob([props.config.fileContent], { type: 'text/csv' })
: await (await fetch(props.config.previewUrl)).blob() : await (await authFetch(props.config.previewUrl)).blob()
const file = new File([blob], getFileName(filePath), { type: 'text/csv' }) const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
const result = await previewCsv(file, csvPreviewRef.value) const result = await previewCsv(file, csvPreviewRef.value)
@@ -786,8 +831,8 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073 // Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
const allowedOrigins = [ const allowedOrigins = [
window.location.origin, window.location.origin,
'null', // about:blank 或 data: URL 'null',
'http://localhost:8073', // 本地文件服务器 resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
] ]
if (!allowedOrigins.includes(event.origin)) { if (!allowedOrigins.includes(event.origin)) {
return return
@@ -835,11 +880,9 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px 12px; padding: 3px 12px;
background: var(--color-fill-1); background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
font-size: 13px;
font-weight: 500;
flex-shrink: 0; flex-shrink: 0;
gap: 12px; gap: 12px;
} }
@@ -944,6 +987,13 @@ onUnmounted(() => {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.media-error {
color: var(--color-danger-6);
font-size: 12px;
padding: 4px 0;
text-align: center;
}
.media-meta { .media-meta {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@@ -3,7 +3,6 @@
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">📋 文件列表</span> <span class="panel-title">📋 文件列表</span>
<div class="panel-header-right"> <div class="panel-header-right">
<span class="panel-count">{{ config.fileList.length }} </span>
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }"> <a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
<a-button size="mini" type="text" class="settings-btn"> <a-button size="mini" type="text" class="settings-btn">
<icon-more /> <icon-more />
@@ -50,21 +49,20 @@
</div> </div>
<div <div
class="file-list-wrapper" class="file-list-wrapper thin-dark-scrollbar"
@contextmenu.prevent="handleWrapperContextMenu" @contextmenu.prevent="handleWrapperContextMenu"
> >
<!-- 文件列表a-table --> <!-- 文件列表滚动区域 -->
<a-table <a-table
v-if="config.fileList.length > 0 || config.fileLoading" v-if="config.fileList.length > 0 || config.fileLoading"
:columns="tableColumns" :columns="tableColumns"
:data="config.fileList" :data="pagedFileList"
:loading="config.fileLoading" :loading="config.fileLoading"
:pagination="false" :pagination="false"
:bordered="false" :bordered="false"
:show-header="showHeader" :show-header="showHeader"
size="mini" size="mini"
:row-class-name="getRowClassName" :row-class-name="getRowClassName"
:scroll="{ y: 'auto' }"
class="file-table" class="file-table"
@row-click="handleRowClick" @row-click="handleRowClick"
@row-contextmenu="handleRowContextMenu" @row-contextmenu="handleRowContextMenu"
@@ -76,13 +74,27 @@
<span>此文件夹为空</span> <span>此文件夹为空</span>
</div> </div>
</div> </div>
<!-- 分页栏固定在面板底部不随内容滚动 -->
<div v-if="config.fileList.length > 0" class="pagination-bar">
<span class="pagination-total"> {{ config.fileList.length }} </span>
<span class="pagination-nav">
<span class="page-btn" :class="{ disabled: currentPage <= 1 }" @click="onPageChange(currentPage - 1)">
<icon-left />
</span>
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
<span class="page-btn" :class="{ disabled: currentPage >= totalPages }" @click="onPageChange(currentPage + 1)">
<icon-right />
</span>
</span>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, nextTick, ref } from 'vue' import { h, computed, nextTick, ref, watch } from 'vue'
import { Input, Button } from '@arco-design/web-vue' import { Input, Button } from '@arco-design/web-vue'
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } from '@arco-design/web-vue/es/icon' import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore, IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils' import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
import { STORAGE_KEYS } from '@/utils/constants' import { STORAGE_KEYS } from '@/utils/constants'
import type { FileListPanelConfig, FileItem } from '@/types/file-system' import type { FileListPanelConfig, FileItem } from '@/types/file-system'
@@ -159,8 +171,8 @@ function loadColSettings(): ColumnConfig[] {
} }
const colSettings = ref<ColumnConfig[]>(loadColSettings()) const colSettings = ref<ColumnConfig[]>(loadColSettings())
// 默认显示表头localStorage 无值时兼容旧行为 // 默认隐藏表头localStorage 无值时默认不显示
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false') const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
// 手动持久化(避免 deep watch 频繁写入) // 手动持久化(避免 deep watch 频繁写入)
function saveColSettings() { function saveColSettings() {
@@ -332,6 +344,26 @@ const tableColumns = computed(() => {
.filter(Boolean) .filter(Boolean)
}) })
// ========== 分页 ==========
const currentPage = ref(1)
const pageSize = 100
const pagedFileList = computed(() => {
const list = props.config.fileList
const start = (currentPage.value - 1) * pageSize
return list.slice(start, start + pageSize)
})
const totalPages = computed(() => Math.max(1, Math.ceil(props.config.fileList.length / pageSize)))
const onPageChange = (page: number) => {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
}
// 当文件列表变化时重置到第1页
watch(() => props.config.fileList.length, () => { currentPage.value = 1 })
// ========== 行事件处理 ========== // ========== 行事件处理 ==========
const handleRowClick = (record: FileItem, ev: Event) => { const handleRowClick = (record: FileItem, ev: Event) => {
const target = ev.target as HTMLElement const target = ev.target as HTMLElement
@@ -372,12 +404,13 @@ defineExpose({ focusEditingItem })
.file-list-panel { .file-list-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */
min-height: 0; /* 允许收缩到小于内容高度 */
background: var(--color-bg-1); background: var(--color-bg-1);
} }
.panel-header { .panel-header {
padding: 6px 12px; padding: 3px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: var(--color-bg-2); background: var(--color-bg-2);
flex-shrink: 0; flex-shrink: 0;
@@ -422,15 +455,20 @@ defineExpose({ focusEditingItem })
color: rgb(var(--primary-6)); color: rgb(var(--primary-6));
} }
/* 滚动容器 */ /* 滚动容器table + 分页 的统一滚动层) */
.file-list-wrapper { .file-list-wrapper {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 0 2px; padding: 0 2px;
} }
/* ====== Table 全局覆盖 ====== */ /* ====== Table ====== */
.file-table {
flex: 1;
min-height: 0;
}
.file-table :deep(.arco-table) { .file-table :deep(.arco-table) {
font-size: 13px; font-size: 13px;
table-layout: fixed; table-layout: fixed;
@@ -564,4 +602,35 @@ defineExpose({ focusEditingItem })
gap: 8px; gap: 8px;
} }
.empty-state span:nth-child(2) { font-size: 14px; } .empty-state span:nth-child(2) { font-size: 14px; }
/* 分页栏(固定底部) */
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: var(--color-bg-2);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.pagination-total {
font-size: 12px;
color: var(--color-text-3);
}
.pagination-nav {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.page-btn {
cursor: pointer;
color: var(--color-text-2);
padding: 0 3px;
line-height: 1;
user-select: none;
}
.page-btn:hover:not(.disabled) { color: rgb(var(--primary-6)); }
.page-btn.disabled { color: var(--color-text-4); cursor: default; }
.page-info { color: var(--color-text-2); min-width: 28px; text-align: center; }
</style> </style>

View File

@@ -5,7 +5,7 @@
<!-- 路径段 --> <!-- 路径段 -->
<div <div
class="breadcrumb-segment" class="breadcrumb-segment"
:class="{ 'is-hoverable': index < segments.length - 1 }" :class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
@mouseenter="onHover(segment, index)" @mouseenter="onHover(segment, index)"
@mouseleave="onLeave" @mouseleave="onLeave"
@click="onClick(segment)" @click="onClick(segment)"
@@ -152,7 +152,8 @@ const resetAndClose = () => {
} }
const onHover = (segment: PathSegment, index: number) => { const onHover = (segment: PathSegment, index: number) => {
if (index === segments.value.length - 1) return // 根目录(如 C:)只有一段,也允许悬停弹出子目录
if (index === segments.value.length - 1 && segments.value.length > 1) return
if (hoverTimer.value) clearTimeout(hoverTimer.value) if (hoverTimer.value) clearTimeout(hoverTimer.value)
if (closeTimer.value) clearTimeout(closeTimer.value) if (closeTimer.value) clearTimeout(closeTimer.value)
@@ -209,7 +210,7 @@ watch(() => props.path, () => {
.breadcrumb-items { .breadcrumb-items {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 2px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -242,7 +243,7 @@ watch(() => props.path, () => {
color: var(--color-text-3); color: var(--color-text-3);
font-size: 12px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
margin: 0 2px; margin: 0 1px;
} }
/* 弹出菜单 */ /* 弹出菜单 */

View File

@@ -1,11 +1,15 @@
<template> <template>
<transition name="slide"> <transition name="slide">
<div v-show="config.visible" class="sidebar"> <div v-show="config.visible" class="sidebar">
<div class="sidebar-header"> <!-- 收藏夹区块 -->
<span class="sidebar-title"> 收藏夹</span> <div class="sidebar-section">
<span class="sidebar-count">{{ config.favoriteFiles.length }}</span> <div class="section-header" @click="favCollapsed = !favCollapsed">
<span class="section-title"> 收藏夹</span>
<span class="section-count">{{ config.favoriteFiles.length }}</span>
<icon-down v-if="!favCollapsed" class="section-toggle" />
<icon-right v-else class="section-toggle" />
</div> </div>
<div class="sidebar-content"> <div class="section-content" :class="{ collapsed: favCollapsed }">
<div <div
v-for="(fav, index) in config.favoriteFiles" v-for="(fav, index) in config.favoriteFiles"
:key="fav.path" :key="fav.path"
@@ -17,7 +21,7 @@
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index, 'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index 'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
}" }"
:draggable="config.draggingState.isDragging && config.draggingState.draggedIndex === index" :draggable="config.draggingState.pressedIndex === index || config.draggingState.isDragging"
@click="handleOpenFavorite(fav)" @click="handleOpenFavorite(fav)"
@mousedown="handleLongPressStart($event, index)" @mousedown="handleLongPressStart($event, index)"
@mouseup="handleLongPressCancel" @mouseup="handleLongPressCancel"
@@ -61,11 +65,27 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 帮助文档区块 -->
<div class="sidebar-section">
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
<span class="section-title">📖 帮助</span>
<icon-down v-if="!helpCollapsed" class="section-toggle" />
<icon-right v-else class="section-toggle" />
</div>
<div class="section-content help-content" :class="{ collapsed: helpCollapsed }">
<div class="help-item" v-for="item in helpItems" :key="item.key">
<span class="help-key">{{ item.key }}</span>
<span class="help-desc">{{ item.desc }}</span>
</div>
</div>
</div>
</div>
</transition> </transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed } from 'vue'
import type { SidebarConfig, FavoriteFile } from '@/types/file-system' import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
// Props // Props
@@ -75,6 +95,10 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
// 折叠状态(组件内部,不污染父组件)
const favCollapsed = ref(false)
const helpCollapsed = ref(true)
// 计算第一个和最后一个置顶项的索引 // 计算第一个和最后一个置顶项的索引
const pinnedIndices = computed(() => { const pinnedIndices = computed(() => {
return props.config.favoriteFiles return props.config.favoriteFiles
@@ -85,6 +109,15 @@ const pinnedIndices = computed(() => {
const firstPinnedIndex = computed(() => pinnedIndices.value[0] ?? -1) const firstPinnedIndex = computed(() => pinnedIndices.value[0] ?? -1)
const lastPinnedIndex = computed(() => pinnedIndices.value[pinnedIndices.value.length - 1] ?? -1) const lastPinnedIndex = computed(() => pinnedIndices.value[pinnedIndices.value.length - 1] ?? -1)
// 帮助内容
const helpItems = [
{ key: 'Ctrl+B', desc: '切换侧边栏' },
{ key: 'Ctrl+H', desc: '历史记录' },
{ key: 'Ctrl+F', desc: '聚焦搜索' },
{ key: 'Click ⭐', desc: '收藏文件' },
{ key: 'Drag', desc: '排序收藏' },
]
// Emits // Emits
interface Emits { interface Emits {
(e: 'openFavorite', file: FavoriteFile): void (e: 'openFavorite', file: FavoriteFile): void
@@ -101,7 +134,7 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// 图标导入 // 图标导入
import { IconStar, IconClose, IconPushpin } from '@arco-design/web-vue/es/icon' import { IconStar, IconClose, IconPushpin, IconDown, IconRight } from '@arco-design/web-vue/es/icon'
import { getFileIcon } from '@/utils/fileUtils' import { getFileIcon } from '@/utils/fileUtils'
// 事件处理 // 事件处理
@@ -151,37 +184,100 @@ const handleDragEnd = () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto;
} }
.sidebar-header { /* 区块 */
padding: 12px 16px; .sidebar-section {
border-bottom: 1px solid var(--color-border);
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center;
background: var(--color-bg-2);
} }
.sidebar-title { /* 区块头部 - 可点击折叠 */
font-size: 14px; .section-header {
font-weight: 600; padding: 5px 12px;
background: var(--color-bg-2);
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.section-header:hover {
background: var(--color-fill-1);
}
.section-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1); color: var(--color-text-1);
} }
.sidebar-count { .section-count {
font-size: 12px; font-size: 12px;
color: var(--color-text-3); color: var(--color-text-3);
background: var(--color-fill-2);
padding: 2px 8px;
border-radius: 10px;
} }
.sidebar-content { .section-toggle {
font-size: 12px;
color: var(--color-text-3);
margin-left: auto;
transition: transform 0.2s;
}
/* 区块内容 - 可折叠 */
.section-content {
overflow: hidden;
transition: max-height 0.25s ease, opacity 0.2s ease;
max-height: calc(100vh - 80px);
opacity: 1;
}
.section-content.collapsed {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
/* 收藏夹内容 */
.section-content:not(.help-content) {
flex: 1; flex: 1;
overflow-y: auto; min-height: 0;
padding: 8px; padding: 4px 8px 0;
} }
/* 帮助内容 */
.help-content {
padding: 4px 12px;
}
.help-item {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
font-size: 12px;
}
.help-key {
font-family: 'Consolas', 'Monaco', monospace;
background: var(--color-fill-2);
padding: 1px 6px;
border-radius: 3px;
color: var(--color-text-2);
white-space: nowrap;
min-width: 56px;
text-align: center;
}
.help-desc {
color: var(--color-text-3);
}
/* 收藏项 */
.sidebar-item { .sidebar-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -257,6 +353,7 @@ const handleDragEnd = () => {
opacity: 1; opacity: 1;
} }
/* 空状态 */
.sidebar-empty { .sidebar-empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -283,7 +380,7 @@ const handleDragEnd = () => {
margin-top: 4px; margin-top: 4px;
} }
/* 滑动动画 */ /* 侧边栏整体滑入滑出动画 */
.slide-enter-active, .slide-enter-active,
.slide-leave-active { .slide-leave-active {
transition: all 0.3s ease; transition: all 0.3s ease;

View File

@@ -9,7 +9,7 @@
📦 {{ config.zipFileName }} 📦 {{ config.zipFileName }}
</a-tag> </a-tag>
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0"> <template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
<icon-right class="breadcrumb-separator" /> <icon-right class="breadcrumb-sep" />
<a-tag <a-tag
v-for="(crumb, index) in config.zipBreadcrumbs" v-for="(crumb, index) in config.zipBreadcrumbs"
:key="index" :key="index"
@@ -25,11 +25,22 @@
退出 ZIP 退出 ZIP
</a-button> </a-button>
</div> </div>
<!-- 正常模式面包屑导航 --> <!-- 正常模式连接指示器 + 面包屑导航融合布局 -->
<div v-else class="path-breadcrumb-wrapper"> <div v-else class="path-breadcrumb-wrapper">
<!-- 快捷访问仅图标面包屑 --> <!-- 连接指示器紧凑标签样式作为面包屑首段 -->
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
<span class="breadcrumb-sep"></span>
<!-- 路径面包屑 -->
<PathBreadcrumb
:path="config.filePath"
@navigate="handleGoToPath"
@openFile="handleOpenFile"
/>
<!-- 右侧操作快捷路径 + 复制 -->
<div class="breadcrumb-right-actions">
<a-tooltip content="快捷路径" position="bottom">
<a-dropdown> <a-dropdown>
<a-button size="mini" type="text"> <a-button size="mini" type="text" class="shortcut-btn">
<template #icon><icon-forward /></template> <template #icon><icon-forward /></template>
</a-button> </a-button>
<template #content> <template #content>
@@ -43,11 +54,7 @@
</a-doption> </a-doption>
</template> </template>
</a-dropdown> </a-dropdown>
<PathBreadcrumb </a-tooltip>
:path="config.filePath"
@navigate="handleGoToPath"
@openFile="handleOpenFile"
/>
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top"> <a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
<a-button <a-button
size="mini" size="mini"
@@ -63,6 +70,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="toolbar-right"> <div class="toolbar-right">
<!-- 搜索框 --> <!-- 搜索框 -->
@@ -73,7 +81,7 @@
class="toolbar-search" class="toolbar-search"
allow-clear allow-clear
@search="handleSearch" @search="handleSearch"
@update:model-value="handleSearchInput" @update:model-value="handleSearch"
@keyup.escape="handleClearSearch" @keyup.escape="handleClearSearch"
/> />
@@ -124,13 +132,18 @@
</a-button> </a-button>
</div> </div>
</div> </div>
<ConnectionDialog ref="connectionDialogRef" v-model:visible="showConnectionDialog" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick } from 'vue'
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon' import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
import type { ToolbarConfig } from '@/types/file-system' import type { ToolbarConfig } from '@/types/file-system'
import PathBreadcrumb from './PathBreadcrumb.vue' import PathBreadcrumb from './PathBreadcrumb.vue'
import { useClipboardCopy } from '../composables/useClipboardCopy' import { useClipboardCopy } from '../composables/useClipboardCopy'
import ConnectionIndicator from './ConnectionIndicator.vue'
import ConnectionDialog from './ConnectionDialog.vue'
// Props // Props
interface Props { interface Props {
@@ -149,10 +162,24 @@ interface Emits {
(e: 'goToPath', path: string): void (e: 'goToPath', path: string): void
(e: 'openFile', path: string): void (e: 'openFile', path: string): void
(e: 'navigateToZipDirectory', path: string): void (e: 'navigateToZipDirectory', path: string): void
(e: 'connectionChanged'): void
} }
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// 连接对话框
const showConnectionDialog = ref(false)
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
const onConnectionChanged = async (_id: string) => {
emit('connectionChanged')
}
const onEditProfile = (id: string) => {
showConnectionDialog.value = true
// 等待 DOM 更新后调用 editProfile 填充表单
nextTick(() => connectionDialogRef.value?.editProfile(id))
}
// 历史记录下拉显隐(供父组件 Ctrl+H 调用) // 历史记录下拉显隐(供父组件 Ctrl+H 调用)
const historyPopupVisible = ref(false) const historyPopupVisible = ref(false)
@@ -177,10 +204,6 @@ const handleNavigateToZipRoot = () => {
emit('navigateToZipDirectory', '') emit('navigateToZipDirectory', '')
} }
const handleNavigateToZipDirectory = (path: string) => {
emit('navigateToZipDirectory', path)
}
const handleToggleSidebar = () => { const handleToggleSidebar = () => {
emit('update:showSidebar', !props.config.showSidebar) emit('update:showSidebar', !props.config.showSidebar)
} }
@@ -189,10 +212,6 @@ const handleSearch = (keyword: string) => {
emit('update:searchKeyword', keyword) emit('update:searchKeyword', keyword)
} }
const handleSearchInput = (keyword: string) => {
emit('update:searchKeyword', keyword)
}
const handleClearSearch = () => { const handleClearSearch = () => {
emit('update:searchKeyword', '') emit('update:searchKeyword', '')
} }
@@ -237,6 +256,11 @@ const handleCopyPath = async () => {
flex-shrink: 0; flex-shrink: 0;
} }
.toolbar-right :deep(.arco-btn-size-small),
.toolbar-right :deep(.arco-input-wrapper) {
height: 34px;
}
.toolbar-search { .toolbar-search {
width: 180px; width: 180px;
flex-shrink: 0; flex-shrink: 0;
@@ -247,27 +271,44 @@ const handleCopyPath = async () => {
min-width: 200px; min-width: 200px;
} }
.path-input {
width: 100%;
}
.path-breadcrumb-wrapper { .path-breadcrumb-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
gap: 8px; gap: 4px;
padding: 4px 8px; padding: 4px 8px;
background: var(--color-fill-1); background: var(--color-fill-1);
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
transition: border-color 0.2s; transition: border-color 0.2s;
overflow: visible;
} }
.path-breadcrumb-wrapper:hover { .path-breadcrumb-wrapper:hover {
border-color: var(--color-border-2); border-color: var(--color-border-2);
} }
.breadcrumb-sep {
color: var(--color-text-3);
font-size: 12px;
flex-shrink: 0;
line-height: 1;
margin: 0 1px;
}
.breadcrumb-right-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.shortcut-btn {
padding: 1px 3px;
}
.toolbar-copy-btn { .toolbar-copy-btn {
padding: 2px 4px; padding: 2px 4px;
} }
@@ -293,12 +334,6 @@ const handleCopyPath = async () => {
border-color: rgb(var(--primary-6)); border-color: rgb(var(--primary-6));
} }
.breadcrumb-separator {
color: var(--color-text-3);
font-size: 12px;
flex-shrink: 0;
}
.breadcrumb-tag { .breadcrumb-tag {
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
@@ -312,15 +347,6 @@ const handleCopyPath = async () => {
border-color: rgb(var(--primary-6)); border-color: rgb(var(--primary-6));
} }
.zip-path-text {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 历史记录下拉 */ /* 历史记录下拉 */
.history-dropdown-content { .history-dropdown-content {
max-width: 420px; max-width: 420px;

View File

@@ -5,90 +5,56 @@
import { ref } from 'vue' import { ref } from 'vue'
import { PATH_ICONS } from '@/utils/constants' import { PATH_ICONS } from '@/utils/constants'
import { getCommonPaths } from '@/api/system'
import { connectionManager } from '@/api/connection-manager'
import type { ShortcutPath } from '@/types/file-system' import type { ShortcutPath } from '@/types/file-system'
export function useCommonPaths() { export function useCommonPaths() {
// 系统路径
const commonPaths = ref<ShortcutPath[]>([]) const commonPaths = ref<ShortcutPath[]>([])
const systemPaths = ref<Record<string, string>>({}) const systemPaths = ref<Record<string, string>>({})
/**
* 加载常用系统路径
*/
const loadCommonPaths = async () => { const loadCommonPaths = async () => {
try { try {
// 检查 Wails API 是否可用 const paths = await getCommonPaths()
if (!window.go?.main?.App?.GetCommonPaths) { if (!paths) throw new Error('无法获取系统路径')
// 降级方案:使用默认路径
commonPaths.value = [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
return
}
const paths = await window.go.main.App.GetCommonPaths()
if (!paths) {
throw new Error('无法获取系统路径')
}
systemPaths.value = paths systemPaths.value = paths
const platform = window.navigator.platform
const pathList: ShortcutPath[] = [] const pathList: ShortcutPath[] = []
// 根据返回数据判断平台Linux agent 返回 root keyWindows 返回 root_ 前缀)
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
if (platform.includes('Win')) { if (isWin) {
// Windows: 先添加基础路径,再添加所有盘符
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop }) if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents }) if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads }) if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home }) if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
// 动态添加所有盘符(按字母顺序)
const drives: Array<{ letter: string; path: string }> = [] const drives: Array<{ letter: string; path: string }> = []
for (const key in paths) { for (const key in paths) {
if (key.startsWith('root_')) { if (key.startsWith('root_')) {
const driveLetter = key.substring(5) drives.push({ letter: key.substring(5), path: paths[key] })
drives.push({
letter: driveLetter,
path: paths[key]
})
} }
} }
drives.sort((a, b) => a.letter.localeCompare(b.letter)) drives.sort((a, b) => a.letter.localeCompare(b.letter))
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}`, path: d.path }))
// 添加盘符到路径列表
drives.forEach(drive => {
pathList.push({
name: `${PATH_ICONS.DRIVE} ${drive.letter}`,
path: drive.path
})
})
} else { } else {
// macOS/Linux: 使用系统路径 // Linux 远程模式
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home }) if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }) if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
if (paths.users) pathList.push({ name: `👥 /home`, path: paths.users })
} }
commonPaths.value = pathList.length > 0 ? pathList : [ commonPaths.value = pathList.length > 0 ? pathList : (
{ name: '💿 C盘', path: 'C:\\' }, connectionManager.isRemote()
{ name: '💿 D盘', path: 'D:\\' } ? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
] : [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
)
} catch (error) { } catch (error) {
console.error('加载系统路径失败:', error) console.error('加载系统路径失败:', error)
// 降级方案 commonPaths.value = connectionManager.isRemote()
commonPaths.value = [ ? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
{ name: '💿 C盘', path: 'C:\\' }, : [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
{ name: '💿 D盘', path: 'D:\\' }
]
} }
} }
return { return { commonPaths, systemPaths, loadCommonPaths }
commonPaths,
systemPaths,
loadCommonPaths
}
} }

View File

@@ -21,18 +21,12 @@ export function useFavorites() {
}) })
/** /**
* 排序收藏列表:置顶项在前(按 pinnedAt 降序),非置顶项按添加时间降序 * 排序收藏列表:置顶项归到前面,组内保持原有顺序(尊重拖拽)
*/ */
const sortFavorites = () => { const sortFavorites = () => {
favorites.value = [...favorites.value].sort((a, b) => { const pinned = favorites.value.filter(f => f.pinnedAt)
// 置顶项优先 const unpinned = favorites.value.filter(f => !f.pinnedAt)
if (a.pinnedAt && !b.pinnedAt) return -1 favorites.value = [...pinned, ...unpinned]
if (!a.pinnedAt && b.pinnedAt) return 1
// 都是置顶项,按置顶时间降序
if (a.pinnedAt && b.pinnedAt) return b.pinnedAt - a.pinnedAt
// 都不是置顶项,按添加时间降序(最新在前)
return b.addedAt - a.addedAt
})
} }
/** /**
@@ -50,7 +44,7 @@ export function useFavorites() {
isDir: fav.isDir ?? (fav as any).is_dir ?? false isDir: fav.isDir ?? (fav as any).is_dir ?? false
})) }))
// // 仅排序(置顶项归组到前面),保持用户拖拽顺
sortFavorites() sortFavorites()
} }
} catch (error) { } catch (error) {
@@ -171,15 +165,23 @@ export function useFavorites() {
} }
// 拖拽方法 // 拖拽方法
const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => { const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
if (event instanceof MouseEvent && event.button !== 0) return if (event instanceof MouseEvent && event.button !== 0) return
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
longPressTimer = setTimeout(() => {
draggingState.value.pressedIndex = index draggingState.value.pressedIndex = index
draggingState.value.draggedIndex = index draggingState.value.draggedIndex = index
}, 200)
} }
const onLongPressCancel = () => { const onLongPressCancel = () => {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
if (!draggingState.value.isDragging) { if (!draggingState.value.isDragging) {
draggingState.value.pressedIndex = -1 draggingState.value.pressedIndex = -1
draggingState.value.draggedIndex = -1 draggingState.value.draggedIndex = -1
@@ -191,7 +193,6 @@ export function useFavorites() {
draggingState.value.draggedIndex = index draggingState.value.draggedIndex = index
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move' event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', index.toString())
} }
} }

View File

@@ -7,6 +7,7 @@ import { ref } from 'vue'
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants' import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { normalizeFilePath, getExt } from '@/utils/fileUtils' import { normalizeFilePath, getExt } from '@/utils/fileUtils'
import { detectFileTypeByContent } from '@/api/system' import { detectFileTypeByContent } from '@/api/system'
import { connectionManager } from '@/api/connection-manager'
import { import {
isImageFile, isVideoFile, isAudioFile, isPdfFile, isImageFile, isVideoFile, isAudioFile, isPdfFile,
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType, isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
@@ -26,21 +27,22 @@ export interface UseFilePreviewOptions {
isBrowsingZip?: boolean isBrowsingZip?: boolean
} }
function getLocalServerURL(): string {
return 'http://localhost:8073'
}
function resolveFileServerBase(): string {
// 单一数据源:从 connectionManager 实时读取,不缓存
if (!connectionManager.isRemote()) return getLocalServerURL()
const base = connectionManager.getFileServerBaseURL()
if (!base) return getLocalServerURL()
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
}
export function useFilePreview(options: UseFilePreviewOptions = {}) { export function useFilePreview(options: UseFilePreviewOptions = {}) {
const { filePath = ref(''), isBrowsingZip = ref(false) } = options const { filePath = ref(''), isBrowsingZip = ref(false) } = options
// 文件服务器 URL优先从后端获取降级到默认值
let _fileServerURL = 'http://localhost:8073'
const initFileServerURL = async () => {
try {
const url = await window.go.main.App.GetFileServerURL()
if (url) _fileServerURL = url
} catch { /* 使用默认值 */ }
}
initFileServerURL()
const getFileServerURL = () => _fileServerURL
// 预览 URL // 预览 URL
const previewUrl = ref('') const previewUrl = ref('')
@@ -49,12 +51,19 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
const currentImageDimensions = ref('') const currentImageDimensions = ref('')
/** /**
* 获取预览 URL与旧版本保持一致 * 获取预览 URL本地/远程自适应,每次实时计算
* 本地: http://localhost:8073/localfs/{encoded_path}
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}Cookie 自动携带认证)
*/ */
const getPreviewUrl = (path: string): string => { const getPreviewUrl = (path: string): string => {
if (!path) return '' if (!path) return ''
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径 const isRemote = connectionManager.isRemote()
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}` const base = resolveFileServerBase()
let normalized = normalizeFilePath(path, true)
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
const sep = base.endsWith('/') ? '' : '/'
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
} }
/** /**
@@ -85,7 +94,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
/** /**
* 更新预览 URL * 更新预览 URL
*/ */
const updatePreviewUrl = (path: string) => { const updatePreviewUrl = async (path: string) => {
previewUrl.value = getPreviewUrl(path) previewUrl.value = getPreviewUrl(path)
} }

View File

@@ -13,6 +13,7 @@
@navigate-to-zip-directory="handleNavigateToZipDirectory" @navigate-to-zip-directory="handleNavigateToZipDirectory"
@update:search-keyword="handleSearchKeywordUpdate" @update:search-keyword="handleSearchKeywordUpdate"
@show-message="handleShowMessage" @show-message="handleShowMessage"
@connection-changed="handleConnectionChanged"
/> />
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -56,9 +57,8 @@
<!-- 分隔条 --> <!-- 分隔条 -->
<div class="resizer" @mousedown="handleHorizontalResize"></div> <div class="resizer" @mousedown="handleHorizontalResize"></div>
<!-- 文件编辑器面板 --> <!-- 文件编辑器面板始终显示无选中文件时为空白预览区 -->
<FileEditorPanel <FileEditorPanel
v-if="hasSelectedFile"
:config="fileEditorPanelConfig" :config="fileEditorPanelConfig"
:width="panelWidth.right" :width="panelWidth.right"
:current-directory="filePath" :current-directory="filePath"
@@ -107,7 +107,7 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils' import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions' import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
// 导入子组件 // 导入子组件
@@ -129,6 +129,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
import { getFileName, sortFileList } from '@/utils/fileUtils' import { getFileName, sortFileList } from '@/utils/fileUtils'
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers' import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
import { listDir, saveBase64File } from '@/api/system' import { listDir, saveBase64File } from '@/api/system'
import { connectionManager } from '@/api/connection-manager'
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants' import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { createResizeHandler } from '@/utils/resize' import { createResizeHandler } from '@/utils/resize'
@@ -336,10 +337,24 @@ const computeRendered = computed(() => {
if (isHtmlFile(currentFileName)) { if (isHtmlFile(currentFileName)) {
return fileContent.value || '' return fileContent.value || ''
} else if (isMarkdownFile(currentFileName)) { } else if (isMarkdownFile(currentFileName)) {
// 使用配置好的 marked 渲染 Markdown支持 mermaid // 使用配置好的 marked 渲染 Markdown支持 mermaid + 图片相对路径转换
try { try {
const content = fileContent.value || '' const content = fileContent.value || ''
return marked(content)
// 设置图片路径转换所需的上下文renderer.image 钩子中读取)
// dir: 当前 md 文件所在目录(从文件完整路径中去掉文件名)
const fullPath = selectedFileItem.value?.path || ''
const dir = fullPath ? fullPath.replace(/[/\\][^/\\]+$/, '') : (filePath.value || '')
setCurrentFileDir(dir)
// 设置文件服务器 Base URL
const isRemote = connectionManager.isRemote()
const base = isRemote
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
: 'http://localhost:8073/localfs'
setFileServerBase(base)
return marked.parse(content) as string
} catch (error) { } catch (error) {
console.error('Markdown 解析失败:', error) console.error('Markdown 解析失败:', error)
return fileContent.value || '' return fileContent.value || ''
@@ -399,10 +414,23 @@ const handleRefresh = async () => {
await loadDirectory(filePath.value) await loadDirectory(filePath.value)
} }
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
connectionManager.onStateChange(async (state) => {
if (state === 'connected') {
await loadCommonPaths()
}
})
const handleSearchKeywordUpdate = (keyword: string) => { const handleSearchKeywordUpdate = (keyword: string) => {
searchKeyword.value = keyword searchKeyword.value = keyword
} }
// 用户主动切换连接时重置到根路径
const handleConnectionChanged = async () => {
await loadCommonPaths()
await navigate(connectionManager.isRemote() ? '/' : 'C:/')
}
const handleGoToPath = async (path: string) => { const handleGoToPath = async (path: string) => {
await navigate(path) await navigate(path)
} }
@@ -500,6 +528,26 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
// 侧边栏事件 // 侧边栏事件
const handleOpenFavorite = async (file: FavoriteFile) => { const handleOpenFavorite = async (file: FavoriteFile) => {
// 根据路径格式自动切换连接Linux 路径 → 远程Windows 路径 → 本地)
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
const shouldBeRemote = isLinuxPath
const isCurrentlyRemote = connectionManager.isRemote()
if (shouldBeRemote !== isCurrentlyRemote) {
// 需要切换连接
if (shouldBeRemote) {
// 切换到远程:找第一个 remote profile
const remoteProfile = connectionManager.profiles.find(p => p.type === 'remote')
if (remoteProfile) {
connectionManager.connect(remoteProfile.id)
}
} else {
// 切换到本地
connectionManager.disconnect()
}
await loadCommonPaths()
}
if (file.isDir) { if (file.isDir) {
await navigate(file.path) await navigate(file.path)
} else { } else {
@@ -1024,6 +1072,9 @@ const selectFile = async (path: string) => {
// 加载文件内容 // 加载文件内容
await loadFileContent(path) await loadFileContent(path)
// 记住上次打开的文件
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
} }
const loadFileContent = async (path: string) => { const loadFileContent = async (path: string) => {
@@ -1178,7 +1229,7 @@ const extractZipImageAndPreview = async (zipPath: string, filePath: string): Pro
const temp = await fileOps.extractZipFileToTemp(zipPath, filePath) const temp = await fileOps.extractZipFileToTemp(zipPath, filePath)
const url = await fileOps.getFileServerURL() const url = await fileOps.getFileServerURL()
const normalized = temp.replace(/\\/g, '/') const normalized = temp.replace(/\\/g, '/')
updatePreviewUrl(`${url}/localfs/${encodeURIComponent(normalized)}`) updatePreviewUrl(normalized)
} catch (error) { } catch (error) {
console.error('提取图片失败:', error) console.error('提取图片失败:', error)
Message.error(`提取图片失败: ${error}`) Message.error(`提取图片失败: ${error}`)
@@ -1217,18 +1268,32 @@ const handleHorizontalResize = createResizeHandler(
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(() => { onMounted(async () => {
// 加载系统路径 // 加载系统路径(阻塞,确保快捷入口就绪)
loadCommonPaths() await loadCommonPaths()
// 初始化加载 // 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
if (!filePath.value) { const startPath = connectionManager.isRemote() ? '/'
// 设置默认路径 : (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
const defaultPath = commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:\\' if (filePath.value && !connectionManager.isRemote()) {
filePath.value = defaultPath await loadDirectory(filePath.value)
loadDirectory(defaultPath)
} else { } else {
loadDirectory(filePath.value) filePath.value = startPath
await loadDirectory(startPath)
}
// 恢复上次打开的文件
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
if (lastFile) {
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
const currentDir = filePath.value.replace(/\\/g, '/').replace(/\/+$/, '')
const lastFileDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/'
if (lastFileDir.toLowerCase() === currentDir.toLowerCase()) {
const found = fileList.value.find(f => f.path.replace(/\\/g, '/').toLowerCase() === normalized.toLowerCase())
if (found && !found.isDir) {
await selectFile(found.path)
}
}
} }
// 添加键盘快捷键 // 添加键盘快捷键
@@ -1497,7 +1562,7 @@ watch(() => themeStore.isDark, async () => {
} }
.resizer { .resizer {
width: 4px; width: 3px;
background: var(--color-border); background: var(--color-border);
cursor: col-resize; cursor: col-resize;
transition: background 0.2s; transition: background 0.2s;

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
/**
* 连接状态 Pinia Store
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { connectionManager, type ConnectionProfile, type ConnectionState } from '@/api/connection-manager'
export const useConnectionStore = defineStore('connection', () => {
const state = ref<ConnectionState>(connectionManager.state)
const activeProfile = ref<ConnectionProfile | null>(connectionManager.activeProfile)
connectionManager.onStateChange((s) => { state.value = s })
const isConnected = computed(() => state.value === 'connected')
const isRemote = computed(() => connectionManager.isRemote())
const fileServerBaseURL = computed(() => connectionManager.getFileServerBaseURL())
function connect(id: string) {
connectionManager.connect(id)
activeProfile.value = connectionManager.activeProfile
}
function disconnect() {
connectionManager.disconnect()
activeProfile.value = connectionManager.activeProfile
}
function refresh() {
activeProfile.value = connectionManager.activeProfile
state.value = connectionManager.state
}
return {
state,
activeProfile,
isConnected,
isRemote,
fileServerBaseURL,
connect,
disconnect,
refresh,
}
})

View File

@@ -30,6 +30,7 @@ export const STORAGE_KEYS = {
SORT: 'app-filesystem-sort', // 排序状态 SORT: 'app-filesystem-sort', // 排序状态
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序) COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐 SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
}, },
// 设备测试模块 // 设备测试模块
@@ -154,6 +155,7 @@ export const FILE_ICONS = {
RUBY: '💎', RUBY: '💎',
DART: '🎯', DART: '🎯',
DOCKERFILE: '🐳', DOCKERFILE: '🐳',
VUE: '💚',
// 数据库 // 数据库
DATABASE: '🗄️', DATABASE: '🗄️',
@@ -270,6 +272,8 @@ const initIconMap = () => {
'dart': FILE_ICONS.DART, 'dart': FILE_ICONS.DART,
// Dockerfile // Dockerfile
'dockerfile': FILE_ICONS.DOCKERFILE, 'dockerfile': FILE_ICONS.DOCKERFILE,
// Vue
'vue': FILE_ICONS.VUE,
} }
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext])) Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))

View File

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

View File

@@ -100,6 +100,80 @@ renderer.heading = function(token: any) {
</h${depth}>` </h${depth}>`
} }
// ========== 图片相对路径转换支持 ==========
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
let _currentFileDir: string = ''
// 文件服务器 Base URL由调用方在渲染前设置
let _fileServerBase: string = 'http://localhost:8073/localfs'
/**
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
* @param dir 文件所在目录的绝对路径,如 "D:/docs" 或 "/"(根目录)
*/
export function setCurrentFileDir(dir: string): void {
_currentFileDir = dir
}
/** 获取当前设置的文件目录 */
export function getCurrentFileDir(): string {
return _currentFileDir
}
/**
* 设置文件服务器 Base URL用于图片相对路径转换
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
*/
export function setFileServerBase(base: string): void {
_fileServerBase = base
}
/**
* 将相对路径图片 src 解析为文件服务器 URL
* - 绝对路径Windows: D:/...、Unix: /usr/...、网络URL、data URI → 不转换
* - 相对路径 → 基于当前文件目录解析为绝对路径,再编码为文件服务器 URL
*/
function resolveImageUrl(src: string, fileServerBase: string): string {
if (!src) return src
// 不转换绝对路径Windows 盘符、网络协议、锚点、data URI
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
// 解析相对路径(处理 ../ 和 ./
const dir = _currentFileDir || '/'
const sep = dir.includes('\\') ? '\\' : '/'
let resolved = normalizeRelativePath(dir, src, sep)
// 编码路径(保留 / 分隔符)
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
return `${fileServerBase}/${encoded}`
}
/**
* 规范化相对路径,处理 .. 和 . 段
*/
function normalizeRelativePath(base: string, relative: string, sep: string): string {
// 确保基础路径不以分隔符结尾
let baseNormalized = base.replace(/[\\/]+$/, '')
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
const baseParts = baseNormalized.split(sep).filter(Boolean)
const relParts = relative.split(/[\\/]/).filter(Boolean)
for (const part of relParts) {
if (part === '..') {
baseParts.pop() // 向上一级
} else if (part !== '.') {
baseParts.push(part)
}
}
// 重建路径Windows 绝对路径保留盘符前缀
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
return baseParts.join(sep)
}
// Unix 风格:以 / 开头
return sep + baseParts.join(sep)
}
// 判断是否为本地文件链接(相对路径或本地绝对路径) // 判断是否为本地文件链接(相对路径或本地绝对路径)
const isLocalFileLink = (href: string): boolean => { const isLocalFileLink = (href: string): boolean => {
if (!href) return false if (!href) return false
@@ -108,6 +182,23 @@ const isLocalFileLink = (href: string): boolean => {
return true return true
} }
// 自定义图片渲染器 - 转换相对路径为文件服务器 URL
renderer.image = function(token: any) {
const src = token.href || ''
const title = token.title || ''
const alt = token.text || ''
const titleAttr = title ? ` title="${title}"` : ''
// 判断是否需要转换(仅处理相对路径,且当前目录已设置)
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
}
// 默认渲染(绝对路径 / 网络 URL / data URI / 未设置目录时原样输出)
return `<img src="${src}" alt="${alt}"${titleAttr}>`
}
// 自定义链接渲染器 - 支持本地文件链接 // 自定义链接渲染器 - 支持本地文件链接
renderer.link = function(token: any) { renderer.link = function(token: any) {
const href = token.href || '' const href = token.href || ''
@@ -126,7 +217,7 @@ renderer.link = function(token: any) {
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>` return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
} }
marked.use({ renderer, breaks: true, gfm: true }) marked.use({ renderer, breaks: true, gfm: true, async: false })
export { marked } export { marked }

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More