Private
Public Access
1
0

1 Commits

Author SHA1 Message Date
90695d71d1 发布:v0.3.3 版本历史模块 + 域名迁移 + 站点版本信息修正
- 版本号更新至 0.3.3(version.go/wails.json/README.md)
- 更新检查域名迁移 img.1216.top → c.1216.top
- 新增 views/version 版本历史 Tab 页面(时间线 UI)
- 设置面板新增版本历史入口按钮
- CHANGELOG 补全 0.3.3 全部 17 个提交记录
- 站点 HTML 修正(删除错误 v0.4.0,v0.3.3 为最新)
- 生成 last-version.json / versions.json 发布数据
2026-04-13 23:49:21 +08:00
144 changed files with 18821 additions and 3111 deletions

View File

@@ -1,48 +1,5 @@
# 更新日志 # 更新日志
## [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
### 新增 ✨
- **CodeMirror 搜索功能**: Ctrl+F / Ctrl+H 全局查找替换,`@codemirror/search` 集成
- **编辑器滚动位置恢复**: LRU 缓存最多5份/3分钟TTL切换文件不丢位置
- **文件列表列排序**: 图标/名称/时间/大小四列可排序,升序降序切换
- **文件搜索过滤**: 工具栏实时搜索框,按文件名过滤列表
- **Toolbar UI 重排**: 快捷访问内嵌面包屑左侧、历史记录改为图标+tooltip、Ctrl+H 快捷键
- **更新面板 Markdown 渲染**: changelog 用 `marked.parse()` 结构化渲染,替代纯文本
- **重命名零闪烁**: `updateFilePath()` 仅迁移路径引用+草稿key不重新加载内容
### 优化 🚀
- **路径安全重构**: `validateFilePath()` 提取统一函数,消除两处重复校验代码
- **requireUpdateAPI 模式**: 7 处重复 nil 检查收敛为 guard 方法
- **端口统一**: 文件服务器端口 18765→8073全局一致消除魔法数字分散
- **文件服务器 URL 动态获取**: 前端从后端 API 获取,不再硬编码
- **Tab 配置迁移扩展**: MigrateTabConfig 改为 map 驱动,覆盖 openclaw-manager→version 迁移
- **updateContent 简化**: 去掉时间窗口双重检查,仅保留版本号机制
### 安全修复 🔒
- **sentinel error 替代字符串匹配**: validateFilePath 错误用 `errors.Is()` 判断,消息变更不再静默失效
- **sanitizeHtml 防御远程 Markdown XSS**: 过滤 script/iframe/embed/on* 事件属性
### 修复 🐛
- **showHeader 默认值修正**: localStorage 无值时默认显示表头(兼容旧行为)
- **外层容器双重 scroll reset 移除**: 避免 CodeEditor 内部滚动恢复与外层 reset 冲突闪烁
---
## [0.3.3] - 2026-04-13 ## [0.3.3] - 2026-04-13
### 新增 ✨ ### 新增 ✨
@@ -57,15 +14,15 @@
### 优化 🚀 ### 优化 🚀
- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容 - MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容
- SQL 查询优化器 — 查询缓存、慢查询日志 - SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)
- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持 - Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持
- Office/CSV 预览增强 — 本地文件服务器获取文件 - Office/CSV 预览增强 — 本地文件服务器获取文件
- Markdown 增强 — 本地文件链接支持、Shell 语法高亮 - Markdown 增强 — 本地文件链接支持、Shell 语法高亮
- HTML 预览 — 改用 iframe src 替代 srcdoc - HTML 预览 — 改用 iframe src 替代 srcdoc
- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复 - Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复
- 文件列表 UI 重构 — 统一渲染逻辑,提升滚动性能 - FileListPanel 重写 (+511 行) — 删除 FileItemRow统一列表渲染逻辑
- CSV 编辑模式优化 + PDF 导出重构 - CSV 编辑模式优化 + PDF 导出重构
- 拷贝功能优化 - 拷贝功能优化 — 新增 ClipboardCopy composable
### 修复 🐛 ### 修复 🐛
- Office 文件预览:修复类型检测与二进制误判 - Office 文件预览:修复类型检测与二进制误判
@@ -82,9 +39,10 @@
### 重构 🔧 ### 重构 🔧
- CodeMirror 架构优化 — 统一导出避免多实例问题 - CodeMirror 架构优化 — 统一导出避免多实例问题
- 消除代码重复 — storage/connection_service 重构、useVisibleDatabases 抽取 - 消除代码重复 — storage/connection_service 重构、useVisibleDatabases 抽取
- 大规模死代码清理,显著减小包体积 - **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层(connection_service 279行)、audit_log、file_lock、recycle_bin、zip_helper、useFileEdit.js(-369行)、useFilePreview.js(-603行)、errorHandler.js(-63行)、DeviceTest 清理等
- 配置加载超时保护(最多重试 30 次) - 配置加载超时保护(最多重试 30 次)
- 正则表达式预编译、缓存读锁优化 - 正则表达式预编译query_optimizer
- 缓存读锁优化 + SHA-256 key hash
- 禁止 Ctrl+滚轮缩放 - 禁止 Ctrl+滚轮缩放
- Dockerfile 语法高亮支持 - Dockerfile 语法高亮支持
- 滚动条样式修复 - 滚动条样式修复

View File

@@ -1,22 +1,10 @@
# U-Desk v0.3.4 # U-Desk v0.3.3
## 功能 ## 功能
- **文件管理** — 本地文件浏览、编辑CodeMirror 语法高亮+搜索)、预览(图片/视频/PDF/HTML/Markdown/Excel/Word/CSV - 数据库客户端
- **数据库客户端** — 多数据库连接管理、SQL 执行、查询历史、表结构管理 - Markdown编辑器
- **Markdown 编辑器** — 独立编辑页面、实时预览、PDF 导出 - PDF导出
- **版本更新** — 自动检查更新、下载安装、changelog 渲染
- **系统信息** — CPU/内存/磁盘硬件信息查询
## 技术栈
- **后端**: Go + Wails v2 (桌面应用框架)
- **前端**: Vue 3 + Arco Design + CodeMirror 6 + Pinia
- **存储**: SQLite (GORM)
- **本地文件服务器**: `localhost:8073`CSS/JS 路径转换、HTML 预览)
## 开发
```bash
wails dev
```
## 更新 ## 更新
-文件服务器安全重构+编辑器增强+搜索排序+更新面板渲染 -MD编辑器完成
- ✅ PDF导出优化中

234
app.go
View File

@@ -1,11 +1,9 @@
// [fs-only] 数据库客户端模块已移除feature/fs-only 分支)
// 保留模块:文件系统 | Markdown编辑器 | 版本历史(抽屉) | 系统信息 | 更新检查 | PDF导出
// 顶部Tab仅file-system数据库 db-cli 已删除)
package main package main
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
stdruntime "runtime" stdruntime "runtime"
@@ -26,9 +24,13 @@ 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
fileServer *http.Server
filesystem *filesystem.FileSystemService filesystem *filesystem.FileSystemService
isAlwaysOnTop bool isAlwaysOnTop bool
} }
@@ -136,6 +138,31 @@ 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("[启动] 初始化文件系统模块...")
@@ -167,7 +194,7 @@ func (a *App) startFileServer() {
return return
} }
fmt.Println("[文件服务器] 启动在 http://localhost:8073") fmt.Println("[文件服务器] 启动在 http://localhost:18765")
} }
// Shutdown 应用关闭时调用 // Shutdown 应用关闭时调用
@@ -388,7 +415,7 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置) // Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
folderGUIDs := map[string]string{ folderGUIDs := map[string]string{
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}", "desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}", "documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}",
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}", "downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
} }
for name, guid := range folderGUIDs { for name, guid := range folderGUIDs {
@@ -414,6 +441,94 @@ 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 {
@@ -474,86 +589,82 @@ func (a *App) WindowToggleAlwaysOnTop() bool {
return a.isAlwaysOnTop return a.isAlwaysOnTop
} }
// ========== 版本更新管理接口 ========== // ========== SQL 标签页管理接口 ==========
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误 // SaveSqlTabs 保存 SQL 标签页列表
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) { func (a *App) SaveSqlTabs(tabs []map[string]interface{}) error {
if a.updateAPI == nil { return a.tabAPI.SaveSqlTabs(tabs)
return nil, fmt.Errorf("更新功能正在初始化中")
}
return a.updateAPI, nil
} }
// ListSqlTabs 获取 SQL 标签页列表
func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
return a.tabAPI.ListSqlTabs()
}
// ========== 版本更新管理接口 ==========
// CheckUpdate 检查更新UpdateAPI 可能尚未初始化完成) // CheckUpdate 检查更新UpdateAPI 可能尚未初始化完成)
func (a *App) CheckUpdate() (map[string]interface{}, error) { func (a *App) CheckUpdate() (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.CheckUpdate() return a.updateAPI.CheckUpdate()
} }
// GetCurrentVersion 获取当前版本号 // GetCurrentVersion 获取当前版本号
func (a *App) GetCurrentVersion() (map[string]interface{}, error) { func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.GetCurrentVersion() return a.updateAPI.GetCurrentVersion()
} }
// GetUpdateConfig 获取更新配置 // GetUpdateConfig 获取更新配置
func (a *App) GetUpdateConfig() (map[string]interface{}, error) { func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.GetUpdateConfig() return a.updateAPI.GetUpdateConfig()
} }
// SetUpdateConfig 设置更新配置 // SetUpdateConfig 设置更新配置
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) { func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL) return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
} }
// DownloadUpdate 下载更新包 // DownloadUpdate 下载更新包
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) { func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.DownloadUpdate(downloadURL) return a.updateAPI.DownloadUpdate(downloadURL)
} }
// InstallUpdate 安装更新包 // InstallUpdate 安装更新包
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) { func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.InstallUpdate(installerPath, autoRestart) return a.updateAPI.InstallUpdate(installerPath, autoRestart)
} }
// InstallUpdateWithHash 安装更新包(带哈希验证) // InstallUpdateWithHash 安装更新包(带哈希验证)
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) { func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType) return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
} }
// VerifyUpdateFile 验证更新文件哈希值 // VerifyUpdateFile 验证更新文件哈希值
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) { func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
api, err := a.requireUpdateAPI() if a.updateAPI == nil {
if err != nil { return nil, fmt.Errorf("更新功能正在初始化中")
return nil, err
} }
return api.VerifyUpdateFile(filePath, expectedHash, hashType) return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
} }
// startAutoUpdateCheck 启动自动更新检查 // startAutoUpdateCheck 启动自动更新检查
@@ -642,7 +753,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
// GetFileServerURL 获取本地文件服务器的URL // GetFileServerURL 获取本地文件服务器的URL
func (a *App) GetFileServerURL() string { func (a *App) GetFileServerURL() string {
return "http://localhost:8073" return "http://localhost:18765"
} }
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件) // DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
@@ -737,6 +848,8 @@ 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:
@@ -745,6 +858,37 @@ 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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -1 +1 @@
{"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} {"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": 18396672, "sha256": "f0bdf8954b276f4bb45a69336f171bb2a481f7a7125fc3309aae5de2fbf0cf15", "force_update": false}

View File

@@ -1 +1 @@
{"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": ""}]} {"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": 18396672, "sha256": "f0bdf8954b276f4bb45a69336f171bb2a481f7a7125fc3309aae5de2fbf0cf15"}, {"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.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,44 +0,0 @@
Add-Type -AssemblyName System.Drawing
$srcPath = "E:\wk-lab\u-desk\build\windows\app-icon.png"
$icoPath = "E:\wk-lab\u-desk\build\windows\icon.ico"
$sizes = @(256, 128, 64, 48, 32, 16)
$src = [System.Drawing.Image]::FromFile($srcPath)
$fs = New-Object System.IO.FileStream($icoPath, [System.IO.FileMode]::Create)
$w = New-Object System.IO.BinaryWriter($fs)
$w.Write([uint16]0)
$w.Write([uint16]1)
$w.Write([uint16]$sizes.Count)
foreach ($sz in $sizes) {
$bmp = New-Object System.Drawing.Bitmap($sz, $sz, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.DrawImage($src, 0, 0, $sz, $sz)
$g.Dispose()
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$bytes = $ms.ToArray()
$ms.Dispose()
$bmp.Dispose()
$w.Write([uint32]40)
$w.Write([int32]$sz)
$w.Write([int32]$sz)
$w.Write([uint16]1)
$w.Write([uint32]32)
$w.Write([uint32]$bytes.Length)
$w.Write([uint32]22)
$w.Write($bytes)
}
$w.Close()
$fs.Close()
$src.Dispose()
$item = Get-Item $icoPath
Write-Output "ICO: $($item.Name) ($([math]::Round($item.Length / 1KB)) KB)"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,105 +0,0 @@
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] 已关闭")
}

73
cmd/debug_db/main.go Normal file
View File

@@ -0,0 +1,73 @@
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工具退出")
}

View File

@@ -1,29 +0,0 @@
# 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,19 +6,24 @@ 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/labstack/echo/v4 v4.15.0 github.com/go-sql-driver/mysql v1.9.3
github.com/redis/go-redis/v9 v9.17.3
github.com/shirou/gopsutil/v3 v3.24.5 github.com/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
gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0
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
@@ -32,6 +37,8 @@ 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
@@ -55,12 +62,16 @@ 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,7 +1,15 @@
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=
@@ -10,6 +18,8 @@ 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=
@@ -21,6 +31,8 @@ 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=
@@ -45,6 +57,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.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=
@@ -82,6 +96,8 @@ 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=
@@ -113,43 +129,72 @@ 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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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

@@ -1,108 +0,0 @@
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

@@ -1,176 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -1,64 +0,0 @@
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

@@ -1,113 +0,0 @@
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

@@ -1,61 +0,0 @@
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

@@ -1,41 +0,0 @@
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

@@ -136,66 +136,40 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
}, nil }, nil
} }
// MigrateTabConfig 迁移旧配置device 移除 + openclaw-manager 重命名) // MigrateTabConfig 迁移旧配置
func (api *ConfigAPI) MigrateTabConfig() error { func (api *ConfigAPI) MigrateTabConfig() error {
config, _ := api.configService.GetTabConfig() config, _ := api.configService.GetTabConfig()
if config == nil { if config == nil {
return nil return nil
} }
needMigrate := false // 检查是否包含 device
hasDevice := false
// 检查是否包含需要迁移的旧 key
for _, tab := range config.AvailableTabs { for _, tab := range config.AvailableTabs {
if tab.Key == "device" || tab.Key == "openclaw-manager" { if tab.Key == "device" {
needMigrate = true hasDevice = true
break break
} }
} }
if !needMigrate { if !hasDevice {
return nil return nil
} }
// 映射:旧 key → 新 key不需要的移除 // 过滤掉 device
keyMap := map[string]string{
"openclaw-manager": "version",
// "device": "" // 直接过滤
}
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs)) newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
newVisible := make([]string, 0, len(config.VisibleTabs)) newVisible := make([]string, 0, len(config.VisibleTabs))
seenKeys := map[string]bool{}
for _, tab := range config.AvailableTabs { for _, tab := range config.AvailableTabs {
newKey, shouldRename := keyMap[tab.Key] if tab.Key != "device" {
if shouldRename {
if newKey == "" {
continue // 移除(如 device
}
if seenKeys[newKey] {
continue // 避免重复
}
seenKeys[newKey] = true
newTabs = append(newTabs, service.TabDefinition{Key: newKey, Title: tab.Title, Enabled: tab.Enabled})
} else {
newTabs = append(newTabs, tab) newTabs = append(newTabs, tab)
} }
} }
for _, key := range config.VisibleTabs { for _, key := range config.VisibleTabs {
if newKey, ok := keyMap[key]; ok { if key != "device" {
if newKey != "" && !seenKeys[newKey] {
newVisible = append(newVisible, newKey)
}
// newKey == "" 时跳过(如 device
} else {
newVisible = append(newVisible, key) newVisible = append(newVisible, key)
} }
} }
defaultTab := config.DefaultTab defaultTab := config.DefaultTab
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
defaultTab = newKey
}
if defaultTab == "device" { if defaultTab == "device" {
defaultTab = "file-system" defaultTab = "file-system"
} }

View File

@@ -0,0 +1,128 @@
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,
)
}

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

@@ -0,0 +1,137 @@
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
}

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

@@ -0,0 +1,79 @@
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,6 +2,8 @@ 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
@@ -9,4 +11,4 @@ const (
) )
// DefaultVisibleTabs 默认可见的 Tabs // DefaultVisibleTabs 默认可见的 Tabs
var DefaultVisibleTabs = []string{TabFileSystem, TabDevice} var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}

View File

@@ -0,0 +1,12 @@
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 // 长时间操作超时
)

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

@@ -0,0 +1,175 @@
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)
}

138
internal/database/db.go Normal file
View File

@@ -0,0 +1,138 @@
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
}

479
internal/dbclient/cache.go Normal file
View File

@@ -0,0 +1,479 @@
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
}

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

@@ -0,0 +1,825 @@
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
}

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

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

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

@@ -0,0 +1,393 @@
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

@@ -0,0 +1,679 @@
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

@@ -0,0 +1,762 @@
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() {
}

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

@@ -0,0 +1,241 @@
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

@@ -0,0 +1,151 @@
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

@@ -2,7 +2,6 @@ package filesystem
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -49,48 +48,6 @@ var (
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译) // HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
var attrRegexCache sync.Map // map[string]*regexp.Regexp var attrRegexCache sync.Map // map[string]*regexp.Regexp
// 路径校验 sentinel error用 errors.Is 匹配,不依赖字符串)
var (
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
ErrPathTraversal = fmt.Errorf("path traversal detected")
ErrPathUnsafe = fmt.Errorf("unsafe path")
)
// validateFilePath 校验文件路径安全性URL解码 + 路径遍历检测 + 安全检查)
// 返回清理后的绝对路径,或 sentinel error
func validateFilePath(rawPath string, logPrefix string) (string, error) {
decodedPath, err := url.QueryUnescape(rawPath)
if err != nil {
return "", ErrPathInvalidEncoding
}
if strings.Contains(decodedPath, "..") {
return "", ErrPathTraversal
}
// 去除代理引入的 /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)
// 确保绝对路径Linux 以 / 开头Windows 以盘符开头)
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
filePath = "/" + filePath
}
if !isSafePath(filePath) {
return "", ErrPathUnsafe
}
return filePath, nil
}
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器) // LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
type LocalFileServer struct { type LocalFileServer struct {
server *http.Server server *http.Server
@@ -118,7 +75,7 @@ func StartLocalFileServer() (string, error) {
// 创建服务器(固定端口) // 创建服务器(固定端口)
server := &http.Server{ server := &http.Server{
Addr: "localhost:8073", Addr: "localhost:18765",
Handler: mux, Handler: mux,
} }
@@ -133,7 +90,7 @@ func StartLocalFileServer() (string, error) {
localFileServer = &LocalFileServer{ localFileServer = &LocalFileServer{
server: server, server: server,
addr: "localhost:8073", addr: "localhost:18765",
} }
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr) log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
@@ -166,20 +123,9 @@ 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 := r.URL.Path pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
for strings.HasPrefix(pathPart, "/localfs/") { log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
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] 路径前缀无效")
@@ -187,24 +133,34 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
return return
} }
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查) // 🔒 修复:先进行URL解码,防止路径遍历攻击
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]") decodedPath, err := url.QueryUnescape(pathPart)
if err != nil { if err != nil {
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart) log.Printf("[LocalFileHandler] URL解码失败: %v", err)
switch { http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return return
} }
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
// 🔒 修复:在路径转换前检查是否包含危险字符
if strings.Contains(decodedPath, "..") {
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
http.Error(w, "Path traversal detected", http.StatusForbidden)
return
}
// 路径转换(统一使用反斜杠)
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
filePath = filepath.Clean(filePath)
log.Printf("[LocalFileHandler] 最终路径: %s", filePath) log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
// 安全检查
if !isSafePath(filePath) {
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
http.Error(w, "Unsafe path", http.StatusForbidden)
return
}
// 🔒 文件类型白名单检查 // 🔒 文件类型白名单检查
ext := strings.ToLower(filepath.Ext(filePath)) ext := strings.ToLower(filepath.Ext(filePath))
if !isAllowedFileType(ext) { if !isAllowedFileType(ext) {
@@ -503,30 +459,32 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
} }
// 解析参数 // 解析参数
rawPath := r.URL.Query().Get("path") filePath := r.URL.Query().Get("path")
var err error
if filePath, err = url.QueryUnescape(filePath); err != nil {
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
return
}
theme := r.URL.Query().Get("theme") theme := r.URL.Query().Get("theme")
if theme == "" { if theme == "" {
theme = "light" theme = "light"
} }
// 校验路径安全性URL解码 + 路径遍历检测 + 安全检查) log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
if err != nil { // 安全检查
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath) if !isSafePath(filePath) {
switch { log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath)
case errors.Is(err, ErrPathInvalidEncoding): http.Error(w, "Unsafe path", http.StatusForbidden)
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return return
} }
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme) // 检查路径遍历攻击
if strings.Contains(filePath, "..") {
log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath)
http.Error(w, "Path traversal detected", http.StatusForbidden)
return
}
// 读取文件 // 读取文件
content, err := os.ReadFile(filePath) content, err := os.ReadFile(filePath)

View File

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

View File

@@ -1,19 +0,0 @@
//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

@@ -0,0 +1,26 @@
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,10 +42,11 @@ 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: "openclaw-manager", Title: "OpenClaw", Enabled: true},
}, },
VisibleTabs: []string{"file-system", "markdown-editor", "version"}, VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "openclaw-manager"},
DefaultTab: "file-system", DefaultTab: "file-system",
} }

View File

@@ -0,0 +1,268 @@
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

@@ -0,0 +1,475 @@
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

@@ -0,0 +1,31 @@
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.4.0" const AppVersion = "0.3.3"
// 版本号缓存 // 版本号缓存
var ( var (

View File

@@ -0,0 +1,26 @@
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

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

View File

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

View File

@@ -0,0 +1,70 @@
package repository
import (
"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

@@ -0,0 +1,90 @@
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

@@ -0,0 +1,51 @@
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,6 +62,9 @@ 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.4.0", "version": "0.3.3",
"frontend:install": "npm install", "frontend:install": "npm install",
"frontend:build": "npm run build", "frontend:build": "npm run build",
"author": { "author": {

12
web/package-lock.json generated
View File

@@ -25,7 +25,6 @@
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.3", "@codemirror/state": "^6.5.3",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.8", "@codemirror/view": "^6.39.8",
@@ -415,17 +414,6 @@
"crelt": "^1.0.5" "crelt": "^1.0.5"
} }
}, },
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": { "node_modules/@codemirror/state": {
"version": "6.5.3", "version": "6.5.3",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",

View File

@@ -25,7 +25,6 @@
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.3", "@codemirror/state": "^6.5.3",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.8", "@codemirror/view": "^6.39.8",

View File

@@ -1 +1 @@
c0e9e27e045c6118704c87fcf34a03de 0e1fafcbb6b28922a38f6c5316932015

View File

@@ -57,7 +57,7 @@
<a-layout-content class="content"> <a-layout-content class="content">
<!-- 动态渲染 Tab 内容 --> <!-- 动态渲染 Tab 内容 -->
<!-- 使用 KeepAlive 缓存组件状态避免切换时重新加载 --> <!-- 使用 KeepAlive 缓存组件状态避免切换时重新加载 -->
<KeepAlive include="FileSystem"> <KeepAlive include="FileSystem,DbCli">
<component :is="getComponent(activeTab)"/> <component :is="getComponent(activeTab)"/>
</KeepAlive> </KeepAlive>
</a-layout-content> </a-layout-content>
@@ -94,19 +94,19 @@
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'
import SettingsPanel from './components/SettingsPanel.vue' import SettingsPanel from './components/SettingsPanel.vue'
import UpdateNotification from './components/UpdateNotification.vue' import UpdateNotification from './components/UpdateNotification.vue'
import {useUpdateStore} from './stores/update' import {useUpdateStore} from './stores/update'
import {useConfigStore, type AppConfig} from './stores/config' import {useConfigStore} from './stores/config'
// 存储键 // 存储键
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab' const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
// 从 localStorage 恢复上次打开的区域,默认为 'file-system' // 从 localStorage 恢复上次打开的区域,默认为 'file-system'
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key已废弃需迁移
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system') const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
const showSettings = ref(false) const showSettings = ref(false)
@@ -125,7 +125,7 @@ const appConfig = computed(() => configStore.appConfig)
const visibleTabs = computed(() => configStore.visibleTabs) const visibleTabs = computed(() => configStore.visibleTabs)
// 保存配置 // 保存配置
const handleSaveConfig = async (config: AppConfig) => { const handleSaveConfig = async (config) => {
try { try {
await configStore.saveConfig(config) await configStore.saveConfig(config)
showSettings.value = false showSettings.value = false
@@ -148,9 +148,10 @@ const loadConfig = async () => {
} }
// 获取组件 // 获取组件
const getComponent = (key: string) => { const getComponent = (key) => {
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
@@ -375,9 +376,4 @@ watch(activeTab, (newTab) => {
.arco-tooltip { .arco-tooltip {
--wails-draggable: no-drag; --wails-draggable: no-drag;
} }
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
html, body {
overflow: hidden !important;
}
</style> </style>

View File

@@ -1,199 +0,0 @@
/**
* 连接管理器 — 管理本地/远程传输层切换
*/
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()

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

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

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

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

View File

@@ -1,136 +0,0 @@
/**
* 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,4 +3,9 @@
*/ */
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'

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

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

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

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

View File

@@ -1,110 +1,306 @@
/** /**
* 系统信息相关 API — 委托给 Transport 层 * 系统信息相关 API
* 本地模式走 Wails IPC远程模式走 HTTP REST API
*/ */
import type { File } from './types' import type { SystemInfo, CPU, Memory, Disk, File } from './types'
import { connectionManager } from './connection-manager' import { debugError } from '@/utils/debugLog'
/** /**
* 转换后端文件数据格式(蛇形 → 驼峰) * 转换后端文件数据格式(蛇形 → 驼峰)
* 后端返回 is_dir前端使用 isDir
*/ */
function transformFile(file: any): File { function transformFile(file: any): File {
return { ...file, isDir: file.is_dir, modified_time: file.mod_time } return {
...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> {
export async function getCPUInfo() { if (!window.go?.main?.App?.GetSystemInfo) {
if (connectionManager.isRemote()) return {} throw new Error('GetSystemInfo API 不可用')
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} } }
return await window.go.main.App.GetSystemInfo()
} }
export async function getMemoryInfo() { /**
if (connectionManager.isRemote()) return {} * 获取 CPU 信息
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[]> {
return transformFileList(await t().listDir(path)) if (!window.go?.main?.App?.ListDir) {
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> {
return t().readFile(path) if (!window.go?.main?.App?.ReadFile) {
throw new Error('ReadFile API 不可用')
}
return await window.go.main.App.ReadFile(path)
} }
/**
* 写入文件
*/
export async function writeFile(path: string, content: string): Promise<void> { export async function writeFile(path: string, content: string): Promise<void> {
await t().writeFile(path, String(content)) if (!window.go?.main?.App?.WriteFile) {
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 (!base64Content) throw new Error('无效的 base64 内容') if (!window.go?.main?.App?.SaveBase64File) {
await t().saveBase64File(path, base64Content) throw new Error('SaveBase64File API 不可用')
}
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> {
return t().deletePath(path) if (!window.go?.main?.App?.DeletePath) {
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> {
return t().createDir(parentPath, dirname) if (!window.go?.main?.App?.CreateDir) {
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> {
return t().createFile(dirPath, filename) if (!window.go?.main?.App?.CreateFile) {
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> {
return t().renamePath(oldPath, String(newPath)) if (!window.go?.main?.App?.RenamePath) {
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>> {
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} } if (!window.go?.main?.App?.GetEnvVars) {
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[]> {
return transformFileList(await t().listZipContents(zipPath)) if (!window.go?.main?.App?.ListZipContents) {
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> {
return t().extractFileFromZip(zipPath, filePath) if (!window.go?.main?.App?.ExtractFileFromZip) {
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> {
return t().extractFileFromZipToTemp(zipPath, filePath) if (!window.go?.main?.App?.ExtractFileFromZipToTemp) {
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> {
return transformFile(await t().getZipFileInfo(zipPath, filePath)) if (!window.go?.main?.App?.GetZipFileInfo) {
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> {
await t().openPath(path) if (!window.go?.main?.App?.OpenPath) {
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> {
return t().getFileServerURL() if (!window.go?.main?.App?.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) { /**
return t().detectFileTypeByContent(path) * 通过文件内容检测文件类型用于小文件500KB以内
} */
export async function detectFileTypeByContent(path: string): Promise<{
export async function getCommonPaths() { extension: string
return t().getCommonPaths() category: string // 'image' | 'text' | 'binary' | 'pdf' | 'archive' | 'unknown'
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
}
} }

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

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

View File

@@ -1,71 +0,0 @@
/**
* 文件系统传输层接口
* 本地模式走 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,6 +2,75 @@
* 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

@@ -1,139 +0,0 @@
/**
* 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

@@ -3,25 +3,34 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue' import { ref, onMounted, watch, onBeforeUnmount, computed, nextTick } from 'vue'
import { import {
EditorView, lineNumbers, highlightActiveLineGutter, keymap, EditorView, lineNumbers, highlightActiveLineGutter, keymap,
EditorState, Compartment, EditorState, Compartment,
defaultKeymap, history, defaultKeymap, history,
bracketMatching, defaultHighlightStyle, syntaxHighlighting, bracketMatching, defaultHighlightStyle, syntaxHighlighting,
oneDark, oneDark
openSearchPanel, search
} from '@/utils/codemirrorExports' } from '@/utils/codemirrorExports'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader' import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
// ==================== 主题定义 ====================
// 亮色主题的基础样式
const lightTheme = EditorView.theme({
'&': { backgroundColor: '#ffffff' },
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
'.cm-line': { caretColor: '#000' },
'.cm-selection': { backgroundColor: '#d9d9d9' },
'.cm-cursor': { borderLeftColor: '#000' }
})
// ==================== Props & Emits ==================== // ==================== Props & Emits ====================
const props = defineProps({ const props = defineProps({
modelValue: { type: String, required: true }, modelValue: { type: String, required: true },
fileExtension: { type: String, default: '' }, fileExtension: { type: String, default: '' }
filePath: { type: String, default: '' },
fileMtime: { type: String, default: '' }
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -32,36 +41,6 @@ const themeStore = useThemeStore()
const editorContainer = ref(null) const editorContainer = ref(null)
let view = null let view = null
// 滚动位置缓存LRU 最多 5 份,每份 3 分钟过期
const MAX_SCROLL_CACHE = 5
const SCROLL_CACHE_TTL = 3 * 60 * 1000 // 3 分钟
const fileScrollPositions = new Map() // filePath → { scrollTop, anchor, timestamp }
let currentFilePath = ''
let saveScrollTimer = null
// 清理过期缓存 + LRU 淘汰,保持最多 MAX_SCROLL_CACHE 条
const cleanScrollCache = () => {
const now = Date.now()
// 清理过期的
for (const [key, val] of fileScrollPositions) {
if (now - val.timestamp > SCROLL_CACHE_TTL) {
fileScrollPositions.delete(key)
}
}
// LRU超出上限时删除最旧的
if (fileScrollPositions.size > MAX_SCROLL_CACHE) {
let oldestKey = null
let oldestTime = Infinity
for (const [key, val] of fileScrollPositions) {
if (val.timestamp < oldestTime) {
oldestTime = val.timestamp
oldestKey = key
}
}
if (oldestKey) fileScrollPositions.delete(oldestKey)
}
}
// 使用 Compartment 实现动态切换,避免重建编辑器 // 使用 Compartment 实现动态切换,避免重建编辑器
const themeCompartment = new Compartment() const themeCompartment = new Compartment()
const languageCompartment = new Compartment() const languageCompartment = new Compartment()
@@ -108,9 +87,6 @@ const createExtensions = () => {
keymap.of(defaultKeymap), keymap.of(defaultKeymap),
bracketMatching(), bracketMatching(),
// 查找替换Ctrl+F / Ctrl+H
search(),
// 内容更新监听(带防抖) // 内容更新监听(带防抖)
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {
@@ -122,7 +98,7 @@ const createExtensions = () => {
EditorView.theme({ EditorView.theme({
'&': { height: '100%', fontSize: '13px' }, '&': { height: '100%', fontSize: '13px' },
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' }, '.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
'.cm-content': { padding: '8px' }, '.cm-content': { padding: '8px', minHeight: '100%' },
'.cm-line': { padding: '0 0' }, '.cm-line': { padding: '0 0' },
'&.cm-focused': { outline: 'none' } '&.cm-focused': { outline: 'none' }
}), }),
@@ -167,12 +143,6 @@ const createEditor = (docContent = '') => {
view = new EditorView({ state, parent: editorContainer.value }) view = new EditorView({ state, parent: editorContainer.value })
// 滚动时防抖保存位置
view.scrollDOM.addEventListener('scroll', () => {
if (saveScrollTimer) clearTimeout(saveScrollTimer)
saveScrollTimer = setTimeout(saveScrollPosition, 200)
}, { passive: true })
// 初始化语言 // 初始化语言
initLanguage() initLanguage()
} }
@@ -193,10 +163,8 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (emitTimeout) clearTimeout(emitTimeout) if (emitTimeout) {
if (saveScrollTimer) clearTimeout(saveScrollTimer) clearTimeout(emitTimeout)
if (view?.scrollDOM) {
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
} }
view?.destroy() view?.destroy()
view = null view = null
@@ -204,64 +172,12 @@ onBeforeUnmount(() => {
// ==================== 监听器 ==================== // ==================== 监听器 ====================
// 保存当前文件滚动位置(防抖) // 监听外部内容变化
const saveScrollPosition = () => { watch(() => props.modelValue, (newValue) => {
if (!view || !currentFilePath) return if (view && newValue !== view.state.doc.toString()) {
const scroller = view.scrollDOM
if (!scroller) return
fileScrollPositions.set(currentFilePath, {
scrollTop: scroller.scrollTop,
anchor: view.state.selection.main.anchor,
timestamp: Date.now()
})
cleanScrollCache()
}
// 监听外部内容变化(切换文件/文件变更时触发)
watch([() => props.modelValue, () => props.fileMtime], ([newValue, newMtime], [oldValue, oldMtime]) => {
// 文件修改时间变了 → 说明磁盘内容有变更 → 强制刷新
const mtimeChanged = newMtime && oldMtime && newMtime !== oldMtime
if (view && (mtimeChanged || newValue !== view.state.doc.toString())) {
// 先保存旧文件的滚动位置
saveScrollPosition()
const newPath = props.filePath || ''
const isSameFile = currentFilePath && currentFilePath === newPath
view.dispatch({ view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }, changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }
selection: { anchor: 0 }
}) })
currentFilePath = newPath
if (isSameFile && fileScrollPositions.has(newPath)) {
// 同一文件 → 检查是否过期,未过期则恢复位置
const saved = fileScrollPositions.get(newPath)
if (saved && Date.now() - saved.timestamp <= SCROLL_CACHE_TTL) {
nextTick(() => {
if (view) {
view.dispatch({
selection: { anchor: saved.anchor },
effects: EditorView.scrollIntoView(saved.anchor)
})
view.scrollDOM.scrollTop = saved.scrollTop
}
})
} else {
// 过期了 → 强制滚动到顶部
nextTick(() => {
if (view) view.scrollDOM.scrollTop = 0
})
}
} else {
// 不同文件 → 强制滚动到顶部scrollIntoView 不一定重置 DOM scrollTop
nextTick(() => {
if (view) {
view.scrollDOM.scrollTop = 0
}
})
}
} }
}) })
@@ -298,6 +214,6 @@ watch(() => props.fileExtension, () => {
} }
.codemirror-editor :deep(.cm-content) { .codemirror-editor :deep(.cm-content) {
/* 不设 height让 CodeMirror 虚拟滚动自行计算文档高度 */ height: 100%;
} }
</style> </style>

View File

@@ -1,77 +0,0 @@
<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

@@ -1,270 +0,0 @@
<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,7 +1,5 @@
<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>
@@ -74,8 +72,7 @@
<!-- 视频预览 --> <!-- 视频预览 -->
<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" @error="handleMediaError('视频')"></video> <video :src="config.previewUrl" controls class="preview-video"></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>
@@ -83,8 +80,7 @@
<!-- 音频预览 --> <!-- 音频预览 -->
<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" @error="handleMediaError('音频')"></audio> <audio :src="config.previewUrl" controls class="preview-audio"></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>
@@ -92,8 +88,7 @@
<!-- 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" @load="handlePdfLoad"></iframe> <iframe :src="config.previewUrl" class="preview-pdf"></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>
@@ -160,8 +155,6 @@
<AsyncCodeEditor <AsyncCodeEditor
:model-value="config.fileContent" :model-value="config.fileContent"
:file-extension="config.currentFileExtension" :file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate" @update:model-value="handleContentUpdate"
class="code-editor" class="code-editor"
/> />
@@ -225,8 +218,6 @@
<AsyncCodeEditor <AsyncCodeEditor
:model-value="config.fileContent" :model-value="config.fileContent"
:file-extension="config.currentFileExtension" :file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate" @update:model-value="handleContentUpdate"
class="code-editor" class="code-editor"
/> />
@@ -293,8 +284,6 @@
<AsyncCodeEditor <AsyncCodeEditor
:model-value="config.fileContent" :model-value="config.fileContent"
:file-extension="config.currentFileExtension" :file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate" @update:model-value="handleContentUpdate"
class="code-editor" class="code-editor"
/> />
@@ -348,8 +337,6 @@
<AsyncCodeEditor <AsyncCodeEditor
:model-value="config.fileContent" :model-value="config.fileContent"
:file-extension="config.currentFileExtension" :file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate" @update:model-value="handleContentUpdate"
class="code-editor" class="code-editor"
/> />
@@ -359,7 +346,6 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
@@ -373,7 +359,6 @@ 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({
@@ -439,27 +424,13 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// HTML 预览 URL实时从 connectionManager 读取,不缓存 // HTML 预览 URL使用后端接口
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) return '' if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
const encodedPath = encodeURIComponent(props.config.currentFileFullPath) return ''
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}`
} }
// 本地模式:直连文件服务器 const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
return `${base}/localfs/html-preview?path=${encodedPath}` return `http://localhost:18765/localfs/html-preview?path=${encodedPath}`
}) })
// 计算属性:判断文件是否在当前目录 // 计算属性:判断文件是否在当前目录
@@ -519,30 +490,6 @@ 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')
@@ -695,7 +642,7 @@ const loadExcelPreview = async (filePath: string) => {
// 直接从本地文件服务器获取(不走 base64 // 直接从本地文件服务器获取(不走 base64
const fileUrl = props.config.previewUrl const fileUrl = props.config.previewUrl
const response = await authFetch(fileUrl) const response = await fetch(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' })
@@ -724,7 +671,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 authFetch(fileUrl) const response = await fetch(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' })
@@ -754,7 +701,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 authFetch(props.config.previewUrl)).blob() : await (await fetch(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)
@@ -828,11 +775,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
// 处理 HTML iframe 发送的消息(链接点击) // 处理 HTML iframe 发送的消息(链接点击)
const handleHtmlIframeMessage = (event: MessageEvent) => { const handleHtmlIframeMessage = (event: MessageEvent) => {
// 安全检查:接受来自本地文件服务器或同源的消息 // 安全检查:接受来自本地文件服务器或同源的消息
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073 // Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
const allowedOrigins = [ const allowedOrigins = [
window.location.origin, window.location.origin,
'null', 'null', // about:blank 或 data: URL
resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址 'http://localhost:18765', // 本地文件服务器
] ]
if (!allowedOrigins.includes(event.origin)) { if (!allowedOrigins.includes(event.origin)) {
return return
@@ -880,9 +827,11 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 3px 12px; padding: 8px 12px;
background: var(--color-bg-2); background: var(--color-fill-1);
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;
} }
@@ -987,13 +936,6 @@ 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,8 @@
<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">
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }"> <span class="panel-count">{{ config.fileList.length }} </span>
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '150px' }">
<a-button size="mini" type="text" class="settings-btn"> <a-button size="mini" type="text" class="settings-btn">
<icon-more /> <icon-more />
</a-button> </a-button>
@@ -30,18 +31,6 @@
:disabled="col.key === 'name' && visibleCount <= 1" :disabled="col.key === 'name' && visibleCount <= 1"
@change="(val: boolean) => toggleColumn(col.key, val)" @change="(val: boolean) => toggleColumn(col.key, val)"
>{{ col.label }}</a-checkbox> >{{ col.label }}</a-checkbox>
<!-- 可排序列点击图标排序 -->
<span
v-if="colSortMap[col.key]"
class="col-sort-icon"
:class="{ 'col-sort-active': sortBy === colSortMap[col.key] }"
:title="sortBy === colSortMap[col.key] ? (sortOrder === 'asc' ? '升序 → 点击降序' : '降序 → 点击升序') : `按${col.label}排序`"
@click.stop="emit('sort', colSortMap[col.key])"
>
<IconSort v-if="sortBy !== colSortMap[col.key]" />
<IconSortAscending v-else-if="sortOrder === 'asc'" />
<IconSortDescending v-else />
</span>
</div> </div>
</template> </template>
</a-popover> </a-popover>
@@ -49,20 +38,21 @@
</div> </div>
<div <div
class="file-list-wrapper thin-dark-scrollbar" class="file-list-wrapper"
@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="pagedFileList" :data="config.fileList"
: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"
@@ -74,27 +64,13 @@
<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, watch } from 'vue' import { h, computed, nextTick, ref } 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, IconLeft, IconRight } from '@arco-design/web-vue/es/icon' import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } 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'
@@ -125,14 +101,6 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// 列 key → 排序字段映射
const colSortMap: Record<string, string> = {
icon: 'type',
name: 'name',
time: 'modified_time',
size: 'size'
}
// ========== 列配置(支持显隐 + 排序) ========== // ========== 列配置(支持显隐 + 排序) ==========
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
@@ -171,8 +139,7 @@ function loadColSettings(): ColumnConfig[] {
} }
const colSettings = ref<ColumnConfig[]>(loadColSettings()) const colSettings = ref<ColumnConfig[]>(loadColSettings())
// 默认隐藏表头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() {
@@ -344,26 +311,6 @@ 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
@@ -404,13 +351,12 @@ defineExpose({ focusEditingItem })
.file-list-panel { .file-list-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */ height: 100%;
min-height: 0; /* 允许收缩到小于内容高度 */
background: var(--color-bg-1); background: var(--color-bg-1);
} }
.panel-header { .panel-header {
padding: 3px 12px; padding: 6px 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;
@@ -436,39 +382,15 @@ defineExpose({ focusEditingItem })
color: var(--color-text-2); color: var(--color-text-2);
} }
/* 列项排序图标 */ /* 滚动容器 */
.col-sort-icon {
margin-left: auto;
font-size: 12px;
color: var(--color-text-4);
cursor: pointer;
padding: 2px;
border-radius: 3px;
flex-shrink: 0;
transition: all 0.15s;
}
.col-sort-icon:hover {
background: var(--color-fill-2);
color: var(--color-text-2);
}
.col-sort-active {
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;
@@ -602,35 +524,4 @@ 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 || segments.length === 1 }" :class="{ 'is-hoverable': index < segments.length - 1 }"
@mouseenter="onHover(segment, index)" @mouseenter="onHover(segment, index)"
@mouseleave="onLeave" @mouseleave="onLeave"
@click="onClick(segment)" @click="onClick(segment)"
@@ -152,8 +152,7 @@ const resetAndClose = () => {
} }
const onHover = (segment: PathSegment, index: number) => { const onHover = (segment: PathSegment, index: number) => {
// 根目录(如 C:)只有一段,也允许悬停弹出子目录 if (index === segments.value.length - 1) return
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)
@@ -210,7 +209,7 @@ watch(() => props.path, () => {
.breadcrumb-items { .breadcrumb-items {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -243,7 +242,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 1px; margin: 0 2px;
} }
/* 弹出菜单 */ /* 弹出菜单 */

View File

@@ -1,83 +1,63 @@
<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">
<div class="sidebar-section"> <span class="sidebar-title"> 收藏夹</span>
<div class="section-header" @click="favCollapsed = !favCollapsed"> <span class="sidebar-count">{{ config.favoriteFiles.length }}</span>
<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 class="section-content" :class="{ collapsed: favCollapsed }">
<div
v-for="(fav, index) in config.favoriteFiles"
:key="fav.path"
class="sidebar-item"
:class="{
'sidebar-item-pinned': fav.pinnedAt,
'sidebar-item-pinned-first': index === firstPinnedIndex,
'sidebar-item-pinned-last': index === lastPinnedIndex,
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
}"
:draggable="config.draggingState.pressedIndex === index || config.draggingState.isDragging"
@click="handleOpenFavorite(fav)"
@mousedown="handleLongPressStart($event, index)"
@mouseup="handleLongPressCancel"
@mouseleave="handleLongPressCancel"
@touchstart="handleLongPressStart($event, index)"
@touchend="handleLongPressCancel"
@touchcancel="handleLongPressCancel"
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver($event)"
@drop="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<span class="sidebar-item-icon">{{ getFileIcon(fav) }}</span>
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
<a-button
type="text"
size="mini"
@click.stop="handleTogglePin(fav)"
class="sidebar-item-pin"
:class="{ 'is-pinned': fav.pinnedAt }"
>
<template #icon>
<icon-pushpin :style="{ opacity: fav.pinnedAt ? 1 : 0.4 }" />
</template>
</a-button>
<a-button
type="text"
size="mini"
@click.stop="handleRemoveFavorite(fav)"
class="sidebar-item-remove"
>
<template #icon>
<icon-close />
</template>
</a-button>
</div>
<div v-if="config.favoriteFiles.length === 0" class="sidebar-empty">
<icon-star />
<span>暂无收藏</span>
<span class="sidebar-hint">点击文件列表中的星标收藏</span>
</div>
</div>
</div> </div>
<div class="sidebar-content">
<!-- 帮助文档区块 --> <div
<div class="sidebar-section"> v-for="(fav, index) in config.favoriteFiles"
<div class="section-header" @click="helpCollapsed = !helpCollapsed"> :key="fav.path"
<span class="section-title">📖 帮助</span> class="sidebar-item"
<icon-down v-if="!helpCollapsed" class="section-toggle" /> :class="{
<icon-right v-else class="section-toggle" /> 'sidebar-item-pinned': fav.pinnedAt,
'sidebar-item-pinned-first': index === firstPinnedIndex,
'sidebar-item-pinned-last': index === lastPinnedIndex,
'sidebar-item-dragging': 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"
@click="handleOpenFavorite(fav)"
@mousedown="handleLongPressStart($event, index)"
@mouseup="handleLongPressCancel"
@mouseleave="handleLongPressCancel"
@touchstart="handleLongPressStart($event, index)"
@touchend="handleLongPressCancel"
@touchcancel="handleLongPressCancel"
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver($event)"
@drop="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<span class="sidebar-item-icon">{{ getFileIcon(fav) }}</span>
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
<a-button
type="text"
size="mini"
@click.stop="handleTogglePin(fav)"
class="sidebar-item-pin"
:class="{ 'is-pinned': fav.pinnedAt }"
>
<template #icon>
<icon-pushpin :style="{ opacity: fav.pinnedAt ? 1 : 0.4 }" />
</template>
</a-button>
<a-button
type="text"
size="mini"
@click.stop="handleRemoveFavorite(fav)"
class="sidebar-item-remove"
>
<template #icon>
<icon-close />
</template>
</a-button>
</div> </div>
<div class="section-content help-content" :class="{ collapsed: helpCollapsed }"> <div v-if="config.favoriteFiles.length === 0" class="sidebar-empty">
<div class="help-item" v-for="item in helpItems" :key="item.key"> <icon-star />
<span class="help-key">{{ item.key }}</span> <span>暂无收藏</span>
<span class="help-desc">{{ item.desc }}</span> <span class="sidebar-hint">点击文件列表中的星标收藏</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -85,7 +65,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { computed } from 'vue'
import type { SidebarConfig, FavoriteFile } from '@/types/file-system' import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
// Props // Props
@@ -95,10 +75,6 @@ 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
@@ -109,15 +85,6 @@ 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
@@ -134,7 +101,7 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// 图标导入 // 图标导入
import { IconStar, IconClose, IconPushpin, IconDown, IconRight } from '@arco-design/web-vue/es/icon' import { IconStar, IconClose, IconPushpin } from '@arco-design/web-vue/es/icon'
import { getFileIcon } from '@/utils/fileUtils' import { getFileIcon } from '@/utils/fileUtils'
// 事件处理 // 事件处理
@@ -184,100 +151,37 @@ const handleDragEnd = () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto;
} }
/* 区块 */ .sidebar-header {
.sidebar-section { padding: 12px 16px;
display: flex; border-bottom: 1px solid var(--color-border);
flex-direction: column;
}
/* 区块头部 - 可点击折叠 */
.section-header {
padding: 5px 12px;
background: var(--color-bg-2);
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 8px; background: var(--color-bg-2);
cursor: pointer;
user-select: none;
flex-shrink: 0;
} }
.section-header:hover { .sidebar-title {
background: var(--color-fill-1); font-size: 14px;
} font-weight: 600;
.section-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1); color: var(--color-text-1);
} }
.section-count { .sidebar-count {
font-size: 12px; font-size: 12px;
color: var(--color-text-3); color: var(--color-text-3);
}
.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;
min-height: 0;
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); background: var(--color-fill-2);
padding: 1px 6px; padding: 2px 8px;
border-radius: 3px; border-radius: 10px;
color: var(--color-text-2);
white-space: nowrap;
min-width: 56px;
text-align: center;
} }
.help-desc { .sidebar-content {
color: var(--color-text-3); flex: 1;
overflow-y: auto;
padding: 8px;
} }
/* 收藏项 */
.sidebar-item { .sidebar-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -353,7 +257,6 @@ const handleDragEnd = () => {
opacity: 1; opacity: 1;
} }
/* 空状态 */
.sidebar-empty { .sidebar-empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -380,7 +283,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-sep" /> <icon-right class="breadcrumb-separator" />
<a-tag <a-tag
v-for="(crumb, index) in config.zipBreadcrumbs" v-for="(crumb, index) in config.zipBreadcrumbs"
:key="index" :key="index"
@@ -25,65 +25,69 @@
退出 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 <PathBreadcrumb
:path="config.filePath" :path="config.filePath"
@navigate="handleGoToPath" @navigate="handleGoToPath"
@openFile="handleOpenFile" @openFile="handleOpenFile"
/> />
<!-- 右侧操作快捷路径 + 复制 --> <a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
<div class="breadcrumb-right-actions"> <a-button
<a-tooltip content="快捷路径" position="bottom"> size="mini"
<a-dropdown> type="text"
<a-button size="mini" type="text" class="shortcut-btn"> :status="copied ? 'success' : 'normal'"
<template #icon><icon-forward /></template> class="toolbar-copy-btn"
</a-button> @click="handleCopyPath"
<template #content> >
<a-doption <icon-copy v-if="!copied" />
v-for="shortcut in config.commonPaths" <icon-check v-else />
:key="shortcut.path" </a-button>
@click="handleGoToPath(shortcut.path)" </a-tooltip>
>
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
{{ (shortcut.name || '').substring(2) }}
</a-doption>
</template>
</a-dropdown>
</a-tooltip>
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
<a-button
size="mini"
type="text"
:status="copied ? 'success' : 'normal'"
class="toolbar-copy-btn"
@click="handleCopyPath"
>
<icon-copy v-if="!copied" />
<icon-check v-else />
</a-button>
</a-tooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<!-- 搜索框 --> <!-- 快捷路径下拉 -->
<a-input-search <a-dropdown v-if="!config.isBrowsingZip">
:model-value="config.searchKeyword" <a-button size="small">
placeholder="搜索文件..." <template #icon>
size="small" <icon-forward />
class="toolbar-search" </template>
allow-clear 快捷访问
@search="handleSearch" </a-button>
@update:model-value="handleSearch" <template #content>
@keyup.escape="handleClearSearch" <a-doption
/> v-for="shortcut in config.commonPaths"
:key="shortcut.path"
@click="handleGoToPath(shortcut.path)"
>
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
{{ (shortcut.name || '').substring(2) }}
</a-doption>
</template>
</a-dropdown>
<!-- 历史记录下拉 -->
<a-dropdown>
<a-button size="small">
<template #icon>
<icon-history />
</template>
历史
</a-button>
<template #content>
<a-doption
v-for="path in config.pathHistory.slice(0, 10)"
:key="path"
@click="handleGoToPath(path)"
>
{{ path }}
</a-doption>
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
</template>
</a-dropdown>
<!-- 刷新按钮 --> <!-- 刷新按钮 -->
<a-button <a-button
@@ -97,29 +101,6 @@
刷新 刷新
</a-button> </a-button>
<!-- 历史记录下拉仅图标Ctrl+H -->
<a-dropdown
v-model:popup-visible="historyPopupVisible"
>
<a-tooltip content="历史记录 (Ctrl+H)" position="left">
<a-button size="small">
<template #icon><icon-history /></template>
</a-button>
</a-tooltip>
<template #content>
<div class="history-dropdown-content">
<a-doption
v-for="path in config.pathHistory.slice(0, 10)"
:key="path"
@click="handleGoToPath(path)"
>
<span class="history-path-text">{{ path }}</span>
</a-doption>
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
</div>
</template>
</a-dropdown>
<!-- 切换侧边栏 --> <!-- 切换侧边栏 -->
<a-button <a-button
size="small" size="small"
@@ -132,18 +113,13 @@
</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 {
@@ -156,33 +132,15 @@ const props = defineProps<Props>()
interface Emits { interface Emits {
(e: 'update:filePath', path: string): void (e: 'update:filePath', path: string): void
(e: 'update:showSidebar', show: boolean): void (e: 'update:showSidebar', show: boolean): void
(e: 'update:searchKeyword', keyword: string): void
(e: 'refresh'): void (e: 'refresh'): void
(e: 'exitZip'): void (e: 'exitZip'): void
(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 调用)
const historyPopupVisible = ref(false)
// 事件处理 // 事件处理
const handleGoToPath = (path: string) => { const handleGoToPath = (path: string) => {
emit('goToPath', path) emit('goToPath', path)
@@ -204,28 +162,16 @@ 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)
} }
const handleSearch = (keyword: string) => {
emit('update:searchKeyword', keyword)
}
const handleClearSearch = () => {
emit('update:searchKeyword', '')
}
// 切换历史记录下拉面板(供父组件 Ctrl+H 调用)
const toggleHistoryDropdown = () => {
historyPopupVisible.value = !historyPopupVisible.value
}
const { copied, copy: copyPath } = useClipboardCopy() const { copied, copy: copyPath } = useClipboardCopy()
// 暴露方法给父组件
defineExpose({ toggleHistoryDropdown })
const handleCopyPath = async () => { const handleCopyPath = async () => {
await copyPath(props.config.filePath) await copyPath(props.config.filePath)
} }
@@ -256,59 +202,32 @@ 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 {
width: 180px;
flex-shrink: 0;
}
.path-input-wrapper { .path-input-wrapper {
flex: 1; flex: 1;
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: 4px; gap: 8px;
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;
} }
@@ -334,6 +253,12 @@ 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;
@@ -347,18 +272,12 @@ const handleCopyPath = async () => {
border-color: rgb(var(--primary-6)); border-color: rgb(var(--primary-6));
} }
/* 历史记录下拉 */ .zip-path-text {
.history-dropdown-content { font-family: 'Consolas', 'Monaco', monospace;
max-width: 420px; font-size: 13px;
max-height: 300px; color: var(--color-text-2);
overflow-y: auto; white-space: nowrap;
}
.history-path-text {
display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
max-width: 380px;
} }
</style> </style>

View File

@@ -5,56 +5,90 @@
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 {
const paths = await getCommonPaths() // 检查 Wails API 是否可用
if (!paths) throw new Error('无法获取系统路径') if (!window.go?.main?.App?.GetCommonPaths) {
// 降级方案:使用默认路径
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 (isWin) { if (platform.includes('Win')) {
// 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_')) {
drives.push({ letter: key.substring(5), path: paths[key] }) const driveLetter = key.substring(5)
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 {
// Linux 远程模式 // macOS/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 })
if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }) 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 : [
connectionManager.isRemote() { name: '💿 C盘', path: 'C:\\' },
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }] { name: '💿 D盘', path: 'D:\\' }
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }] ]
)
} catch (error) { } catch (error) {
console.error('加载系统路径失败:', error) console.error('加载系统路径失败:', error)
commonPaths.value = connectionManager.isRemote() // 降级方案
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }] commonPaths.value = [
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }] { name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
} }
} }
return { commonPaths, systemPaths, loadCommonPaths } return {
commonPaths,
systemPaths,
loadCommonPaths
}
} }

View File

@@ -21,12 +21,18 @@ export function useFavorites() {
}) })
/** /**
* 排序收藏列表:置顶项归到前面,组内保持原有顺序(尊重拖拽) * 排序收藏列表:置顶项在前(按 pinnedAt 降序),非置顶项按添加时间降序
*/ */
const sortFavorites = () => { const sortFavorites = () => {
const pinned = favorites.value.filter(f => f.pinnedAt) favorites.value = [...favorites.value].sort((a, b) => {
const unpinned = favorites.value.filter(f => !f.pinnedAt) // 置顶项优先
favorites.value = [...pinned, ...unpinned] if (a.pinnedAt && !b.pinnedAt) return -1
if (!a.pinnedAt && b.pinnedAt) return 1
// 都是置顶项,按置顶时间降序
if (a.pinnedAt && b.pinnedAt) return b.pinnedAt - a.pinnedAt
// 都不是置顶项,按添加时间降序(最新在前)
return b.addedAt - a.addedAt
})
} }
/** /**
@@ -44,7 +50,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) {
@@ -165,23 +171,15 @@ 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
@@ -193,6 +191,7 @@ 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

@@ -13,11 +13,10 @@ import {
isTextEditable, isConfigFile isTextEditable, isConfigFile
} from '@/utils/fileTypeHelpers' } from '@/utils/fileTypeHelpers'
import { useFileOperations } from './useFileOperations' import { useFileOperations } from './useFileOperations'
import type { FileItem } from '@/types/file-system'
export interface UseFileEditOptions { export interface UseFileEditOptions {
currentFilePath?: import('vue').Ref<FileItem | null> currentFilePath?: any
currentDirectory?: import('vue').Ref<string> currentDirectory?: any
} }
// 文件大小限制5MB // 文件大小限制5MB
@@ -47,6 +46,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 文件版本跟踪(用于防止切换文件后的过期更新) // 文件版本跟踪(用于防止切换文件后的过期更新)
const fileVersion = ref(0) const fileVersion = ref(0)
// 最后一次文件加载的时间戳,用于过滤过期更新
const lastLoadTime = ref(0)
// 使用文件操作 composable // 使用文件操作 composable
const { readFile, writeFile } = useFileOperations({ const { readFile, writeFile } = useFileOperations({
onSuccess: (operation, data) => { onSuccess: (operation, data) => {
@@ -79,7 +81,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
* 判断是否为二进制文件(基于扩展名) * 判断是否为二进制文件(基于扩展名)
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测 * 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
*/ */
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => { const isBinaryFileByExt = (filepath: any): boolean | null => {
const path = getFilePath(filepath) const path = getFilePath(filepath)
const ext = getExt(path) const ext = getExt(path)
if (!ext) return null // 无扩展名返回 null表示需要进一步检测 if (!ext) return null // 无扩展名返回 null表示需要进一步检测
@@ -183,6 +185,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 增加文件版本号,使之前的过期更新失效 // 增加文件版本号,使之前的过期更新失效
fileVersion.value++ fileVersion.value++
// 记录加载时间戳,用于过滤过期更新
lastLoadTime.value = Date.now()
// 注意:不再清空内容,避免 HTML 预览切换时闪烁 // 注意:不再清空内容,避免 HTML 预览切换时闪烁
// 新内容加载完成后会直接替换旧内容 // 新内容加载完成后会直接替换旧内容
@@ -451,33 +456,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
} }
} }
/**
* 仅更新文件路径(重命名场景:内容不变,只切换路径关联)
* 迁移草稿 key更新 currentFilePathRef
*/
const updateFilePath = (newPath: string) => {
const oldPath = currentFilePathRef.value
// 迁移草稿(旧 key → 新 key
if (draftKey.value && oldPath !== newPath) {
try {
const draft = localStorage.getItem(draftKey.value)
if (draft) {
const newKey = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${newPath}`
localStorage.setItem(newKey, draft)
localStorage.removeItem(draftKey.value)
draftKey.value = newKey
}
} catch (error) {
console.warn('[useFileEdit] 草稿迁移失败:', error)
}
}
// 只更新内部路径字符串引用,不触碰 currentFilePath它是 FileItem 对象,由父组件管理)
// 这样不会触发 watch → clearDraft
currentFilePathRef.value = newPath
}
/** /**
* 重置文件内容 * 重置文件内容
*/ */
@@ -523,10 +501,14 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
} }
/** /**
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容) * 更新文件内容
* 注意:需要确保更新后 fileContent 和 originalContent 保持正确的同步关系
*/ */
const updateContent = (content: string, expectedVersion?: number) => { const updateContent = (content: string, expectedVersion?: number) => {
// 如果提供了期望的版本号,检查是否匹配
// 这用于防止快速切换文件时,旧文件的防抖更新覆盖新文件的内容
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) { if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
// 版本不匹配,这是一个过期的更新,忽略它
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', { console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
expected: expectedVersion, expected: expectedVersion,
current: fileVersion.value, current: fileVersion.value,
@@ -535,9 +517,25 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
return return
} }
// 额外检查:如果更新是在文件加载后的短时间内,可能是过期更新
// 防抖时间是 150ms我们使用 300ms 的安全边际
const timeSinceLoad = Date.now() - lastLoadTime.value
if (timeSinceLoad < 300) {
console.debug('[useFileEdit] 忽略过期更新(时间窗口内):', {
timeSinceLoad,
content: content.substring(0, 50)
})
return
}
// 确保只有在内容真正改变时才更新
if (fileContent.value !== content) { if (fileContent.value !== content) {
fileContent.value = content fileContent.value = content
} }
// 自动保存草稿(防抖)
// 实际实现应该使用防抖函数
// saveDraft()
} }
/** /**
@@ -558,6 +556,12 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
return filePath.startsWith(currentDirectory.value) return filePath.startsWith(currentDirectory.value)
} }
// 监听文件内容变化,自动保存草稿
watch(fileContent, () => {
// 实际实现应该使用防抖
// saveDraft()
}, { deep: true })
// 监听文件路径变化,清除草稿 // 监听文件路径变化,清除草稿
watch(currentFilePath, (newPath, oldPath) => { watch(currentFilePath, (newPath, oldPath) => {
if (newPath !== oldPath) { if (newPath !== oldPath) {
@@ -600,7 +604,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
// 其他 // 其他
resetContent, resetContent,
clearContent, clearContent,
updateFilePath,
setEditorHeight, setEditorHeight,
// 文件类型检查 // 文件类型检查

View File

@@ -7,7 +7,6 @@ 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,
@@ -27,22 +26,12 @@ 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硬编码与旧版本保持一致
const fileServerURL = 'http://localhost:18765'
// 预览 URL // 预览 URL
const previewUrl = ref('') const previewUrl = ref('')
@@ -51,19 +40,12 @@ 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 ''
const isRemote = connectionManager.isRemote() // 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
const base = resolveFileServerBase() return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
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}`
} }
/** /**
@@ -94,7 +76,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
/** /**
* 更新预览 URL * 更新预览 URL
*/ */
const updatePreviewUrl = async (path: string) => { const updatePreviewUrl = (path: string) => {
previewUrl.value = getPreviewUrl(path) previewUrl.value = getPreviewUrl(path)
} }
@@ -206,6 +188,12 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
// 文件类型判断(同步,基于扩展名) // 文件类型判断(同步,基于扩展名)
getFileType, getFileType,
isImageFile,
isVideoFile,
isAudioFile,
isPdfFile,
isHtmlFile,
isMarkdownFile,
isPreviewable, isPreviewable,
isEditable, isEditable,

View File

@@ -2,7 +2,6 @@
<div class="file-system-container"> <div class="file-system-container">
<!-- 顶部工具栏 --> <!-- 顶部工具栏 -->
<Toolbar <Toolbar
ref="toolbarRef"
:config="toolbarConfig" :config="toolbarConfig"
@update:file-path="handleFilePathUpdate" @update:file-path="handleFilePathUpdate"
@update:show-sidebar="handleSidebarToggle" @update:show-sidebar="handleSidebarToggle"
@@ -11,9 +10,7 @@
@go-to-path="handleGoToPath" @go-to-path="handleGoToPath"
@open-file="handleOpenFile" @open-file="handleOpenFile"
@navigate-to-zip-directory="handleNavigateToZipDirectory" @navigate-to-zip-directory="handleNavigateToZipDirectory"
@update:search-keyword="handleSearchKeywordUpdate"
@show-message="handleShowMessage" @show-message="handleShowMessage"
@connection-changed="handleConnectionChanged"
/> />
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -57,8 +54,9 @@
<!-- 分隔条 --> <!-- 分隔条 -->
<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 +105,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, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions' import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
// 导入子组件 // 导入子组件
@@ -129,7 +127,6 @@ 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'
@@ -155,7 +152,6 @@ const isEditableWithPreview = (filename: string): boolean => {
const fileList = ref<FileItem[]>([]) const fileList = ref<FileItem[]>([])
const fileLoading = ref(false) const fileLoading = ref(false)
const selectedFileItem = ref<FileItem | null>(null) const selectedFileItem = ref<FileItem | null>(null)
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
// 排序状态(带 localStorage 持久化) // 排序状态(带 localStorage 持久化)
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
@@ -191,18 +187,6 @@ const editingFileName = ref('')
// 侧边栏 // 侧边栏
const showSidebar = ref(true) const showSidebar = ref(true)
// 搜索
const searchKeyword = ref('')
// 过滤后的文件列表(基于搜索关键词)
const filteredFileList = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword) return fileList.value
return fileList.value.filter(item =>
item.name.toLowerCase().includes(keyword)
)
})
// 面板宽度(带 localStorage 持久化) // 面板宽度(带 localStorage 持久化)
const restorePanelWidth = (): { left: number; right: number } => { const restorePanelWidth = (): { left: number; right: number } => {
try { try {
@@ -287,7 +271,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
}) })
// 文件编辑 // 文件编辑
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } = const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
useFileEdit({ useFileEdit({
currentFilePath: selectedFileItem, currentFilePath: selectedFileItem,
currentDirectory: filePath currentDirectory: filePath
@@ -311,8 +295,7 @@ const toolbarConfig = computed(() => ({
fileLoading: fileLoading.value, fileLoading: fileLoading.value,
showSidebar: showSidebar.value, showSidebar: showSidebar.value,
sortBy: sortBy.value, sortBy: sortBy.value,
sortOrder: sortOrder.value, sortOrder: sortOrder.value
searchKeyword: searchKeyword.value
})) }))
// 侧边栏配置 // 侧边栏配置
@@ -324,7 +307,7 @@ const sidebarConfig = computed(() => ({
// 文件列表面板配置 // 文件列表面板配置
const fileListPanelConfig = computed(() => ({ const fileListPanelConfig = computed(() => ({
fileList: filteredFileList.value, fileList: fileList.value,
fileLoading: fileLoading.value, fileLoading: fileLoading.value,
selectedFileItem: selectedFileItem.value, selectedFileItem: selectedFileItem.value,
editingFilePath: editingFilePath.value, editingFilePath: editingFilePath.value,
@@ -337,24 +320,10 @@ 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 || ''
@@ -394,8 +363,7 @@ const fileEditorPanelConfig = computed(() => {
imageLoading: imageLoading.value, imageLoading: imageLoading.value,
currentImageDimensions: currentImageDimensions.value, currentImageDimensions: currentImageDimensions.value,
currentFileExtension, currentFileExtension,
isBinaryFile: isBinaryFileRef.value, isBinaryFile: isBinaryFileRef.value
fileMtime: selectedFileItem.value?.modified_time || ''
} }
}) })
@@ -414,23 +382,6 @@ const handleRefresh = async () => {
await loadDirectory(filePath.value) await loadDirectory(filePath.value)
} }
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
connectionManager.onStateChange(async (state) => {
if (state === 'connected') {
await loadCommonPaths()
}
})
const handleSearchKeywordUpdate = (keyword: string) => {
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)
} }
@@ -528,26 +479,6 @@ 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 {
@@ -688,12 +619,24 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
} }
try { try {
// 标记是否需要重命名后仅更新路径(内容不变,零闪烁) // 如果重命名的是当前打开的文件,先关闭编辑器和预览
let needUpdatePath = false if (selectedFileItem.value?.path === oldPath) {
// 如果是文件(不是文件夹),才需要关闭编辑器
if (!selectedFileItem.value.isDir) {
// 清空编辑器内容
await clearContent()
// 如果重命名的是当前打开的文件 // 清空预览URL
if (selectedFileItem.value?.path === oldPath && !selectedFileItem.value.isDir) { if (previewUrl.value) {
needUpdatePath = true previewUrl.value = ''
}
}
// 取消选中状态
selectedFileItem.value = null
// 等待文件句柄释放(文件需要更长时间)
await new Promise(resolve => setTimeout(resolve, 300))
} }
const renamedFile = await fileOps.rename(oldPath, trimmedName) const renamedFile = await fileOps.rename(oldPath, trimmedName)
@@ -707,13 +650,6 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
} }
Message.success(`✓ 重命名成功: ${trimmedName}`) Message.success(`✓ 重命名成功: ${trimmedName}`)
// 仅更新路径关联,不重新加载内容(编辑器内容不变,零闪烁)
if (needUpdatePath && !renamedFile.isDir) {
selectedFileItem.value = renamedFile
updateFilePath(newPath)
updatePreviewUrl(newPath)
}
} catch (error: any) { } catch (error: any) {
// 提取错误信息 // 提取错误信息
let errorMsg = error?.message || error?.toString() || '未知错误' let errorMsg = error?.message || error?.toString() || '未知错误'
@@ -1072,9 +1008,6 @@ 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) => {
@@ -1229,7 +1162,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(normalized) updatePreviewUrl(`${url}/localfs/${encodeURIComponent(normalized)}`)
} catch (error) { } catch (error) {
console.error('提取图片失败:', error) console.error('提取图片失败:', error)
Message.error(`提取图片失败: ${error}`) Message.error(`提取图片失败: ${error}`)
@@ -1268,32 +1201,18 @@ const handleHorizontalResize = createResizeHandler(
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(async () => { onMounted(() => {
// 加载系统路径(阻塞,确保快捷入口就绪) // 加载系统路径
await loadCommonPaths() loadCommonPaths()
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径 // 初始化加载
const startPath = connectionManager.isRemote() ? '/' if (!filePath.value) {
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/') // 设置默认路径
if (filePath.value && !connectionManager.isRemote()) { const defaultPath = commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:\\'
await loadDirectory(filePath.value) filePath.value = defaultPath
loadDirectory(defaultPath)
} else { } else {
filePath.value = startPath loadDirectory(filePath.value)
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)
}
}
} }
// 添加键盘快捷键 // 添加键盘快捷键
@@ -1318,26 +1237,15 @@ const handleKeyDown = async (event: KeyboardEvent) => {
return return
} }
// F5 刷新文件列表 + 重载当前预览文件 // F5 刷新文件列表
if (event.key === 'F5') { if (event.key === 'F5') {
event.preventDefault() event.preventDefault()
if (filePath.value) { if (filePath.value) {
await loadDirectory(filePath.value) loadDirectory(filePath.value)
// 如果有正在预览的文件,同时重新加载其内容(类似重新点击一次)
if (selectedFileItem.value && !selectedFileItem.value.isDir) {
await loadFileContent(selectedFileItem.value.path)
}
} }
return return
} }
// Ctrl+H 打开历史记录面板
if ((event.ctrlKey || event.metaKey) && event.key === 'h') {
event.preventDefault()
toolbarRef.value?.toggleHistoryDropdown?.()
return
}
// Ctrl+Shift+C/D/E/F/G/H 快速打开对应盘符 // Ctrl+Shift+C/D/E/F/G/H 快速打开对应盘符
if ((event.ctrlKey || event.metaKey) && event.shiftKey) { if ((event.ctrlKey || event.metaKey) && event.shiftKey) {
const driveLetter = event.key.toUpperCase() const driveLetter = event.key.toUpperCase()
@@ -1562,7 +1470,7 @@ watch(() => themeStore.isDark, async () => {
} }
.resizer { .resizer {
width: 3px; width: 4px;
background: var(--color-border); background: var(--color-border);
cursor: col-resize; cursor: col-resize;
transition: background 0.2s; transition: background 0.2s;

View File

@@ -6,8 +6,6 @@
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue' import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
import { Modal, Message, Progress } from '@arco-design/web-vue' import { Modal, Message, Progress } from '@arco-design/web-vue'
import { useUpdateStore } from '../stores/update' import { useUpdateStore } from '../stores/update'
import { marked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -68,8 +66,8 @@ const showUpdateModal = () => {
title: forceUpdate.value ? '重要更新' : '发现新版本', title: forceUpdate.value ? '重要更新' : '发现新版本',
content: () => { content: () => {
const elements = [ const elements = [
h('div', { style: { marginBottom: '8px' } }, [ h('div', { style: { marginBottom: '12px' } }, [
h('span', { style: { fontSize: '13px', color: 'var(--color-text-2)' } }, '版本:'), h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '版本:'),
h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value), h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value),
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'), h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value) h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
@@ -78,23 +76,20 @@ const showUpdateModal = () => {
// 更新日志 // 更新日志
if (changelog.value) { if (changelog.value) {
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
elements.push( elements.push(
h('div', { style: { marginBottom: '8px' } }, [ h('div', { style: { marginBottom: '12px' } }, [
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'), h('div', { style: { fontSize: '13px', color: 'var(--color-text-2)', marginBottom: '8px' } }, '更新内容:'),
h('div', { h('div', {
style: { style: {
fontSize: '12px', fontSize: '13px',
color: 'var(--color-text-2)', color: 'var(--color-text-2)',
lineHeight: '1.6', lineHeight: '1.8',
padding: '10px 12px', padding: '12px',
background: 'var(--color-fill-1)', background: 'var(--color-fill-1)',
borderRadius: '4px', borderRadius: '4px',
maxHeight: '240px', whiteSpace: 'pre-wrap'
overflowY: 'auto' }
}, }, changelog.value)
innerHTML: changelogHtml
})
]) ])
) )
} }
@@ -109,7 +104,7 @@ const showUpdateModal = () => {
} }
if (metadata.length > 0) { if (metadata.length > 0) {
elements.push( elements.push(
h('div', { style: { marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-3)' } }, metadata.join(' · ')) h('div', { style: { marginBottom: '12px', fontSize: '13px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
) )
} }

View File

@@ -30,7 +30,7 @@
<div class="changelog-title"> <div class="changelog-title">
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }} {{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
</div> </div>
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" /> <div class="changelog">{{ updateInfo.changelog }}</div>
</div> </div>
</a-card> </a-card>
@@ -92,8 +92,8 @@
:status="downloadStatus" :status="downloadStatus"
/> />
<div class="progress-info"> <div class="progress-info">
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span> <span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span> <span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
</div> </div>
</div> </div>
@@ -118,8 +118,6 @@ import { Message, Modal } from '@arco-design/web-vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { IconHistory } from '@arco-design/web-vue/es/icon' import { IconHistory } from '@arco-design/web-vue/es/icon'
import { useUpdateStore } from '../stores/update' import { useUpdateStore } from '../stores/update'
import { marked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
// Emits // Emits
defineEmits(['open-version-history']) defineEmits(['open-version-history'])
@@ -136,10 +134,13 @@ const lastCheckTime = ref('-')
const installResult = ref(null) const installResult = ref(null)
const downloadedFile = ref(null) const downloadedFile = ref(null)
/** 渲染 changelogMarkdown → HTML */ // 工具函数
function renderChangelog(text: string): string { const formatFileSize = (bytes) => {
if (!text) return '' return updateStore.formatFileSize(bytes)
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text } }
const formatSpeed = (bytesPerSecond) => {
return updateStore.formatSpeed(bytesPerSecond)
} }
// 加载当前版本 // 加载当前版本
@@ -282,70 +283,29 @@ onUnmounted(() => {
} }
.changelog-section { .changelog-section {
margin-top: 12px; margin-top: 16px;
} }
.changelog-title { .changelog-title {
font-size: 13px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--color-text-2); color: var(--color-text-1);
margin-bottom: 6px; margin-bottom: 8px;
} }
.changelog { .changelog {
background: var(--color-fill-1); background: var(--color-fill-2);
padding: 10px 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin: 0; white-space: pre-wrap;
max-height: 280px; margin: 8px 0;
max-height: 200px;
overflow-y: auto; overflow-y: auto;
font-size: 12px; font-size: 13px;
line-height: 1.65; line-height: 1.6;
color: var(--color-text-2); color: var(--color-text-2);
} }
.changelog :deep(h4) {
font-size: 12px;
font-weight: 600;
color: var(--color-text-1);
margin: 8px 0 3px;
}
.changelog :deep(h4:first-child) {
margin-top: 0;
}
.changelog :deep(ul) {
list-style: none;
padding: 0;
margin: 1px 0;
}
.changelog :deep(li) {
position: relative;
padding-left: 14px;
margin: 1px 0;
}
.changelog :deep(li::before) {
content: '·';
position: absolute;
left: 4px;
color: var(--color-text-4);
font-weight: bold;
}
.changelog :deep(code) {
background: var(--color-fill-3);
padding: 0 4px;
border-radius: 3px;
font-size: 11px;
}
.changelog :deep(p) {
margin: 2px 0;
}
.download-progress { .download-progress {
margin-top: 16px; margin-top: 16px;
padding: 16px; padding: 16px;

View File

@@ -0,0 +1,50 @@
/**
* 可见数据库管理 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

@@ -15,7 +15,7 @@ interface TabConfig {
/** /**
* 应用配置类型 * 应用配置类型
*/ */
export interface AppConfig { interface AppConfig {
tabs: TabConfig[] tabs: TabConfig[]
visibleTabs: string[] visibleTabs: string[]
defaultTab: string defaultTab: string
@@ -44,7 +44,8 @@ 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: '数据库' }
] ]
} }
@@ -92,8 +93,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'] const allKeys = ['file-system', 'db-cli']
const tabTitles: Record<string, string> = { 'file-system': '文件管理' } const tabTitles: Record<string, string> = { 'file-system': '文件管理', 'db-cli': '数据库' }
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))
@@ -118,9 +119,10 @@ 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'], visibleTabs: ['file-system', 'db-cli'],
defaultTab: 'file-system' defaultTab: 'file-system'
} }
} }

View File

@@ -1,44 +0,0 @@
/**
* 连接状态 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

@@ -115,8 +115,6 @@ export interface ToolbarConfig {
sortBy: string sortBy: string
/** 排序方向 */ /** 排序方向 */
sortOrder: string sortOrder: string
/** 搜索关键词 */
searchKeyword: string
} }
/** /**
@@ -201,8 +199,6 @@ export interface FileEditorPanelConfig {
currentFileExtension: string currentFileExtension: string
/** 是否为二进制文件 */ /** 是否为二进制文件 */
isBinaryFile: boolean isBinaryFile: boolean
/** 文件修改时间(用于检测外部变更) */
fileMtime: string
} }
/** /**

View File

@@ -10,7 +10,4 @@ export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language' export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
export { oneDark } from '@codemirror/theme-one-dark' export { oneDark } from '@codemirror/theme-one-dark'
// 查找替换
export { openSearchPanel, closeSearchPanel, search, searchKeymap, SearchQuery } from '@codemirror/search'
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包 // 语言包通过 codeMirrorLoader 动态导入,避免全量打包

View File

@@ -30,7 +30,6 @@ 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', // 上次打开的文件路径
}, },
// 设备测试模块 // 设备测试模块
@@ -155,7 +154,6 @@ export const FILE_ICONS = {
RUBY: '💎', RUBY: '💎',
DART: '🎯', DART: '🎯',
DOCKERFILE: '🐳', DOCKERFILE: '🐳',
VUE: '💚',
// 数据库 // 数据库
DATABASE: '🗄️', DATABASE: '🗄️',
@@ -272,8 +270,6 @@ 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

@@ -0,0 +1,114 @@
/**
* 数据库错误提示工具
* 将技术性错误信息转换为用户友好的提示
*/
/**
* 解析数据库连接错误,返回友好的提示信息
*/
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

@@ -38,21 +38,6 @@ export const escapeHtml = (str) => {
.replace(/'/g, '&#039;') .replace(/'/g, '&#039;')
} }
/**
* 轻量 HTML 消毒(用于渲染远程 Markdown 等不可信 HTML 片段)
* 移除 script/iframe/object/embed 标签和 on* 事件属性
*/
export const sanitizeHtml = (html) => {
if (!html) return ''
return String(html)
.replace(/<script\b[^<]*(?:<\/script>|$)/gi, '')
.replace(/<iframe\b[^<]*(?:<\/iframe>|$)/gi, '')
.replace(/<object\b[^<]*(?:<\/object>|$)/gi, '')
.replace(/<embed\b[^>]*\/?>/gi, '')
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '')
}
/** /**
* 获取文件扩展名(路径安全) * 获取文件扩展名(路径安全)
* @param {string} path - 文件路径 * @param {string} path - 文件路径

View File

@@ -100,80 +100,6 @@ 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
@@ -182,23 +108,6 @@ 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 || ''
@@ -217,7 +126,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, async: false }) marked.use({ renderer, breaks: true, gfm: true })
export { marked } export { marked }

View File

@@ -0,0 +1,713 @@
<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

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

View File

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

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