Compare commits
7 Commits
v0.3.3
...
feature/fs
| Author | SHA1 | Date | |
|---|---|---|---|
| 44847e0d40 | |||
| 3d5a1e5892 | |||
| 4f1d5f885f | |||
| 742581c5d6 | |||
| 4ffac72999 | |||
| 72fef3e56f | |||
| 691e38604f |
54
CHANGELOG.md
@@ -1,5 +1,48 @@
|
||||
# 更新日志
|
||||
|
||||
## [0.4.0] - 2026-04-25
|
||||
|
||||
### 重构 🔧
|
||||
- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理
|
||||
- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖
|
||||
- **构建体积优化**: 原始 exe 从 36MB 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)
|
||||
|
||||
### 变更说明
|
||||
- 顶部 Tab 仅保留「文件管理」,移除数据库入口
|
||||
- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响
|
||||
- 本地 SQLite 配置存储(AppConfig)保留不变
|
||||
|
||||
---
|
||||
|
||||
## [0.3.4] - 2026-04-22
|
||||
|
||||
### 新增 ✨
|
||||
- **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
|
||||
|
||||
### 新增 ✨
|
||||
@@ -14,15 +57,15 @@
|
||||
|
||||
### 优化 🚀
|
||||
- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容
|
||||
- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)
|
||||
- SQL 查询优化器 — 查询缓存、慢查询日志
|
||||
- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持
|
||||
- Office/CSV 预览增强 — 本地文件服务器获取文件
|
||||
- Markdown 增强 — 本地文件链接支持、Shell 语法高亮
|
||||
- HTML 预览 — 改用 iframe src 替代 srcdoc
|
||||
- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复
|
||||
- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑
|
||||
- 文件列表 UI 重构 — 统一渲染逻辑,提升滚动性能
|
||||
- CSV 编辑模式优化 + PDF 导出重构
|
||||
- 拷贝功能优化 — 新增 ClipboardCopy composable
|
||||
- 拷贝功能优化
|
||||
|
||||
### 修复 🐛
|
||||
- Office 文件预览:修复类型检测与二进制误判
|
||||
@@ -39,10 +82,9 @@
|
||||
### 重构 🔧
|
||||
- CodeMirror 架构优化 — 统一导出避免多实例问题
|
||||
- 消除代码重复 — 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 次)
|
||||
- 正则表达式预编译(query_optimizer)
|
||||
- 缓存读锁优化 + SHA-256 key hash
|
||||
- 正则表达式预编译、缓存读锁优化
|
||||
- 禁止 Ctrl+滚轮缩放
|
||||
- Dockerfile 语法高亮支持
|
||||
- 滚动条样式修复
|
||||
|
||||
24
README.md
@@ -1,10 +1,22 @@
|
||||
# U-Desk v0.3.3
|
||||
# U-Desk v0.3.4
|
||||
|
||||
## 功能
|
||||
- 数据库客户端
|
||||
- Markdown编辑器
|
||||
- PDF导出
|
||||
- **文件管理** — 本地文件浏览、编辑(CodeMirror 语法高亮+搜索)、预览(图片/视频/PDF/HTML/Markdown/Excel/Word/CSV)
|
||||
- **数据库客户端** — 多数据库连接管理、SQL 执行、查询历史、表结构管理
|
||||
- **Markdown 编辑器** — 独立编辑页面、实时预览、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导出优化中
|
||||
- ✅ 文件服务器安全重构+编辑器增强+搜索排序+更新面板渲染
|
||||
|
||||
236
app.go
@@ -1,9 +1,11 @@
|
||||
// [fs-only] 数据库客户端模块已移除(feature/fs-only 分支)
|
||||
// 保留模块:文件系统 | Markdown编辑器 | 版本历史(抽屉) | 系统信息 | 更新检查 | PDF导出
|
||||
// 顶部Tab仅:file-system(数据库 db-cli 已删除)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdruntime "runtime"
|
||||
@@ -24,13 +26,9 @@ import (
|
||||
// App 应用结构体
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
connectionAPI *api.ConnectionAPI
|
||||
sqlAPI *api.SqlAPI
|
||||
tabAPI *api.TabAPI
|
||||
updateAPI *api.UpdateAPI
|
||||
configAPI *api.ConfigAPI
|
||||
pdfAPI *api.PdfAPI
|
||||
fileServer *http.Server
|
||||
filesystem *filesystem.FileSystemService
|
||||
isAlwaysOnTop bool
|
||||
}
|
||||
@@ -138,31 +136,6 @@ func (a *App) getVisibleTabs() []string {
|
||||
|
||||
// initModulesByConfig 根据配置初始化模块
|
||||
func (a *App) initModulesByConfig(visibleTabs []string) error {
|
||||
// 检查是否启用数据库模块
|
||||
if common.Contains(visibleTabs, common.TabDatabase) {
|
||||
fmt.Println("[启动] 初始化数据库模块...")
|
||||
var err error
|
||||
|
||||
// 初始化 ConnectionAPI
|
||||
if a.connectionAPI, err = api.NewConnectionAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化 SqlAPI
|
||||
if a.sqlAPI, err = api.NewSqlAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化 TabAPI
|
||||
if a.tabAPI, err = api.NewTabAPI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("[启动] 数据库模块初始化完成")
|
||||
} else {
|
||||
fmt.Println("[启动] 跳过数据库模块(未启用)")
|
||||
}
|
||||
|
||||
// 检查是否启用文件系统模块
|
||||
if common.Contains(visibleTabs, common.TabFileSystem) {
|
||||
fmt.Println("[启动] 初始化文件系统模块...")
|
||||
@@ -194,7 +167,7 @@ func (a *App) startFileServer() {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:18765")
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
|
||||
}
|
||||
|
||||
// Shutdown 应用关闭时调用
|
||||
@@ -415,7 +388,7 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
|
||||
folderGUIDs := map[string]string{
|
||||
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||
"documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}",
|
||||
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
|
||||
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||
}
|
||||
for name, guid := range folderGUIDs {
|
||||
@@ -441,94 +414,6 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// ========== 数据库连接管理接口 ==========
|
||||
|
||||
// SaveDbConnection 保存数据库连接配置
|
||||
func (a *App) SaveDbConnection(req api.SaveConnectionRequest) error {
|
||||
return a.connectionAPI.SaveDbConnection(req)
|
||||
}
|
||||
|
||||
// ListDbConnections 获取连接列表
|
||||
func (a *App) ListDbConnections() ([]map[string]interface{}, error) {
|
||||
return a.connectionAPI.ListDbConnections()
|
||||
}
|
||||
|
||||
// DeleteDbConnection 删除连接配置
|
||||
func (a *App) DeleteDbConnection(id uint) error {
|
||||
return a.connectionAPI.DeleteDbConnection(id)
|
||||
}
|
||||
|
||||
// TestDbConnection 测试连接(通过已保存的连接ID)
|
||||
func (a *App) TestDbConnection(id uint) error {
|
||||
return a.connectionAPI.TestDbConnection(id)
|
||||
}
|
||||
|
||||
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
|
||||
func (a *App) TestDbConnectionWithParams(req api.TestConnectionRequest) error {
|
||||
return a.connectionAPI.TestDbConnectionWithParams(req)
|
||||
}
|
||||
|
||||
// LoadAllDatabases 加载全部数据库列表
|
||||
func (a *App) LoadAllDatabases(req api.LoadAllDatabasesRequest) ([]string, error) {
|
||||
return a.connectionAPI.LoadAllDatabases(req)
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行 SQL 语句
|
||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||
func (a *App) ExecuteSQL(connectionId uint, sqlStr string, database string) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.ExecuteSQL(connectionId, sqlStr, database)
|
||||
}
|
||||
|
||||
// GetDatabases 获取数据库列表
|
||||
func (a *App) GetDatabases(connectionId uint) ([]string, error) {
|
||||
return a.sqlAPI.GetDatabases(connectionId)
|
||||
}
|
||||
|
||||
// GetTables 获取表列表
|
||||
func (a *App) GetTables(connectionId uint, database string) ([]string, error) {
|
||||
return a.sqlAPI.GetTables(connectionId, database)
|
||||
}
|
||||
|
||||
// GetTableStructure 获取表结构
|
||||
func (a *App) GetTableStructure(connectionId uint, database, tableName string) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetTableStructure(connectionId, database, tableName)
|
||||
}
|
||||
|
||||
// GetIndexes 获取索引列表
|
||||
func (a *App) GetIndexes(connectionId uint, database, tableName string) ([]map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetIndexes(connectionId, database, tableName)
|
||||
}
|
||||
|
||||
// PreviewTableStructure 预览表结构变更
|
||||
func (a *App) PreviewTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return a.sqlAPI.PreviewTableStructure(connectionId, database, tableName, structure)
|
||||
}
|
||||
|
||||
// UpdateTableStructure 更新表结构
|
||||
func (a *App) UpdateTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return a.sqlAPI.UpdateTableStructure(connectionId, database, tableName, structure)
|
||||
}
|
||||
|
||||
// SaveResult 手动保存执行结果
|
||||
func (a *App) SaveResult(connectionId uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.SaveResult(connectionId, database, sql, resultType, data, columns, rowsAffected, executionTime)
|
||||
}
|
||||
|
||||
// GetResultHistory 获取结果历史
|
||||
func (a *App) GetResultHistory(connectionId *uint, keyword string, limit, offset int) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetResultHistory(connectionId, keyword, limit, offset)
|
||||
}
|
||||
|
||||
// GetResultHistoryByID 根据ID获取结果历史
|
||||
func (a *App) GetResultHistoryByID(id uint) (map[string]interface{}, error) {
|
||||
return a.sqlAPI.GetResultHistoryByID(id)
|
||||
}
|
||||
|
||||
// DeleteResultHistory 删除结果历史
|
||||
func (a *App) DeleteResultHistory(id uint) error {
|
||||
return a.sqlAPI.DeleteResultHistory(id)
|
||||
}
|
||||
|
||||
// Reload 重新加载窗口(用于菜单项)
|
||||
func (a *App) Reload() {
|
||||
if a.ctx != nil {
|
||||
@@ -589,82 +474,86 @@ func (a *App) WindowToggleAlwaysOnTop() bool {
|
||||
return a.isAlwaysOnTop
|
||||
}
|
||||
|
||||
// ========== SQL 标签页管理接口 ==========
|
||||
|
||||
// SaveSqlTabs 保存 SQL 标签页列表
|
||||
func (a *App) SaveSqlTabs(tabs []map[string]interface{}) error {
|
||||
return a.tabAPI.SaveSqlTabs(tabs)
|
||||
}
|
||||
|
||||
// ListSqlTabs 获取 SQL 标签页列表
|
||||
func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
|
||||
return a.tabAPI.ListSqlTabs()
|
||||
}
|
||||
|
||||
// ========== 版本更新管理接口 ==========
|
||||
|
||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
|
||||
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return a.updateAPI.CheckUpdate()
|
||||
return a.updateAPI, nil
|
||||
}
|
||||
|
||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.CheckUpdate()
|
||||
}
|
||||
|
||||
// GetCurrentVersion 获取当前版本号
|
||||
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.GetCurrentVersion()
|
||||
return api.GetCurrentVersion()
|
||||
}
|
||||
|
||||
// GetUpdateConfig 获取更新配置
|
||||
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.GetUpdateConfig()
|
||||
return api.GetUpdateConfig()
|
||||
}
|
||||
|
||||
// SetUpdateConfig 设置更新配置
|
||||
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
}
|
||||
|
||||
// DownloadUpdate 下载更新包
|
||||
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.DownloadUpdate(downloadURL)
|
||||
return api.DownloadUpdate(downloadURL)
|
||||
}
|
||||
|
||||
// InstallUpdate 安装更新包
|
||||
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
|
||||
return api.InstallUpdate(installerPath, autoRestart)
|
||||
}
|
||||
|
||||
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// VerifyUpdateFile 验证更新文件哈希值
|
||||
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// startAutoUpdateCheck 启动自动更新检查
|
||||
@@ -753,7 +642,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
||||
|
||||
// GetFileServerURL 获取本地文件服务器的URL
|
||||
func (a *App) GetFileServerURL() string {
|
||||
return "http://localhost:18765"
|
||||
return "http://localhost:8073"
|
||||
}
|
||||
|
||||
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
||||
@@ -848,8 +737,6 @@ func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
|
||||
|
||||
for _, tab := range newlyEnabled {
|
||||
switch tab {
|
||||
case common.TabDatabase:
|
||||
a.initDatabaseModule()
|
||||
case common.TabFileSystem:
|
||||
a.initFilesystemModule()
|
||||
case common.TabDevice:
|
||||
@@ -858,37 +745,6 @@ func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// initDatabaseModule 延迟初始化数据库模块
|
||||
func (a *App) initDatabaseModule() {
|
||||
if a.connectionAPI != nil {
|
||||
fmt.Println("[模块] 数据库模块已初始化,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[模块] 延迟初始化数据库模块...")
|
||||
var err error
|
||||
|
||||
// 初始化 ConnectionAPI
|
||||
if a.connectionAPI, err = api.NewConnectionAPI(); err != nil {
|
||||
fmt.Printf("[模块] 数据库模块初始化失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 SqlAPI
|
||||
if a.sqlAPI, err = api.NewSqlAPI(); err != nil {
|
||||
fmt.Printf("[模块] SqlAPI 初始化失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 TabAPI
|
||||
if a.tabAPI, err = api.NewTabAPI(); err != nil {
|
||||
fmt.Printf("[模块] TabAPI 初始化失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[模块] 数据库模块初始化完成")
|
||||
}
|
||||
|
||||
// initFilesystemModule 延迟初始化文件系统模块
|
||||
func (a *App) initFilesystemModule() {
|
||||
if a.filesystem != nil {
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 35 KiB |
@@ -1 +1 @@
|
||||
{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- Markdown 编辑器: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- PDF 导出: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- 窗口置顶 + 收藏夹置顶\n- Excel/Word 文件预览支持\n- 数据库 UI 大幅改进: 查询历史、查询模板、SQL 工具栏、结果导出\n- 数据库可见性过滤与连接管理增强\n\n### 优化 🚀\n- MySQL 动态连接池重构(健康检查、性能权重、自适应扩缩容)\n- SQL 查询优化器(查询缓存、慢查询日志)\n- Redis Pipeline 支持\n- Wails 框架升级 + FileListPanel 重写\n- CSV 编辑模式优化 + 拷贝功能优化\n\n### 修复 🐛\n- Office 类型检测修复、CORS 跨域修复、大文件卡死修复\n\n### 安全修复 🔒\n- XSS 防护、PDF 路径穿越防护、HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化、大规模死代码清理(-1306行)", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 18396672, "sha256": "f0bdf8954b276f4bb45a69336f171bb2a481f7a7125fc3309aae5de2fbf0cf15", "force_update": false}
|
||||
{"version": "0.4.0", "download_url": "https://c.1216.top/download/u-desk-0.4.0.exe", "changelog": "### 重构 🔧\n- 移除数据库客户端模块:删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- 清理依赖:移除 mysql/redis/mongo 驱动依赖\n- 构建体积优化:原始 exe 26MB,UPX 压缩后 7.5MB(压缩率 28.8%)\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响", "force_update": false, "release_date": "2026-04-25", "file_size": 7766016}
|
||||
@@ -1 +1 @@
|
||||
{"updated_at": "2026-04-13T23:45:00+08:00", "versions": [{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 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": ""}]}
|
||||
{"updated_at": "2026-04-25T23:58:00+08:00", "versions": [{"version": "0.4.0", "release_date": "2026-04-25", "changelog": "### 重构 🔧\n- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖\n- **构建体积优化**: 原始 exe 从 36MB 降至 26MB,UPX 压缩后仅 7.5MB(压缩率 28.8%)\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响\n- 本地 SQLite 配置存储(AppConfig)保留不变", "download_url": "https://c.1216.top/download/u-desk-0.4.0.exe", "file_size": 7766016, "sha256": "532c30bdc57ea0ff5bc71756714b7ca18388ad3e09b2c4eefcdb6816349c7dda"}, {"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c"}, {"version": "0.3.2", "release_date": "2026-02-05", "changelog": "### 重构 🔧\n- CodeMirror 架构优化 - 统一导出避免多实例问题\n- 语言加载器优化 - 从动态 import 改为静态导入\n- 动态主题切换 - 使用 Compartment 实现无损切换\n\n### 优化 🚀\n- 编辑器性能 - 添加内容更新防抖\n- 亮色主题 - 改进代码编辑器亮色模式样式", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.3.0", "release_date": "2026-02-04", "changelog": "### 新增 ✨\n- Markdown 图表支持 - Mermaid 流程图、时序图、类图等\n- 代码语法高亮 - 支持 20+ 种常用编程语言\n- 文件列表优化 - 文件夹优先显示,同类型按名称排序", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.2.0", "release_date": "2026-01-28", "changelog": "### 新增 ✨\n- 应用配置管理 - 全新设置面板,支持自定义显示模块和默认启动页\n- 智能更新提醒 - 新增版本更新通知组件\n- 模块重命名 - 应用更名为 u-desk", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.5", "release_date": "2026-01-22", "changelog": "### 新增 ✨\n- 文件管理模块 - 文件浏览、编辑、操作功能\n- 版本更新管理 - 自动检查和下载更新\n- 系统信息查询 - CPU、内存、磁盘等硬件信息", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.0", "release_date": "2026-01-18", "changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能", "download_url": "", "file_size": 0, "sha256": ""}]}
|
||||
BIN
build/windows/app-icon.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
44
build/windows/convert-ico.ps1
Normal file
@@ -0,0 +1,44 @@
|
||||
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)"
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 53 KiB |
BIN
build/windows/u-desk-icon.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
cmd/agent/clipboard_20260429_195256.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
105
cmd/agent/main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/agent/config"
|
||||
agentmw "u-desk/internal/agent/middleware"
|
||||
"u-desk/internal/agent/handler"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("configs/agent.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] 加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
fsConfig := filesystem.DefaultConfig()
|
||||
fsSvc, err := filesystem.NewFileSystemService(fsConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] 初始化文件服务失败: %v", err)
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: cfg.CORS.AllowedOrigins,
|
||||
AllowMethods: []string{echo.GET, echo.PUT, echo.POST, echo.DELETE, echo.PATCH, echo.OPTIONS},
|
||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAuthorization, echo.HeaderAccept},
|
||||
}))
|
||||
if cfg.Auth.Token != "" {
|
||||
e.Use(agentmw.Auth(cfg.Auth.Token))
|
||||
}
|
||||
|
||||
h := handler.New(fsSvc, cfg)
|
||||
|
||||
api := e.Group("/api/v1")
|
||||
{
|
||||
api.GET("/ping", h.Ping)
|
||||
api.GET("/info", h.Info)
|
||||
|
||||
// 文件操作 — 所有通过 ?path= 参数传递路径
|
||||
api.GET("/fs", h.ListOrStat) // ?path=xxx [&action=stat]
|
||||
api.GET("/fs/read", h.ReadFile) // ?path=xxx
|
||||
api.PUT("/fs/write", h.WriteFile) // ?path=xxx & body={content}
|
||||
api.POST("/fs/create", h.Create) // ?path=xxx & body={type,name}
|
||||
api.DELETE("/fs/delete", h.Delete) // ?path=xxx
|
||||
api.PATCH("/fs/rename", h.Rename) // ?path=xxx & body={new_path}
|
||||
api.POST("/fs/upload", h.Upload) // ?path=xxx & body={content}
|
||||
api.GET("/fs/detect", h.DetectType) // ?path=xxx
|
||||
|
||||
sys := api.Group("/system")
|
||||
{
|
||||
sys.GET("/common-paths", h.CommonPaths)
|
||||
sys.GET("/drives", h.Drives)
|
||||
}
|
||||
|
||||
proxy := api.Group("/proxy")
|
||||
{
|
||||
proxy.GET("/localfs/*", h.FileServerProxy)
|
||||
proxy.GET("/html-preview", h.HTMLPreviewProxy)
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
go func() {
|
||||
log.Printf("[INFO] u-fs-agent 启动于 %s", addr)
|
||||
if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[FATAL] HTTP 服务器错误: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if _, err := filesystem.StartLocalFileServer(); err != nil {
|
||||
log.Printf("[WARN] 文件服务器启动失败(媒体预览不可用): %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("[INFO] 正在关闭...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filesystem.ShutdownLocalFileServer()
|
||||
e.Shutdown(ctx)
|
||||
fsSvc.Close(ctx)
|
||||
log.Println("[INFO] 已关闭")
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化数据库
|
||||
db, err := storage.Init()
|
||||
if err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== 数据库连接配置调试工具 ===")
|
||||
fmt.Println()
|
||||
|
||||
// 列出所有连接
|
||||
var connections []models.DbConnection
|
||||
result := db.Order("id").Find(&connections)
|
||||
if result.Error != nil {
|
||||
log.Fatalf("查询失败: %v", result.Error)
|
||||
}
|
||||
|
||||
fmt.Printf("当前有 %d 个连接配置:\n", len(connections))
|
||||
fmt.Println()
|
||||
|
||||
for _, conn := range connections {
|
||||
fmt.Printf("ID: %d\n", conn.ID)
|
||||
fmt.Printf(" 名称: %s\n", conn.Name)
|
||||
fmt.Printf(" 类型: %s\n", conn.Type)
|
||||
fmt.Printf(" 主机: %s:%d\n", conn.Host, conn.Port)
|
||||
fmt.Printf(" 用户名: %s\n", conn.Username)
|
||||
fmt.Printf(" 创建时间: %s\n", conn.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 询问用户操作
|
||||
var choice int
|
||||
fmt.Print("请选择操作:\n")
|
||||
fmt.Print("1. 删除指定 ID 的连接\n")
|
||||
fmt.Print("2. 列出连接详情\n")
|
||||
fmt.Print("0. 退出\n")
|
||||
fmt.Print("请输入: ")
|
||||
fmt.Scanln(&choice)
|
||||
|
||||
if choice == 1 {
|
||||
var id uint
|
||||
fmt.Print("请输入要删除的连接 ID: ")
|
||||
fmt.Scanln(&id)
|
||||
|
||||
// 确认
|
||||
var confirm string
|
||||
fmt.Printf("确认删除 ID=%d 的连接吗?(y/N): ", id)
|
||||
fmt.Scanln(&confirm)
|
||||
|
||||
if confirm == "y" || confirm == "Y" {
|
||||
result := db.Delete(&models.DbConnection{}, id)
|
||||
if result.Error != nil {
|
||||
log.Printf("删除失败: %v", result.Error)
|
||||
} else {
|
||||
fmt.Printf("删除成功!影响行数: %d\n", result.RowsAffected)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("已取消删除")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n工具退出")
|
||||
}
|
||||
29
configs/agent.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# u-fs-agent 配置文件
|
||||
# 部署到远端服务器后修改此文件
|
||||
|
||||
server:
|
||||
port: 9876 # 监听端口
|
||||
host: "0.0.0.0" # 监听地址
|
||||
|
||||
auth:
|
||||
token: "" # API Token(留空则不验证,生产环境必须设置)
|
||||
# 生成随机 token: openssl rand -hex 32
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "*" # 开发模式允许所有来源
|
||||
# 生产环境建议限定:
|
||||
# - "http://localhost:5173"
|
||||
# - "http://localhost:5174"
|
||||
|
||||
log:
|
||||
level: "info" # debug / info / warn / error
|
||||
format: "json" # json / text
|
||||
|
||||
file_server:
|
||||
port: 8073 # 内置文件服务器端口(用于媒体预览代理)
|
||||
max_file_size: 524288000 # 最大文件大小 500MB
|
||||
|
||||
security:
|
||||
allow_symlinks: false # 是否允许符号链接
|
||||
check_system_paths: true # 检查系统关键目录
|
||||
17
go.mod
@@ -6,24 +6,19 @@ require (
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/labstack/echo/v4 v4.15.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
golang.org/x/sys v0.40.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
@@ -37,8 +32,6 @@ require (
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
@@ -62,16 +55,12 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
53
go.sum
@@ -1,15 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
@@ -18,8 +10,6 @@ github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipw
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
@@ -31,8 +21,6 @@ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
@@ -57,8 +45,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
@@ -96,8 +82,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -129,72 +113,43 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
|
||||
108
internal/agent/config/config.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
FileServer FileServerConfig `yaml:"file_server"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
Host string `yaml:"host"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type FileServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
MaxFileSize int64 `yaml:"max_file_size"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
AllowSymlinks bool `yaml:"allow_symlinks"`
|
||||
CheckSystemPaths bool `yaml:"check_system_paths"`
|
||||
}
|
||||
|
||||
// FileServerAddr 返回文件服务器的完整地址
|
||||
func (c *Config) FileServerAddr() string {
|
||||
return fmt.Sprintf("http://localhost:%d", c.FileServer.Port)
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// 配置文件不存在时使用默认值
|
||||
if os.IsNotExist(err) {
|
||||
return Default(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清理 origins 中的空格并去重
|
||||
seen := make(map[string]bool, len(cfg.CORS.AllowedOrigins))
|
||||
uniques := cfg.CORS.AllowedOrigins[:0]
|
||||
for _, origin := range cfg.CORS.AllowedOrigins {
|
||||
o := strings.TrimSpace(origin)
|
||||
if o != "" && !seen[o] {
|
||||
seen[o] = true
|
||||
uniques = append(uniques, o)
|
||||
}
|
||||
}
|
||||
cfg.CORS.AllowedOrigins = uniques
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: 9876,
|
||||
Host: "0.0.0.0",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
Token: "",
|
||||
},
|
||||
CORS: CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
Log: LogConfig{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
},
|
||||
FileServer: FileServerConfig{
|
||||
Port: 8073,
|
||||
MaxFileSize: 500 * 1024 * 1024,
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
AllowSymlinks: false,
|
||||
CheckSystemPaths: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
176
internal/agent/handler/file_handler.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"u-desk/internal/agent/model"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type writeFileReq struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
Type string `json:"type"` // "file" or "dir"
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type renameReq struct {
|
||||
NewPath string `json:"new_path"`
|
||||
}
|
||||
|
||||
type uploadReq struct {
|
||||
Content string `json:"content"` // base64 编码内容
|
||||
}
|
||||
|
||||
// ListOrStat 列出目录或获取文件信息(?get=stat 时返回单文件信息)
|
||||
func (h *Handler) ListOrStat(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
action := c.QueryParam("get")
|
||||
|
||||
if action == "stat" {
|
||||
info, err := h.fsSvc.GetFileInfo(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.NotFound(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(info))
|
||||
}
|
||||
|
||||
files, err := h.fsSvc.ListDir(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
// 限制返回数量,避免大目录导致前端卡顿
|
||||
limit := c.QueryParam("limit")
|
||||
if limit != "" {
|
||||
n := 0
|
||||
for i, f := range files {
|
||||
if n >= 500 { // 硬限制 500 条
|
||||
break
|
||||
}
|
||||
files[i] = f
|
||||
n++
|
||||
}
|
||||
files = files[:n]
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(files))
|
||||
}
|
||||
|
||||
// ReadFile 读取文件文本内容
|
||||
func (h *Handler) ReadFile(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
content, err := h.fsSvc.ReadFile(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||
"content": content,
|
||||
}))
|
||||
}
|
||||
|
||||
// WriteFile 写入文件文本内容
|
||||
func (h *Handler) WriteFile(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
var req writeFileReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
if err := h.fsSvc.WriteFile(path, req.Content); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.NoContent())
|
||||
}
|
||||
|
||||
// Create 创建文件或目录
|
||||
func (h *Handler) Create(c echo.Context) error {
|
||||
parentPath := getPath(c)
|
||||
var req createReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("名称不能为空"))
|
||||
}
|
||||
|
||||
var result *filesystem.FileOperationResult
|
||||
var err error
|
||||
|
||||
fullPath := filepath.Join(parentPath, req.Name)
|
||||
|
||||
switch req.Type {
|
||||
case "dir":
|
||||
result, err = h.fsSvc.CreateDir(fullPath)
|
||||
default:
|
||||
result, err = h.fsSvc.CreateFile(fullPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusCreated, model.OK(result))
|
||||
}
|
||||
|
||||
// Delete 删除文件或目录
|
||||
func (h *Handler) Delete(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
result, err := h.fsSvc.DeletePath(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(result))
|
||||
}
|
||||
|
||||
// Rename 重命名文件或目录
|
||||
func (h *Handler) Rename(c echo.Context) error {
|
||||
oldPath := getPath(c)
|
||||
var req renameReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
req.NewPath = strings.TrimSpace(req.NewPath)
|
||||
if req.NewPath == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不能为空"))
|
||||
}
|
||||
cleanNew := filepath.Clean(req.NewPath)
|
||||
if strings.Contains(cleanNew, "..") {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("新路径不允许包含 .."))
|
||||
}
|
||||
result, err := h.fsSvc.RenamePath(oldPath, cleanNew)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(result))
|
||||
}
|
||||
|
||||
// Upload 上传 Base64 编码的二进制文件
|
||||
func (h *Handler) Upload(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
var req uploadReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("无效请求体"))
|
||||
}
|
||||
if req.Content == "" {
|
||||
return c.JSON(http.StatusBadRequest, model.BadRequest("内容不能为空"))
|
||||
}
|
||||
if err := h.fsSvc.SaveBase64File(path, req.Content); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.NoContent())
|
||||
}
|
||||
|
||||
// DetectType 通过文件内容检测类型
|
||||
func (h *Handler) DetectType(c echo.Context) error {
|
||||
path := getPath(c)
|
||||
info, err := h.fsSvc.DetectFileTypeByContent(path)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusOK, model.InternalError(err.Error()))
|
||||
}
|
||||
return c.JSON(http.StatusOK, model.OK(info))
|
||||
}
|
||||
37
internal/agent/handler/handler.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"u-desk/internal/agent/config"
|
||||
"u-desk/internal/filesystem"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
fsSvc *filesystem.FileSystemService
|
||||
cfg *config.Config
|
||||
fileProxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func New(fsSvc *filesystem.FileSystemService, cfg *config.Config) *Handler {
|
||||
fileTarget, _ := url.Parse(cfg.FileServerAddr() + "/localfs/")
|
||||
return &Handler{
|
||||
fsSvc: fsSvc,
|
||||
cfg: cfg,
|
||||
fileProxy: httputil.NewSingleHostReverseProxy(fileTarget),
|
||||
}
|
||||
}
|
||||
|
||||
// getPath 从 query 参数提取并规范化文件路径
|
||||
func getPath(c echo.Context) string {
|
||||
raw := c.QueryParam("path")
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// URL 已被 Echo 自动 decode,只需转换路径分隔符
|
||||
return filepath.FromSlash(raw)
|
||||
}
|
||||
64
internal/agent/handler/server_handler.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// FileServerProxy 反向代理到内置文件服务器(用于媒体预览)
|
||||
func (h *Handler) FileServerProxy(c echo.Context) error {
|
||||
rawPath := c.Param("*")
|
||||
if rawPath == "" {
|
||||
return c.String(http.StatusBadRequest, "缺少文件路径")
|
||||
}
|
||||
|
||||
clean := filepath.Clean(rawPath)
|
||||
if strings.Contains(clean, "..") {
|
||||
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||
}
|
||||
|
||||
// 防止多重 /localfs/ 前缀(循环去除所有)
|
||||
targetPath := filepath.ToSlash(clean)
|
||||
for strings.HasPrefix(targetPath, "localfs/") || strings.HasPrefix(targetPath, "localfs\\") {
|
||||
targetPath = strings.TrimPrefix(targetPath, "localfs/")
|
||||
targetPath = strings.TrimPrefix(targetPath, "localfs\\")
|
||||
}
|
||||
c.Request().URL.Path = "/localfs/" + targetPath
|
||||
h.fileProxy.ServeHTTP(c.Response(), c.Request())
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTMLPreviewProxy 代理 HTML 预览请求(直连内部服务器,避免 ReverseProxy 路径拼接问题)
|
||||
func (h *Handler) HTMLPreviewProxy(c echo.Context) error {
|
||||
rawPath := c.QueryParam("path")
|
||||
if rawPath == "" {
|
||||
return c.String(http.StatusBadRequest, "缺少 path 参数")
|
||||
}
|
||||
clean := filepath.Clean(rawPath)
|
||||
if strings.Contains(clean, "..") {
|
||||
return c.String(http.StatusForbidden, "路径不允许包含 ..")
|
||||
}
|
||||
theme := c.QueryParam("theme")
|
||||
|
||||
targetURL := fmt.Sprintf("http://localhost:8073/localfs/html-preview?path=%s&theme=%s",
|
||||
url.QueryEscape(clean), url.QueryEscape(theme))
|
||||
|
||||
resp, err := http.Get(targetURL)
|
||||
if err != nil {
|
||||
return c.String(http.StatusBadGateway, "内部服务器不可用")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for k, v := range resp.Header {
|
||||
c.Response().Header()[k] = v
|
||||
}
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
io.Copy(c.Response(), resp.Body)
|
||||
return nil
|
||||
}
|
||||
113
internal/agent/handler/system_handler.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"u-desk/internal/agent/model"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Ping 健康检查
|
||||
func (h *Handler) Ping(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]string{
|
||||
"status": "ok",
|
||||
}))
|
||||
}
|
||||
|
||||
// Info 返回 Agent 信息
|
||||
func (h *Handler) Info(c echo.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
return c.JSON(http.StatusOK, model.OK(map[string]interface{}{
|
||||
"version": "0.1.0",
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"hostname": hostname,
|
||||
}))
|
||||
}
|
||||
|
||||
// CommonPaths 返回常用系统路径
|
||||
func (h *Handler) CommonPaths(c echo.Context) error {
|
||||
paths := map[string]string{}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
paths["home"] = home
|
||||
paths["desktop"] = home + "/Desktop"
|
||||
paths["documents"] = home + "/Documents"
|
||||
paths["downloads"] = home + "/Downloads"
|
||||
}
|
||||
|
||||
// 根据平台添加盘符/根路径
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
_, err := os.Stat(string(drive) + ":\\")
|
||||
if err == nil {
|
||||
paths["drive_"+strings.ToLower(string(drive))] = string(drive) + ":\\"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
paths["root"] = "/"
|
||||
_, err := os.Stat("/home")
|
||||
if err == nil {
|
||||
paths["users"] = "/home"
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, model.OK(paths))
|
||||
}
|
||||
|
||||
// Drives 返回可用磁盘列表
|
||||
func (h *Handler) Drives(c echo.Context) error {
|
||||
type DriveInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
FsType string `json:"fs_type,omitempty"`
|
||||
Total uint64 `json:"total"`
|
||||
Free uint64 `json:"free"`
|
||||
}
|
||||
|
||||
var drives []DriveInfo
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
drivePath := string(drive) + ":\\"
|
||||
if _, err := os.Stat(drivePath); err != nil {
|
||||
continue
|
||||
}
|
||||
drives = append(drives, DriveInfo{
|
||||
Name: strings.ToLower(string(drive)),
|
||||
Path: drivePath,
|
||||
Total: 0,
|
||||
Free: 0,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
parts, err := os.ReadDir("/")
|
||||
if err == nil {
|
||||
for _, p := range parts {
|
||||
name := p.Name()
|
||||
if len(name) == 2 && name[0] != '.' && name[1] != '.' && p.IsDir() {
|
||||
// 可能是挂载点
|
||||
fullPath := "/" + name
|
||||
if stat, err := os.Stat(fullPath); err == nil && stat.IsDir() {
|
||||
drives = append(drives, DriveInfo{
|
||||
Name: name,
|
||||
Path: fullPath,
|
||||
})
|
||||
_ = stat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 至少返回根目录
|
||||
if len(drives) == 0 {
|
||||
drives = append(drives, DriveInfo{Name: "/", Path: "/"})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, model.OK(drives))
|
||||
}
|
||||
61
internal/agent/middleware/auth.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const cookieName = "fs_token"
|
||||
|
||||
func Auth(token string) echo.MiddlewareFunc {
|
||||
if token == "" {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// 1. Authorization header(API 调用,首选)
|
||||
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,
|
||||
})
|
||||
}
|
||||
41
internal/agent/model/response.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func OK(data interface{}) Response {
|
||||
return Response{Code: http.StatusOK, Data: data}
|
||||
}
|
||||
|
||||
func Created(data interface{}) Response {
|
||||
return Response{Code: http.StatusCreated, Data: data}
|
||||
}
|
||||
|
||||
func NoContent() Response {
|
||||
return Response{Code: http.StatusNoContent}
|
||||
}
|
||||
|
||||
func BadRequest(msg string) Response {
|
||||
return Response{Code: http.StatusBadRequest, Message: msg}
|
||||
}
|
||||
|
||||
func Unauthorized(msg string) Response {
|
||||
return Response{Code: http.StatusUnauthorized, Message: msg}
|
||||
}
|
||||
|
||||
func Forbidden(msg string) Response {
|
||||
return Response{Code: http.StatusForbidden, Message: msg}
|
||||
}
|
||||
|
||||
func NotFound(msg string) Response {
|
||||
return Response{Code: http.StatusNotFound, Message: msg}
|
||||
}
|
||||
|
||||
func InternalError(msg string) Response {
|
||||
return Response{Code: http.StatusInternalServerError, Message: msg}
|
||||
}
|
||||
@@ -136,40 +136,66 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MigrateTabConfig 迁移旧配置
|
||||
// MigrateTabConfig 迁移旧配置(device 移除 + openclaw-manager 重命名)
|
||||
func (api *ConfigAPI) MigrateTabConfig() error {
|
||||
config, _ := api.configService.GetTabConfig()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查是否包含 device
|
||||
hasDevice := false
|
||||
needMigrate := false
|
||||
|
||||
// 检查是否包含需要迁移的旧 key
|
||||
for _, tab := range config.AvailableTabs {
|
||||
if tab.Key == "device" {
|
||||
hasDevice = true
|
||||
if tab.Key == "device" || tab.Key == "openclaw-manager" {
|
||||
needMigrate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDevice {
|
||||
if !needMigrate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 过滤掉 device
|
||||
// 映射:旧 key → 新 key(不需要的移除)
|
||||
keyMap := map[string]string{
|
||||
"openclaw-manager": "version",
|
||||
// "device": "" // 直接过滤
|
||||
}
|
||||
|
||||
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
||||
newVisible := make([]string, 0, len(config.VisibleTabs))
|
||||
seenKeys := map[string]bool{}
|
||||
|
||||
for _, tab := range config.AvailableTabs {
|
||||
if tab.Key != "device" {
|
||||
newKey, shouldRename := keyMap[tab.Key]
|
||||
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)
|
||||
}
|
||||
}
|
||||
for _, key := range config.VisibleTabs {
|
||||
if key != "device" {
|
||||
if newKey, ok := keyMap[key]; ok {
|
||||
if newKey != "" && !seenKeys[newKey] {
|
||||
newVisible = append(newVisible, newKey)
|
||||
}
|
||||
// newKey == "" 时跳过(如 device)
|
||||
} else {
|
||||
newVisible = append(newVisible, key)
|
||||
}
|
||||
}
|
||||
|
||||
defaultTab := config.DefaultTab
|
||||
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
|
||||
defaultTab = newKey
|
||||
}
|
||||
if defaultTab == "device" {
|
||||
defaultTab = "file-system"
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// ConnectionAPI 连接管理API
|
||||
type ConnectionAPI struct {
|
||||
connService *service.ConnectionService
|
||||
}
|
||||
|
||||
// NewConnectionAPI 创建连接管理API
|
||||
func NewConnectionAPI() (*ConnectionAPI, error) {
|
||||
connService, err := service.NewConnectionService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ConnectionAPI{connService}, nil
|
||||
}
|
||||
|
||||
// SaveConnectionRequest 保存连接请求结构体
|
||||
type SaveConnectionRequest struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
VisibleDatabases string `json:"visible_databases"`
|
||||
}
|
||||
|
||||
// SaveDbConnection 保存数据库连接配置
|
||||
func (api *ConnectionAPI) SaveDbConnection(req SaveConnectionRequest) error {
|
||||
conn := &models.DbConnection{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Database: req.Database,
|
||||
Options: req.Options,
|
||||
VisibleDatabases: req.VisibleDatabases,
|
||||
}
|
||||
return api.connService.SaveConnection(conn)
|
||||
}
|
||||
|
||||
// ListDbConnections 获取连接列表
|
||||
func (api *ConnectionAPI) ListDbConnections() ([]map[string]interface{}, error) {
|
||||
connections, err := api.connService.ListConnections()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, len(connections))
|
||||
timeFormat := "2006-01-02 15:04:05"
|
||||
for i, conn := range connections {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": conn.ID,
|
||||
"name": conn.Name,
|
||||
"type": conn.Type,
|
||||
"host": conn.Host,
|
||||
"port": conn.Port,
|
||||
"username": conn.Username,
|
||||
"database": conn.Database,
|
||||
"options": conn.Options,
|
||||
"visible_databases": conn.VisibleDatabases,
|
||||
"created_at": conn.CreatedAt.Format(timeFormat),
|
||||
"updated_at": conn.UpdatedAt.Format(timeFormat),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (api *ConnectionAPI) DeleteDbConnection(id uint) error {
|
||||
return api.connService.DeleteConnection(id)
|
||||
}
|
||||
|
||||
func (api *ConnectionAPI) TestDbConnection(id uint) error {
|
||||
return api.connService.TestConnection(id)
|
||||
}
|
||||
|
||||
// TestConnectionRequest 测试连接请求结构体(不保存数据)
|
||||
type TestConnectionRequest struct {
|
||||
ID uint `json:"id"` // 编辑模式下的连接ID(用于获取已保存的密码)
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
|
||||
func (api *ConnectionAPI) TestDbConnectionWithParams(req TestConnectionRequest) error {
|
||||
return api.connService.TestConnectionWithParams(
|
||||
req.Type, req.Host, req.Port,
|
||||
req.Username, req.Password, req.Database,
|
||||
req.Options, req.ID,
|
||||
)
|
||||
}
|
||||
|
||||
// LoadAllDatabasesRequest 加载全部数据库请求结构体
|
||||
type LoadAllDatabasesRequest struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
// LoadAllDatabases 加载全部数据库列表
|
||||
func (api *ConnectionAPI) LoadAllDatabases(req LoadAllDatabasesRequest) ([]string, error) {
|
||||
return api.connService.LoadAllDatabases(
|
||||
req.Type, req.Host, req.Port,
|
||||
req.Username, req.Password, req.Database,
|
||||
req.Options, req.ID,
|
||||
)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
)
|
||||
|
||||
type SqlAPI struct {
|
||||
sqlService *service.SqlExecService
|
||||
resultRepo repository.ResultRepository
|
||||
}
|
||||
|
||||
func NewSqlAPI() (*SqlAPI, error) {
|
||||
sqlService, err := service.NewSqlExecService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resultRepo, err := repository.NewResultRepository()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SqlAPI{sqlService, resultRepo}, nil
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行SQL语句
|
||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||
func (api *SqlAPI) ExecuteSQL(connectionID uint, sqlStr string, database string) (map[string]interface{}, error) {
|
||||
result, err := api.sqlService.ExecuteSQL(connectionID, sqlStr, database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"type": result.Type,
|
||||
"data": result.Data,
|
||||
"rowsAffected": result.RowsAffected,
|
||||
"executionTime": result.ExecutionTime,
|
||||
}
|
||||
// 如果是查询,添加列顺序信息
|
||||
if result.Type == "query" && len(result.Columns) > 0 {
|
||||
response["columns"] = result.Columns
|
||||
}
|
||||
|
||||
// 自动保存结果到历史记录(异步执行)
|
||||
go func() {
|
||||
api.resultRepo.Save(connectionID, database, sqlStr, result.Type, result.Data, result.Columns, result.RowsAffected, result.ExecutionTime)
|
||||
}()
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetDatabases(connectionID uint) ([]string, error) {
|
||||
return api.sqlService.GetDatabases(connectionID)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetTables(connectionID uint, database string) ([]string, error) {
|
||||
return api.sqlService.GetTables(connectionID, database)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetTableStructure(connectionID uint, database, tableName string) (map[string]interface{}, error) {
|
||||
return api.sqlService.GetTableStructure(connectionID, database, tableName)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetIndexes(connectionID uint, database, tableName string) ([]map[string]interface{}, error) {
|
||||
return api.sqlService.GetIndexes(connectionID, database, tableName)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) PreviewTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return api.sqlService.PreviewTableStructure(connectionID, database, tableName, structure)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) UpdateTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
return api.sqlService.UpdateTableStructure(connectionID, database, tableName, structure)
|
||||
}
|
||||
|
||||
func (api *SqlAPI) SaveResult(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (map[string]interface{}, error) {
|
||||
history, err := api.resultRepo.Save(connectionID, database, sql, resultType, data, columns, rowsAffected, executionTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return historyToMap(history), nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetResultHistory(connectionID *uint, keyword string, limit, offset int) (map[string]interface{}, error) {
|
||||
histories, total, err := api.resultRepo.Search(connectionID, keyword, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, len(histories))
|
||||
for i, h := range histories {
|
||||
items[i] = historyToMap(&h)
|
||||
}
|
||||
|
||||
return map[string]interface{}{"items": items, "total": total}, nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) GetResultHistoryByID(id uint) (map[string]interface{}, error) {
|
||||
history, err := api.resultRepo.FindByID(id)
|
||||
if err != nil || history == nil {
|
||||
return nil, err
|
||||
}
|
||||
return historyToMap(history), nil
|
||||
}
|
||||
|
||||
func (api *SqlAPI) DeleteResultHistory(id uint) error {
|
||||
return api.resultRepo.Delete(id)
|
||||
}
|
||||
|
||||
func historyToMap(history *models.SqlResultHistory) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"id": history.ID,
|
||||
"connection_id": history.ConnectionID,
|
||||
"database": history.Database,
|
||||
"sql": history.Sql,
|
||||
"type": history.Type,
|
||||
"rows_affected": history.RowsAffected,
|
||||
"execution_time": history.ExecutionTime,
|
||||
"created_at": history.CreatedAt,
|
||||
}
|
||||
|
||||
if history.Data != "" {
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(history.Data), &data)
|
||||
result["data"] = data
|
||||
}
|
||||
|
||||
if history.Columns != "" {
|
||||
var columns []string
|
||||
json.Unmarshal([]byte(history.Columns), &columns)
|
||||
result["columns"] = columns
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"u-desk/internal/service"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// TabAPI 标签页API
|
||||
type TabAPI struct {
|
||||
tabService *service.TabService
|
||||
}
|
||||
|
||||
// NewTabAPI 创建标签页API
|
||||
func NewTabAPI() (*TabAPI, error) {
|
||||
tabService, err := service.NewTabService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TabAPI{tabService: tabService}, nil
|
||||
}
|
||||
|
||||
// SaveSqlTabs 保存SQL标签页列表(接收 map 格式,转换为模型)
|
||||
func (api *TabAPI) SaveSqlTabs(tabs []map[string]interface{}) error {
|
||||
sqlTabs := make([]models.SqlTab, len(tabs))
|
||||
for idx, tabData := range tabs {
|
||||
tab := models.SqlTab{
|
||||
Order: idx,
|
||||
}
|
||||
|
||||
// 处理 ID
|
||||
if id, ok := tabData["id"].(float64); ok && id > 0 {
|
||||
tab.ID = uint(id)
|
||||
}
|
||||
|
||||
// 处理标题
|
||||
if title, ok := tabData["title"].(string); ok {
|
||||
tab.Title = title
|
||||
} else {
|
||||
tab.Title = fmt.Sprintf("查询 %d", idx+1)
|
||||
}
|
||||
|
||||
// 处理内容
|
||||
if content, ok := tabData["content"].(string); ok {
|
||||
tab.Content = content
|
||||
}
|
||||
|
||||
// 处理连接ID
|
||||
if connId, ok := tabData["connectionId"].(float64); ok && connId > 0 {
|
||||
connID := uint(connId)
|
||||
tab.ConnectionID = &connID
|
||||
}
|
||||
|
||||
sqlTabs[idx] = tab
|
||||
}
|
||||
return api.tabService.SaveTabs(sqlTabs)
|
||||
}
|
||||
|
||||
// ListSqlTabs 获取SQL标签页列表(返回 map 格式)
|
||||
func (api *TabAPI) ListSqlTabs() ([]map[string]interface{}, error) {
|
||||
tabs, err := api.tabService.ListTabs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, len(tabs))
|
||||
for i, tab := range tabs {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": tab.ID,
|
||||
"title": tab.Title,
|
||||
"content": tab.Content,
|
||||
"connectionId": tab.ConnectionID,
|
||||
"order": tab.Order,
|
||||
"createdAt": tab.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updatedAt": tab.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package common
|
||||
|
||||
// Default visible tabs configuration
|
||||
const (
|
||||
// TabDatabase 数据库管理 Tab
|
||||
TabDatabase = "db-cli"
|
||||
// TabFileSystem 文件系统 Tab
|
||||
TabFileSystem = "file-system"
|
||||
// TabDevice 设备测试 Tab
|
||||
@@ -11,4 +9,4 @@ const (
|
||||
)
|
||||
|
||||
// DefaultVisibleTabs 默认可见的 Tabs
|
||||
var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}
|
||||
var DefaultVisibleTabs = []string{TabFileSystem, TabDevice}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
// 数据库操作超时配置
|
||||
const (
|
||||
TimeoutPing = 2 * time.Second // 连接测试超时
|
||||
TimeoutConnect = 5 * time.Second // 初始连接超时
|
||||
TimeoutFastQuery = 10 * time.Second // 元数据查询超时
|
||||
TimeoutQuery = 30 * time.Second // 普通查询超时
|
||||
TimeoutLongOp = 60 * time.Second // 长时间操作超时
|
||||
)
|
||||
@@ -1,175 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 旧版硬编码密钥(用于兼容迁移已有加密数据)
|
||||
var legacyKey = []byte("go-desk-db-cli-key-32bytes123456")
|
||||
|
||||
var (
|
||||
encryptionKey []byte
|
||||
keyOnce sync.Once
|
||||
keyInitErr error
|
||||
)
|
||||
|
||||
// getKey 获取或创建机器唯一密钥
|
||||
// 首次启动时生成并持久化到用户配置目录,后续直接读取
|
||||
func getKey() ([]byte, error) {
|
||||
keyOnce.Do(func() {
|
||||
keyFile, err := getKeyFilePath()
|
||||
if err != nil {
|
||||
keyInitErr = fmt.Errorf("获取密钥路径失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试读取已有密钥
|
||||
if data, err := os.ReadFile(keyFile); err == nil && len(data) == 32 {
|
||||
encryptionKey = data
|
||||
return
|
||||
}
|
||||
|
||||
// 生成新密钥
|
||||
newKey := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, newKey); err != nil {
|
||||
keyInitErr = fmt.Errorf("生成密钥失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 持久化密钥
|
||||
dir := filepath.Dir(keyFile)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
keyInitErr = fmt.Errorf("创建密钥目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyFile, newKey, 0600); err != nil {
|
||||
keyInitErr = fmt.Errorf("保存密钥失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
encryptionKey = newKey
|
||||
})
|
||||
|
||||
return encryptionKey, keyInitErr
|
||||
}
|
||||
|
||||
// getKeyFilePath 返回密钥文件路径
|
||||
func getKeyFilePath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(configDir, "u-desk", ".aes-key"), nil
|
||||
}
|
||||
|
||||
// DecryptPasswordV2 使用指定密钥解密(用于密钥迁移)
|
||||
func DecryptPasswordV2(encryptedPassword string, key []byte) (string, error) {
|
||||
if encryptedPassword == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(encryptedPassword) < 10 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码失败: %v", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建解密器失败: %v", err)
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 GCM 失败: %v", err)
|
||||
}
|
||||
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return "", fmt.Errorf("密文长度不足")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
|
||||
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解密失败: %v", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// EncryptPassword 加密密码
|
||||
func EncryptPassword(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key, err := getKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取加密密钥失败: %v", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建加密器失败: %v", err)
|
||||
}
|
||||
|
||||
// 使用 GCM 模式
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 GCM 失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成随机 nonce
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("生成 nonce 失败: %v", err)
|
||||
}
|
||||
|
||||
// 加密
|
||||
ciphertext := aesGCM.Seal(nonce, nonce, []byte(password), nil)
|
||||
|
||||
// Base64 编码
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// DecryptPassword 解密密码(自动回退旧密钥兼容旧数据)
|
||||
func DecryptPassword(encryptedPassword string) (string, error) {
|
||||
if encryptedPassword == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(encryptedPassword) < 10 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key, err := getKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取解密密钥失败: %v", err)
|
||||
}
|
||||
|
||||
// 先用新密钥尝试解密
|
||||
result, err := DecryptPasswordV2(encryptedPassword, key)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 新密钥失败,尝试旧密钥(兼容已迁移的旧数据)
|
||||
result, err = DecryptPasswordV2(encryptedPassword, legacyKey)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 两种密钥都失败
|
||||
return "", fmt.Errorf("解密失败: %v", err)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"u-desk/internal/model"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConnected = errors.New("数据库未连接")
|
||||
)
|
||||
|
||||
// DB 数据库连接封装
|
||||
type DB struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var globalDB *DB
|
||||
|
||||
// Init 初始化数据库连接
|
||||
func Init() (*DB, error) {
|
||||
if globalDB != nil {
|
||||
return globalDB, nil
|
||||
}
|
||||
|
||||
// 数据库配置 - 测试服 lab_dev
|
||||
// 测试机外网IP: 39.99.243.191
|
||||
// 使用 mysqldriver.Config 结构体构建 DSN,自动处理密码中的特殊字符
|
||||
config := mysqldriver.Config{
|
||||
User: "root",
|
||||
Passwd: "123456",
|
||||
Net: "tcp",
|
||||
Addr: "127.0.0.1:3306",
|
||||
DBName: "lab_dev",
|
||||
Params: map[string]string{"charset": "utf8mb4", "parseTime": "True", "loc": "Local"},
|
||||
AllowNativePasswords: true,
|
||||
}
|
||||
dsn := config.FormatDSN()
|
||||
|
||||
// GORM 配置
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库连接失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取底层 sql.DB 设置连接池参数
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取数据库实例失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置连接池参数
|
||||
sqlDB.SetMaxOpenConns(25)
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(300) * time.Second)
|
||||
|
||||
globalDB = &DB{db: db}
|
||||
return globalDB, nil
|
||||
}
|
||||
|
||||
// QueryUsers 查询用户列表
|
||||
func (d *DB) QueryUsers(keyword string, status int, role int, organid int, page int, pageSize int, sortField string, sortOrder string) (map[string]interface{}, error) {
|
||||
if d.db == nil {
|
||||
return nil, ErrNotConnected
|
||||
}
|
||||
|
||||
query := d.db.Model(&model.MemberInfo{})
|
||||
|
||||
// 关键字搜索(姓名、账号、电话)
|
||||
if keyword != "" {
|
||||
query = query.Where("membername LIKE ? OR account LIKE ? OR contactphone LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
} else {
|
||||
// 默认过滤删除状态
|
||||
query = query.Where("status != ?", 3)
|
||||
}
|
||||
|
||||
// 角色筛选(需要关联查询,暂时简化)
|
||||
if role > 0 {
|
||||
// TODO: 关联 sys_member_role 表查询
|
||||
}
|
||||
|
||||
// 机构筛选
|
||||
if organid > 0 {
|
||||
query = query.Where("organid = ?", organid)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if sortField != "" {
|
||||
if sortOrder == "descend" || sortOrder == "desc" {
|
||||
query = query.Order(sortField + " DESC")
|
||||
} else {
|
||||
query = query.Order(sortField + " ASC")
|
||||
}
|
||||
} else {
|
||||
// 默认按创建时间倒序
|
||||
query = query.Order("createtime DESC")
|
||||
}
|
||||
|
||||
// 总数
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 分页
|
||||
offset := (page - 1) * pageSize
|
||||
var users []model.MemberInfo
|
||||
if err := query.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("查询用户失败: %v", err)
|
||||
}
|
||||
|
||||
// 返回结果
|
||||
result := map[string]interface{}{
|
||||
"rows": users,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QueryCache 查询缓存
|
||||
type QueryCache struct {
|
||||
items map[string]*CachedQuery
|
||||
size int
|
||||
ttl time.Duration
|
||||
mu sync.RWMutex
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// 智能缓存策略
|
||||
hitRate float64 // 缓存命中率
|
||||
hitCount int64 // 命中次数
|
||||
missCount int64 // 未命中次数
|
||||
evictionCount int64 // 驱逐次数
|
||||
hotQueries map[string]bool // 热点查询标记
|
||||
cooldowns map[string]time.Time // 冷却时间(避免频繁驱逐)
|
||||
|
||||
// 内存限制
|
||||
maxMemoryBytes int64 // 缓存最大内存(字节),默认 100MB
|
||||
usedMemory int64 // 当前估算内存使用量
|
||||
}
|
||||
|
||||
// NewQueryCache 创建新的查询缓存
|
||||
func NewQueryCache(size int, ttl time.Duration) *QueryCache {
|
||||
cache := &QueryCache{
|
||||
items: make(map[string]*CachedQuery),
|
||||
size: size,
|
||||
ttl: ttl,
|
||||
stopCh: make(chan struct{}),
|
||||
hitRate: 0.0,
|
||||
hitCount: 0,
|
||||
missCount: 0,
|
||||
evictionCount: 0,
|
||||
hotQueries: make(map[string]bool),
|
||||
cooldowns: make(map[string]time.Time),
|
||||
maxMemoryBytes: 100 * 1024 * 1024, // 默认 100MB
|
||||
}
|
||||
|
||||
// 启动清理协程
|
||||
cache.StartCleanup()
|
||||
|
||||
// 启动统计协程
|
||||
cache.StartStatsCollection()
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
// Get 从缓存中获取查询结果
|
||||
func (c *QueryCache) Get(params QueryParams) (*CachedQuery, error) {
|
||||
key := c.generateKey(params)
|
||||
|
||||
c.mu.RLock()
|
||||
item, exists := c.items[key]
|
||||
if !exists {
|
||||
c.missCount++
|
||||
_, inCooldown := c.cooldowns[key]
|
||||
if inCooldown && time.Now().Before(c.cooldowns[key]) {
|
||||
c.mu.RUnlock()
|
||||
return nil, ErrCacheCooldown
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
return nil, ErrCacheNotFound
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().After(item.ExpiryTime) {
|
||||
if c.isHotQuery(key) {
|
||||
c.mu.RUnlock()
|
||||
c.mu.Lock()
|
||||
item.ExpiryTime = time.Now().Add(c.ttl)
|
||||
c.hitCount++
|
||||
c.markAsHot(key)
|
||||
c.mu.Unlock()
|
||||
return item, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
c.mu.Lock()
|
||||
delete(c.items, key)
|
||||
c.evictionCount++
|
||||
c.missCount++
|
||||
c.mu.Unlock()
|
||||
return nil, ErrCacheExpired
|
||||
}
|
||||
|
||||
// 命中
|
||||
c.hitCount++
|
||||
needsMark := !c.hotQueries[key]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if needsMark {
|
||||
c.mu.Lock()
|
||||
c.markAsHot(key)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Set 将查询结果存入缓存
|
||||
func (c *QueryCache) Set(params QueryParams, item *CachedQuery) {
|
||||
key := c.generateKey(params)
|
||||
|
||||
// 估算条目内存大小
|
||||
itemSize := c.estimateSize(params, item)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// 更新统计
|
||||
c.recordQueryAttempt(key)
|
||||
|
||||
// 如果超过内存限制,执行驱逐直到有空间
|
||||
for c.usedMemory+itemSize > c.maxMemoryBytes && len(c.items) > 0 {
|
||||
c.smartEvict(key)
|
||||
}
|
||||
|
||||
// 如果条目数已满,执行智能驱逐
|
||||
if len(c.items) >= c.size {
|
||||
c.smartEvict(key)
|
||||
}
|
||||
|
||||
// 如果已有旧条目,先减去旧的大小
|
||||
if old, exists := c.items[key]; exists {
|
||||
c.usedMemory -= c.estimateItemSize(old)
|
||||
}
|
||||
|
||||
c.items[key] = item
|
||||
c.usedMemory += itemSize
|
||||
|
||||
// 标记为热点查询
|
||||
c.markAsHot(key)
|
||||
}
|
||||
|
||||
// smartEvict 智能驱逐策略
|
||||
func (c *QueryCache) smartEvict(newKey string) {
|
||||
if len(c.items) == 0 {
|
||||
return
|
||||
}
|
||||
// LRU + LFU 混合策略
|
||||
var evictKey string
|
||||
var worstScore float64 = -1
|
||||
|
||||
for key, item := range c.items {
|
||||
if key == newKey {
|
||||
continue
|
||||
}
|
||||
|
||||
score := c.calculateEvictionScore(key, item)
|
||||
if score > worstScore {
|
||||
worstScore = score
|
||||
evictKey = key
|
||||
}
|
||||
}
|
||||
|
||||
if evictKey != "" {
|
||||
if evicted, exists := c.items[evictKey]; exists {
|
||||
c.usedMemory -= c.estimateItemSize(evicted)
|
||||
}
|
||||
c.cooldowns[evictKey] = time.Now().Add(1 * time.Minute)
|
||||
delete(c.items, evictKey)
|
||||
c.evictionCount++
|
||||
}
|
||||
}
|
||||
|
||||
// calculateEvictionScore 计算驱逐分数(越低越适合保留)
|
||||
func (c *QueryCache) calculateEvictionScore(key string, item *CachedQuery) float64 {
|
||||
now := time.Now()
|
||||
|
||||
// 基础分数
|
||||
score := 1.0
|
||||
|
||||
// 热点查询加分(优先保留)
|
||||
if c.isHotQuery(key) {
|
||||
score -= 0.5
|
||||
}
|
||||
|
||||
// 接近过期的加分(优先驱逐即将过期的)
|
||||
if item.ExpiryTime.Sub(now) < c.ttl/2 {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
// 最近使用的加分(优先保留最近使用的)
|
||||
if !item.LastUsed.IsZero() {
|
||||
recency := now.Sub(item.LastUsed)
|
||||
if recency < 5*time.Minute {
|
||||
score -= 0.2
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// isHotQuery 检查是否为热点查询
|
||||
func (c *QueryCache) isHotQuery(key string) bool {
|
||||
return c.hotQueries[key]
|
||||
}
|
||||
|
||||
// markAsHot 标记为热点查询
|
||||
func (c *QueryCache) markAsHot(key string) {
|
||||
c.hotQueries[key] = true
|
||||
}
|
||||
|
||||
// cleanupHotMarkers 清理热点标记
|
||||
func (c *QueryCache) cleanupHotMarkers() {
|
||||
now := time.Now()
|
||||
for key := range c.hotQueries {
|
||||
// 清理超过10分钟未使用的热点标记
|
||||
if item, exists := c.items[key]; exists {
|
||||
if now.Sub(item.LastUsed) > 10*time.Minute {
|
||||
delete(c.hotQueries, key)
|
||||
}
|
||||
} else {
|
||||
delete(c.hotQueries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordQueryAttempt 记录查询尝试
|
||||
func (c *QueryCache) recordQueryAttempt(key string) {
|
||||
// 更新命中率
|
||||
c.updateHitRate()
|
||||
|
||||
// 更新最后使用时间
|
||||
if item, exists := c.items[key]; exists {
|
||||
item.LastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// updateHitRate 更新命中率
|
||||
func (c *QueryCache) updateHitRate() {
|
||||
total := c.hitCount + c.missCount
|
||||
if total > 0 {
|
||||
c.hitRate = float64(c.hitCount) / float64(total)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete 从缓存中删除指定查询
|
||||
func (c *QueryCache) Delete(params QueryParams) {
|
||||
key := c.generateKey(params)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if item, exists := c.items[key]; exists {
|
||||
c.usedMemory -= c.estimateItemSize(item)
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear 清空整个缓存
|
||||
func (c *QueryCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.items = make(map[string]*CachedQuery)
|
||||
c.usedMemory = 0
|
||||
}
|
||||
|
||||
// Size 获取缓存大小
|
||||
func (c *QueryCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return len(c.items)
|
||||
}
|
||||
|
||||
// CleanupExpired 清理过期的缓存条目
|
||||
func (c *QueryCache) CleanupExpired() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, item := range c.items {
|
||||
if now.After(item.ExpiryTime) {
|
||||
c.usedMemory -= c.estimateItemSize(item)
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keys 获取缓存中所有的键
|
||||
func (c *QueryCache) Keys() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
keys := make([]string, 0, len(c.items))
|
||||
for key := range c.items {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Stats 获取缓存统计信息
|
||||
func (c *QueryCache) Stats() CacheStats {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
expired := 0
|
||||
active := 0
|
||||
|
||||
for _, item := range c.items {
|
||||
if now.After(item.ExpiryTime) {
|
||||
expired++
|
||||
} else {
|
||||
active++
|
||||
}
|
||||
}
|
||||
|
||||
return CacheStats{
|
||||
TotalItems: len(c.items),
|
||||
ActiveItems: active,
|
||||
ExpiredItems: expired,
|
||||
Size: c.size,
|
||||
TTL: c.ttl,
|
||||
HitRate: c.hitRate,
|
||||
HitCount: c.hitCount,
|
||||
MissCount: c.missCount,
|
||||
EvictionCount: c.evictionCount,
|
||||
HotQueries: len(c.hotQueries),
|
||||
}
|
||||
}
|
||||
|
||||
// generateKey 生成缓存键
|
||||
func (c *QueryCache) generateKey(params QueryParams) string {
|
||||
key := fmt.Sprintf("%s|%s|%d|%d|%s|%s|%s|%v",
|
||||
params.SQL, params.Database, params.Limit, params.Offset,
|
||||
params.Table, params.Where, params.SortBy, params.IsReadOnly)
|
||||
h := sha256.Sum256([]byte(key))
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
|
||||
// evictOldest 删除最老的缓存条目
|
||||
func (c *QueryCache) evictOldest() {
|
||||
var oldestKey string
|
||||
var oldestTime time.Time
|
||||
|
||||
for key, item := range c.items {
|
||||
if oldestKey == "" || item.CreatedAt.Before(oldestTime) {
|
||||
oldestKey = key
|
||||
oldestTime = item.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if oldestKey != "" {
|
||||
delete(c.items, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
// StartCleanup 启动清理协程
|
||||
func (c *QueryCache) StartCleanup() {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(c.ttl / 2) // 每 TTL/2 时间检查一次
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.CleanupExpired()
|
||||
c.cleanupCooldowns() // 清理冷却时间
|
||||
case <-c.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StartStatsCollection 启动统计收集协程
|
||||
func (c *QueryCache) StartStatsCollection() {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute) // 每分钟收集一次统计
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.updateHitRate()
|
||||
c.cleanupHotMarkers()
|
||||
case <-c.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// cleanupCooldowns 清理冷却时间
|
||||
func (c *QueryCache) cleanupCooldowns() {
|
||||
now := time.Now()
|
||||
for key, cooldown := range c.cooldowns {
|
||||
if now.After(cooldown) {
|
||||
delete(c.cooldowns, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止缓存清理
|
||||
func (c *QueryCache) Stop() {
|
||||
close(c.stopCh)
|
||||
c.wg.Wait()
|
||||
}
|
||||
|
||||
// CacheStats 缓存统计信息
|
||||
type CacheStats struct {
|
||||
TotalItems int
|
||||
ActiveItems int
|
||||
ExpiredItems int
|
||||
Size int
|
||||
TTL time.Duration
|
||||
HitRate float64
|
||||
HitCount int64
|
||||
MissCount int64
|
||||
EvictionCount int64
|
||||
HotQueries int
|
||||
}
|
||||
|
||||
// 缓存错误定义
|
||||
var (
|
||||
ErrCacheNotFound = &CacheError{Message: "缓存未找到"}
|
||||
ErrCacheExpired = &CacheError{Message: "缓存已过期"}
|
||||
ErrCacheCooldown = &CacheError{Message: "查询在冷却中"}
|
||||
)
|
||||
|
||||
// CacheError 缓存错误
|
||||
type CacheError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *CacheError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// estimateSize 估算缓存条目的内存大小(字节)
|
||||
func (c *QueryCache) estimateSize(params QueryParams, item *CachedQuery) int64 {
|
||||
size := int64(len(params.SQL) + len(params.Database) + len(params.Table) +
|
||||
len(params.Where) + len(params.SortBy))
|
||||
if item != nil && item.Result != nil {
|
||||
size += c.estimateItemSize(item)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// estimateItemSize 估算 CachedQuery 的内存大小
|
||||
func (c *QueryCache) estimateItemSize(item *CachedQuery) int64 {
|
||||
if item == nil || item.Result == nil {
|
||||
return 128 // 基础结构体大小
|
||||
}
|
||||
size := int64(128) // CachedQuery 结构体基础大小
|
||||
for _, row := range item.Result.Data {
|
||||
for _, v := range row {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
size += int64(len(val))
|
||||
case []byte:
|
||||
size += int64(len(val))
|
||||
case nil:
|
||||
// 无额外开销
|
||||
default:
|
||||
size += 64 // 其他类型的估算值
|
||||
}
|
||||
}
|
||||
}
|
||||
size += int64(len(item.Result.Columns)) * 64 // 列名估算
|
||||
return size
|
||||
}
|
||||
@@ -1,825 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"u-desk/internal/common"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// MongoClient MongoDB 客户端
|
||||
type MongoClient struct {
|
||||
client *mongo.Client
|
||||
database *mongo.Database
|
||||
config *MongoConfig
|
||||
}
|
||||
|
||||
// MongoConfig MongoDB 配置
|
||||
type MongoConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
AuthSource string // 认证数据库,默认为 "admin"
|
||||
AuthMechanism string // 认证机制,如 "SCRAM-SHA-1", "SCRAM-SHA-256" 等
|
||||
}
|
||||
|
||||
// NewMongoClient 创建 MongoDB 客户端
|
||||
func NewMongoClient(config *MongoConfig) (*MongoClient, error) {
|
||||
// 确定认证数据库,默认为 admin
|
||||
authSource := config.AuthSource
|
||||
if authSource == "" {
|
||||
authSource = "admin"
|
||||
}
|
||||
|
||||
// 如果指定了认证机制,直接使用;否则尝试自动检测
|
||||
authMechanisms := []string{}
|
||||
if config.AuthMechanism != "" {
|
||||
// 用户明确指定了认证机制,只使用该机制
|
||||
authMechanisms = []string{config.AuthMechanism}
|
||||
} else {
|
||||
// 未指定时,先尝试 SCRAM-SHA-256(更安全),失败则尝试 SCRAM-SHA-1
|
||||
authMechanisms = []string{"SCRAM-SHA-256", "SCRAM-SHA-1"}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, authMechanism := range authMechanisms {
|
||||
client, err := tryConnectMongo(config, authSource, authMechanism)
|
||||
if err == nil {
|
||||
return client, nil
|
||||
}
|
||||
lastErr = err
|
||||
// 如果明确指定了认证机制,失败后不再尝试其他机制
|
||||
if config.AuthMechanism != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 所有认证机制都失败
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("MongoDB 连接测试失败: %v", lastErr)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("MongoDB 连接失败: 未知错误")
|
||||
}
|
||||
|
||||
// tryConnectMongo 尝试使用指定的认证机制连接 MongoDB
|
||||
func tryConnectMongo(config *MongoConfig, authSource, authMechanism string) (*MongoClient, error) {
|
||||
// 构建连接 URI
|
||||
var uri string
|
||||
|
||||
if config.Username != "" && config.Password != "" {
|
||||
// 使用 url.UserPassword 正确转义用户名和密码中的特殊字符
|
||||
// 这会正确处理 @、:、/ 等特殊字符
|
||||
userInfo := url.UserPassword(config.Username, config.Password)
|
||||
|
||||
// 构建基础 URI
|
||||
uri = fmt.Sprintf("mongodb://%s@%s:%d", userInfo.String(), config.Host, config.Port)
|
||||
|
||||
// 添加数据库和认证源参数
|
||||
params := url.Values{}
|
||||
params.Set("authSource", authSource)
|
||||
|
||||
// 添加认证机制参数
|
||||
if authMechanism != "" {
|
||||
params.Set("authMechanism", authMechanism)
|
||||
}
|
||||
|
||||
// 如果有业务数据库,添加到路径中
|
||||
if config.Database != "" {
|
||||
uri = fmt.Sprintf("%s/%s?%s", uri, config.Database, params.Encode())
|
||||
} else {
|
||||
// MongoDB URI 要求查询参数前必须有 /,即使没有数据库名
|
||||
uri = fmt.Sprintf("%s/?%s", uri, params.Encode())
|
||||
}
|
||||
} else if config.Database != "" {
|
||||
// 没有认证信息时,数据库部分用于指定默认数据库
|
||||
uri = fmt.Sprintf("mongodb://%s:%d/%s", config.Host, config.Port, config.Database)
|
||||
} else {
|
||||
uri = fmt.Sprintf("mongodb://%s:%d", config.Host, config.Port)
|
||||
}
|
||||
|
||||
// 客户端选项
|
||||
clientOptions := options.Client().
|
||||
ApplyURI(uri).
|
||||
SetConnectTimeout(common.TimeoutConnect).
|
||||
SetServerSelectionTimeout(common.TimeoutConnect)
|
||||
|
||||
// 创建客户端 (v2: 移除了 context 参数)
|
||||
client, err := mongo.Connect(clientOptions)
|
||||
|
||||
// 创建 context 用于其他操作
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutConnect)
|
||||
defer cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 MongoDB 失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := client.Ping(ctx, nil); err != nil {
|
||||
client.Disconnect(ctx)
|
||||
return nil, fmt.Errorf("MongoDB 连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
var database *mongo.Database
|
||||
if config.Database != "" {
|
||||
database = client.Database(config.Database)
|
||||
}
|
||||
|
||||
return &MongoClient{
|
||||
client: client,
|
||||
database: database,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestMongoConnection 测试连接
|
||||
func TestMongoConnection(host string, port int, username, password, database string) error {
|
||||
return TestMongoConnectionWithAuthSource(host, port, username, password, database, "")
|
||||
}
|
||||
|
||||
// TestMongoConnectionWithAuthSource 测试连接(支持指定认证数据库)
|
||||
func TestMongoConnectionWithAuthSource(host string, port int, username, password, database, authSource string) error {
|
||||
return TestMongoConnectionWithOptions(host, port, username, password, database, authSource, "")
|
||||
}
|
||||
|
||||
// TestMongoConnectionWithOptions 测试连接(支持指定认证数据库和认证机制)
|
||||
func TestMongoConnectionWithOptions(host string, port int, username, password, database, authSource, authMechanism string) error {
|
||||
config := &MongoConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: database,
|
||||
AuthSource: authSource,
|
||||
AuthMechanism: authMechanism,
|
||||
}
|
||||
client, err := NewMongoClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *MongoClient) Close() error {
|
||||
if c.client != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutConnect)
|
||||
defer cancel()
|
||||
return c.client.Disconnect(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDatabases 获取数据库列表
|
||||
func (c *MongoClient) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
databases, err := c.client.ListDatabaseNames(ctx, bson.M{})
|
||||
return databases, err
|
||||
}
|
||||
|
||||
// ListCollections 获取集合列表
|
||||
func (c *MongoClient) ListCollections(ctx context.Context, database string) ([]string, error) {
|
||||
db := c.client.Database(database)
|
||||
collections, err := db.ListCollectionNames(ctx, bson.M{})
|
||||
return collections, err
|
||||
}
|
||||
|
||||
// GetCollectionStructure 获取集合结构
|
||||
func (c *MongoClient) GetCollectionStructure(ctx context.Context, database, collectionName string) (map[string]interface{}, error) {
|
||||
coll := c.client.Database(database).Collection(collectionName)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"database": database,
|
||||
"collection": collectionName,
|
||||
"sampleDocs": []map[string]interface{}{},
|
||||
"fieldStats": map[string]int{},
|
||||
"indexes": []map[string]interface{}{},
|
||||
"documentCount": int64(0),
|
||||
}
|
||||
|
||||
// 获取文档示例(最多 5 个)
|
||||
opts := options.Find().SetLimit(5)
|
||||
cursor, err := coll.Find(ctx, bson.M{}, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取文档示例失败: %v", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var docs []bson.M
|
||||
if err = cursor.All(ctx, &docs); err != nil {
|
||||
return nil, fmt.Errorf("解析文档失败: %v", err)
|
||||
}
|
||||
|
||||
// 转换为 map
|
||||
sampleDocs := make([]map[string]interface{}, 0, len(docs))
|
||||
for _, doc := range docs {
|
||||
docMap := make(map[string]interface{})
|
||||
for k, v := range doc {
|
||||
docMap[k] = v
|
||||
}
|
||||
sampleDocs = append(sampleDocs, docMap)
|
||||
}
|
||||
result["sampleDocs"] = sampleDocs
|
||||
|
||||
// 字段统计:使用 $sample 聚合管道随机采样10个文档进行统计
|
||||
// 这样可以获得更准确的字段分布,同时保持良好性能
|
||||
// 使用异步方式执行,避免阻塞主流程
|
||||
sampleSize := 10
|
||||
pipeline := []bson.M{
|
||||
{"$sample": bson.M{"size": sampleSize}},
|
||||
{"$project": bson.M{"keys": bson.M{"$objectToArray": "$$ROOT"}}},
|
||||
{"$unwind": "$keys"},
|
||||
{"$group": bson.M{
|
||||
"_id": "$keys.k",
|
||||
"count": bson.M{"$sum": 1},
|
||||
}},
|
||||
{"$sort": bson.M{"count": -1}}, // 按出现次数降序排序
|
||||
}
|
||||
|
||||
sampleCursor, err := coll.Aggregate(ctx, pipeline)
|
||||
if err != nil {
|
||||
// 如果采样失败,回退到基于文档示例的统计
|
||||
fieldCount := make(map[string]int)
|
||||
for _, doc := range docs {
|
||||
for key := range doc {
|
||||
fieldCount[key]++
|
||||
}
|
||||
}
|
||||
result["fieldStats"] = fieldCount
|
||||
result["fieldStatsSampleSize"] = len(docs) // 记录实际采样数量
|
||||
result["fieldStatsMethod"] = "sample-docs" // 标记统计方式
|
||||
} else {
|
||||
defer sampleCursor.Close(ctx)
|
||||
fieldCount := make(map[string]int)
|
||||
for sampleCursor.Next(ctx) {
|
||||
var statResult bson.M
|
||||
if err := sampleCursor.Decode(&statResult); err != nil {
|
||||
continue
|
||||
}
|
||||
fieldName, ok := statResult["_id"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var count int
|
||||
switch v := statResult["count"].(type) {
|
||||
case int32:
|
||||
count = int(v)
|
||||
case int64:
|
||||
count = int(v)
|
||||
case int:
|
||||
count = v
|
||||
case float64:
|
||||
count = int(v)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
fieldCount[fieldName] = count
|
||||
}
|
||||
result["fieldStats"] = fieldCount
|
||||
result["fieldStatsSampleSize"] = sampleSize // 记录采样数量
|
||||
result["fieldStatsMethod"] = "sample-aggregate" // 标记统计方式
|
||||
}
|
||||
|
||||
// 文档总数(使用估算值,性能更好)
|
||||
// 对于大数据集,estimatedDocumentCount 比 CountDocuments 快得多
|
||||
// 如果需要精确值,可以使用 CountDocuments,但性能较差
|
||||
count, err := coll.EstimatedDocumentCount(ctx)
|
||||
if err != nil {
|
||||
// 如果估算失败,尝试精确计数(可能较慢)
|
||||
count, err = coll.CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取文档数量失败: %v", err)
|
||||
}
|
||||
}
|
||||
result["documentCount"] = count
|
||||
|
||||
// 索引信息
|
||||
indexCursor, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
// 索引查询失败不影响主流程
|
||||
result["indexes"] = []map[string]interface{}{}
|
||||
} else {
|
||||
var indexes []map[string]interface{}
|
||||
for indexCursor.Next(ctx) {
|
||||
var indexSpec bson.M
|
||||
if err := indexCursor.Decode(&indexSpec); err != nil {
|
||||
continue
|
||||
}
|
||||
indexes = append(indexes, map[string]interface{}{
|
||||
"name": indexSpec["name"],
|
||||
"unique": indexSpec["unique"],
|
||||
"keys": indexSpec["key"],
|
||||
})
|
||||
}
|
||||
indexCursor.Close(ctx)
|
||||
result["indexes"] = indexes
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExecuteQuery 执行查询
|
||||
func (c *MongoClient) ExecuteQuery(ctx context.Context, database, collection string, filter bson.M, limit int64) ([]map[string]interface{}, error) {
|
||||
db := c.client.Database(database)
|
||||
coll := db.Collection(collection)
|
||||
|
||||
opts := options.Find().SetLimit(limit)
|
||||
cursor, err := coll.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询失败: %v", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var results []map[string]interface{}
|
||||
if err := cursor.All(ctx, &results); err != nil {
|
||||
return nil, fmt.Errorf("读取结果失败: %v", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CountDocuments 获取文档数量
|
||||
func (c *MongoClient) CountDocuments(ctx context.Context, database, collection string, filter bson.M) (int64, error) {
|
||||
db := c.client.Database(database)
|
||||
coll := db.Collection(collection)
|
||||
return coll.CountDocuments(ctx, filter)
|
||||
}
|
||||
|
||||
// ExecuteCommand 执行 MongoDB 命令
|
||||
// command 可以是 JSON 格式的字符串,格式:{"op": "find", "database": "test", "collection": "users", "filter": {}, "limit": 100}
|
||||
// 支持的操作:find, count, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany
|
||||
func (c *MongoClient) ExecuteCommand(ctx context.Context, database string, command map[string]interface{}) (interface{}, error) {
|
||||
op, ok := command["op"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("命令中缺少 'op' 字段或格式错误")
|
||||
}
|
||||
|
||||
collectionName, ok := command["collection"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("命令中缺少 'collection' 字段或格式错误")
|
||||
}
|
||||
|
||||
// 如果没有指定数据库,使用配置中的默认数据库
|
||||
if database == "" {
|
||||
if c.config != nil && c.config.Database != "" {
|
||||
database = c.config.Database
|
||||
} else {
|
||||
return nil, fmt.Errorf("需要指定数据库名称")
|
||||
}
|
||||
}
|
||||
|
||||
db := c.client.Database(database)
|
||||
coll := db.Collection(collectionName)
|
||||
|
||||
switch op {
|
||||
case "find":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
}
|
||||
|
||||
limit := int64(100)
|
||||
if l, ok := command["limit"]; ok {
|
||||
if limitVal, ok := l.(float64); ok {
|
||||
limit = int64(limitVal)
|
||||
} else if limitVal, ok := l.(int64); ok {
|
||||
limit = limitVal
|
||||
}
|
||||
}
|
||||
|
||||
opts := options.Find().SetLimit(limit)
|
||||
cursor, err := coll.Find(ctx, filter, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询失败: %v", err)
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var results []map[string]interface{}
|
||||
if err := cursor.All(ctx, &results); err != nil {
|
||||
return nil, fmt.Errorf("读取结果失败: %v", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
|
||||
case "count":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := coll.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("统计失败: %v", err)
|
||||
}
|
||||
return count, nil
|
||||
|
||||
case "insertOne":
|
||||
document, ok := command["document"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("insertOne 操作需要 'document' 字段")
|
||||
}
|
||||
|
||||
doc := bson.M{}
|
||||
if docMap, ok := document.(map[string]interface{}); ok {
|
||||
doc = bson.M(docMap)
|
||||
} else {
|
||||
return nil, fmt.Errorf("document 必须是对象格式")
|
||||
}
|
||||
|
||||
result, err := coll.InsertOne(ctx, doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插入失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"insertedId": result.InsertedID,
|
||||
}, nil
|
||||
|
||||
case "insertMany":
|
||||
documents, ok := command["documents"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("insertMany 操作需要 'documents' 字段")
|
||||
}
|
||||
|
||||
docs := []interface{}{}
|
||||
if docsSlice, ok := documents.([]interface{}); ok {
|
||||
for _, d := range docsSlice {
|
||||
if docMap, ok := d.(map[string]interface{}); ok {
|
||||
docs = append(docs, bson.M(docMap))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("documents 必须是数组格式")
|
||||
}
|
||||
|
||||
result, err := coll.InsertMany(ctx, docs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量插入失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"insertedIds": result.InsertedIDs,
|
||||
"insertedCount": len(result.InsertedIDs),
|
||||
}, nil
|
||||
|
||||
case "updateOne":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("updateOne 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
update, ok := command["update"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("updateOne 操作需要 'update' 字段")
|
||||
}
|
||||
|
||||
updateDoc := bson.M{}
|
||||
if updateMap, ok := update.(map[string]interface{}); ok {
|
||||
updateDoc = bson.M(updateMap)
|
||||
} else {
|
||||
return nil, fmt.Errorf("update 必须是对象格式")
|
||||
}
|
||||
|
||||
result, err := coll.UpdateOne(ctx, filter, bson.M{"$set": updateDoc})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"matchedCount": result.MatchedCount,
|
||||
"modifiedCount": result.ModifiedCount,
|
||||
}, nil
|
||||
|
||||
case "updateMany":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("updateMany 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
update, ok := command["update"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("updateMany 操作需要 'update' 字段")
|
||||
}
|
||||
|
||||
updateDoc := bson.M{}
|
||||
if updateMap, ok := update.(map[string]interface{}); ok {
|
||||
updateDoc = bson.M(updateMap)
|
||||
} else {
|
||||
return nil, fmt.Errorf("update 必须是对象格式")
|
||||
}
|
||||
|
||||
result, err := coll.UpdateMany(ctx, filter, bson.M{"$set": updateDoc})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量更新失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"matchedCount": result.MatchedCount,
|
||||
"modifiedCount": result.ModifiedCount,
|
||||
}, nil
|
||||
|
||||
case "deleteOne":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("deleteOne 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
result, err := coll.DeleteOne(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("删除失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"deletedCount": result.DeletedCount,
|
||||
}, nil
|
||||
|
||||
case "deleteMany":
|
||||
filter := bson.M{}
|
||||
if f, ok := command["filter"]; ok {
|
||||
if filterMap, ok := f.(map[string]interface{}); ok {
|
||||
filter = bson.M(filterMap)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("deleteMany 操作需要 'filter' 字段")
|
||||
}
|
||||
|
||||
result, err := coll.DeleteMany(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量删除失败: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"deletedCount": result.DeletedCount,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的操作: %s,支持的操作: find, count, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany", op)
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewCollectionIndexes 预览集合索引变更,只生成命令列表不执行
|
||||
func (c *MongoClient) PreviewCollectionIndexes(ctx context.Context, database, collectionName string, structure map[string]interface{}) ([]string, error) {
|
||||
coll := c.client.Database(database).Collection(collectionName)
|
||||
var commands []string
|
||||
|
||||
// 获取当前索引
|
||||
currentIndexes, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前索引失败: %v", err)
|
||||
}
|
||||
defer currentIndexes.Close(ctx)
|
||||
|
||||
// 解析新的索引数据
|
||||
var newIndexes []map[string]interface{}
|
||||
if idxs, ok := structure["indexes"].([]interface{}); ok {
|
||||
for _, idx := range idxs {
|
||||
if idxMap, ok := idx.(map[string]interface{}); ok {
|
||||
newIndexes = append(newIndexes, idxMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建当前索引名映射
|
||||
currentIndexMap := make(map[string]bool)
|
||||
for currentIndexes.Next(ctx) {
|
||||
var indexSpec bson.M
|
||||
if err := currentIndexes.Decode(&indexSpec); err != nil {
|
||||
continue
|
||||
}
|
||||
if name, ok := indexSpec["name"].(string); ok && name != "_id_" {
|
||||
currentIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新索引名映射
|
||||
newIndexMap := make(map[string]bool)
|
||||
for _, idx := range newIndexes {
|
||||
if name, ok := idx["name"].(string); ok && name != "" && name != "_id_" {
|
||||
newIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的索引
|
||||
for name := range currentIndexMap {
|
||||
if !newIndexMap[name] {
|
||||
cmd := fmt.Sprintf("db.%s.dropIndex(\"%s\")", collectionName, name)
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加或更新索引
|
||||
for _, idx := range newIndexes {
|
||||
name, _ := idx["name"].(string)
|
||||
if name == "" || name == "_id_" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引键
|
||||
keys := bson.D{}
|
||||
if keysData, ok := idx["keys"].(map[string]interface{}); ok {
|
||||
for k, v := range keysData {
|
||||
var order int
|
||||
if vFloat, ok := v.(float64); ok {
|
||||
order = int(vFloat)
|
||||
} else if vInt, ok := v.(int); ok {
|
||||
order = vInt
|
||||
} else {
|
||||
order = 1 // 默认升序
|
||||
}
|
||||
keys = append(keys, bson.E{Key: k, Value: order})
|
||||
}
|
||||
} else if columnName, ok := idx["Column_name"].(string); ok && columnName != "" {
|
||||
// 兼容 MySQL 格式的索引数据
|
||||
keys = append(keys, bson.E{Key: columnName, Value: 1})
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引选项,并跟踪 unique 状态(v2: IndexOptionsBuilder 无 Unique 字段可读)
|
||||
indexOptions := options.Index()
|
||||
indexOptions.SetName(name)
|
||||
|
||||
isUnique := false
|
||||
if unique, ok := idx["unique"].(bool); ok && unique {
|
||||
indexOptions.SetUnique(true)
|
||||
isUnique = true
|
||||
} else if nonUnique, ok := idx["Non_unique"].(float64); ok && nonUnique == 0 {
|
||||
indexOptions.SetUnique(true)
|
||||
isUnique = true
|
||||
}
|
||||
|
||||
// 如果索引已存在,先删除再创建
|
||||
if currentIndexMap[name] {
|
||||
dropCmd := fmt.Sprintf("db.%s.dropIndex(\"%s\")", collectionName, name)
|
||||
commands = append(commands, dropCmd)
|
||||
}
|
||||
|
||||
// 构建命令字符串(MongoDB shell 格式)
|
||||
keysStr := "{"
|
||||
for i, key := range keys {
|
||||
if i > 0 {
|
||||
keysStr += ", "
|
||||
}
|
||||
keysStr += fmt.Sprintf("%s: %d", key.Key, key.Value)
|
||||
}
|
||||
keysStr += "}"
|
||||
|
||||
optionsStr := "{name: \"" + name + "\""
|
||||
if isUnique {
|
||||
optionsStr += ", unique: true"
|
||||
}
|
||||
optionsStr += "}"
|
||||
|
||||
cmd := fmt.Sprintf("db.%s.createIndex(%s, %s)", collectionName, keysStr, optionsStr)
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// UpdateCollectionIndexes 更新集合索引,返回执行的命令列表
|
||||
func (c *MongoClient) UpdateCollectionIndexes(ctx context.Context, database, collectionName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 先预览生成命令列表
|
||||
commands, err := c.PreviewCollectionIndexes(ctx, database, collectionName, structure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
coll := c.client.Database(database).Collection(collectionName)
|
||||
|
||||
// 获取当前索引
|
||||
currentIndexes, err := coll.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("获取当前索引失败: %v", err)
|
||||
}
|
||||
defer currentIndexes.Close(ctx)
|
||||
|
||||
// 解析新的索引数据
|
||||
var newIndexes []map[string]interface{}
|
||||
if idxs, ok := structure["indexes"].([]interface{}); ok {
|
||||
for _, idx := range idxs {
|
||||
if idxMap, ok := idx.(map[string]interface{}); ok {
|
||||
newIndexes = append(newIndexes, idxMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建当前索引名映射
|
||||
currentIndexMap := make(map[string]bool)
|
||||
for currentIndexes.Next(ctx) {
|
||||
var indexSpec bson.M
|
||||
if err := currentIndexes.Decode(&indexSpec); err != nil {
|
||||
continue
|
||||
}
|
||||
if name, ok := indexSpec["name"].(string); ok && name != "_id_" {
|
||||
currentIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新索引名映射
|
||||
newIndexMap := make(map[string]bool)
|
||||
for _, idx := range newIndexes {
|
||||
if name, ok := idx["name"].(string); ok && name != "" && name != "_id_" {
|
||||
newIndexMap[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的索引
|
||||
for name := range currentIndexMap {
|
||||
if !newIndexMap[name] {
|
||||
// v2: DropOne 只返回 error,不再返回 bson.Raw
|
||||
err := coll.Indexes().DropOne(ctx, name)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("删除索引失败: %v, 索引名: %s", err, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加或更新索引
|
||||
for _, idx := range newIndexes {
|
||||
name, _ := idx["name"].(string)
|
||||
if name == "" || name == "_id_" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引键
|
||||
keys := bson.D{}
|
||||
if keysData, ok := idx["keys"].(map[string]interface{}); ok {
|
||||
for k, v := range keysData {
|
||||
var order int
|
||||
if vFloat, ok := v.(float64); ok {
|
||||
order = int(vFloat)
|
||||
} else if vInt, ok := v.(int); ok {
|
||||
order = vInt
|
||||
} else {
|
||||
order = 1 // 默认升序
|
||||
}
|
||||
keys = append(keys, bson.E{Key: k, Value: order})
|
||||
}
|
||||
} else if columnName, ok := idx["Column_name"].(string); ok && columnName != "" {
|
||||
// 兼容 MySQL 格式的索引数据
|
||||
keys = append(keys, bson.E{Key: columnName, Value: 1})
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建索引选项
|
||||
indexOptions := options.Index()
|
||||
indexOptions.SetName(name)
|
||||
|
||||
if unique, ok := idx["unique"].(bool); ok && unique {
|
||||
indexOptions.SetUnique(true)
|
||||
} else if nonUnique, ok := idx["Non_unique"].(float64); ok && nonUnique == 0 {
|
||||
indexOptions.SetUnique(true)
|
||||
}
|
||||
|
||||
// 创建索引
|
||||
indexModel := mongo.IndexModel{
|
||||
Keys: keys,
|
||||
Options: indexOptions,
|
||||
}
|
||||
|
||||
// 如果索引已存在,先删除再创建
|
||||
if currentIndexMap[name] {
|
||||
// v2: DropOne 只返回 error,不再返回 bson.Raw
|
||||
err := coll.Indexes().DropOne(ctx, name)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("删除旧索引失败: %v, 索引名: %s", err, name)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := coll.Indexes().CreateOne(ctx, indexModel)
|
||||
if err != nil {
|
||||
return commands, fmt.Errorf("创建索引失败: %v, 索引名: %s", err, name)
|
||||
}
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
@@ -1,875 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// MySQLClient MySQL 客户端
|
||||
type MySQLClient struct {
|
||||
db *gorm.DB
|
||||
sqlDB *sql.DB
|
||||
config *MySQLConfig
|
||||
}
|
||||
|
||||
// MySQLConfig MySQL 配置
|
||||
type MySQLConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
}
|
||||
|
||||
// NewMySQLClient 创建 MySQL 客户端
|
||||
func NewMySQLClient(config *MySQLConfig) (*MySQLClient, error) {
|
||||
// 构建 DSN
|
||||
mysqlConfig := mysqldriver.Config{
|
||||
User: config.Username,
|
||||
Passwd: config.Password,
|
||||
Net: "tcp",
|
||||
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
|
||||
DBName: config.Database,
|
||||
Params: map[string]string{
|
||||
"charset": "utf8mb4",
|
||||
"parseTime": "True",
|
||||
"loc": "Local",
|
||||
"multiStatements": "true", // 支持多条SQL语句执行
|
||||
},
|
||||
AllowNativePasswords: true,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
dsn := mysqlConfig.FormatDSN()
|
||||
|
||||
// GORM 配置
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
|
||||
// 打开连接
|
||||
db, err := gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 MySQL 失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取底层 sql.DB
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取数据库实例失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("MySQL 连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置连接池参数
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
sqlDB.SetMaxIdleConns(2)
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
return &MySQLClient{
|
||||
db: db,
|
||||
sqlDB: sqlDB,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestConnection 测试连接
|
||||
func TestMySQLConnection(host string, port int, username, password, database string) error {
|
||||
config := &MySQLConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Database: database,
|
||||
}
|
||||
client, err := NewMySQLClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *MySQLClient) Close() error {
|
||||
if c.sqlDB != nil {
|
||||
return c.sqlDB.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryResult 查询结果,包含数据和列顺序
|
||||
type QueryResult struct {
|
||||
Data []map[string]interface{}
|
||||
Columns []string
|
||||
}
|
||||
|
||||
// ExecuteQuery 执行查询 SQL
|
||||
// database 参数可选,如果提供且不为空则优先使用,否则使用配置中的数据库
|
||||
// 注意:SQL 语句应该已经包含 LIMIT 和 OFFSET(由客户端添加)
|
||||
func (c *MySQLClient) ExecuteQuery(ctx context.Context, sqlStr string, database string) (*QueryResult, error) {
|
||||
// 确定要使用的数据库
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
// 使用 Session 创建独立的数据库会话,避免影响其他查询
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
|
||||
// 如果指定了数据库,先切换到该数据库
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return nil, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.Raw(sqlStr).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("执行查询失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// 检查 rows 错误
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("查询结果错误: %v", err)
|
||||
}
|
||||
|
||||
// 获取列名
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
// 如果没有列,返回空数组
|
||||
if len(columns) == 0 {
|
||||
return &QueryResult{
|
||||
Data: []map[string]interface{}{},
|
||||
Columns: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 读取数据
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
// 创建值数组和指针数组
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
// 扫描行数据
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 构建结果 map,按照列顺序构建
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
// 处理 nil 值
|
||||
if val == nil {
|
||||
row[col] = nil
|
||||
} else if b, ok := val.([]byte); ok {
|
||||
// 处理 []byte 类型
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// 检查迭代过程中的错误
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("读取数据时发生错误: %v", err)
|
||||
}
|
||||
|
||||
return &QueryResult{
|
||||
Data: results,
|
||||
Columns: columns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExecuteUpdate 执行更新 SQL(INSERT/UPDATE/DELETE)
|
||||
// database 参数可选,如果提供且不为空则优先使用,否则使用配置中的数据库
|
||||
func (c *MySQLClient) ExecuteUpdate(ctx context.Context, sqlStr string, database string) (int64, error) {
|
||||
// 确定要使用的数据库
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
// 使用 Session 创建独立的数据库会话,避免影响其他查询
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
|
||||
// 如果指定了数据库,先切换到该数据库
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return 0, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
result := db.Exec(sqlStr)
|
||||
if result.Error != nil {
|
||||
return 0, fmt.Errorf("执行更新失败: %v", result.Error)
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// ListDatabases 获取数据库列表
|
||||
func (c *MySQLClient) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
var databases []string
|
||||
err := c.db.Raw("SHOW DATABASES").Scan(&databases).Error
|
||||
return databases, err
|
||||
}
|
||||
|
||||
// ListTables 获取表列表
|
||||
func (c *MySQLClient) ListTables(ctx context.Context, database string) ([]string, error) {
|
||||
var tables []string
|
||||
query := "SHOW TABLES"
|
||||
if database != "" {
|
||||
query = fmt.Sprintf("SHOW TABLES FROM `%s`", database)
|
||||
}
|
||||
err := c.db.Raw(query).Scan(&tables).Error
|
||||
return tables, err
|
||||
}
|
||||
|
||||
// GetTableStructure 获取表结构
|
||||
func (c *MySQLClient) GetTableStructure(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||||
// 使用 SHOW FULL COLUMNS 来获取包含 comment 的完整字段信息
|
||||
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
|
||||
if database != "" {
|
||||
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", database, tableName)
|
||||
}
|
||||
|
||||
rows, err := c.db.Raw(query).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取表结构失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else if val == nil {
|
||||
row[col] = nil
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 Comment 字段存在(SHOW FULL COLUMNS 返回的字段名是 Comment)
|
||||
if _, ok := row["Comment"]; !ok {
|
||||
row["Comment"] = ""
|
||||
}
|
||||
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetIndexes 获取索引列表
|
||||
func (c *MySQLClient) GetIndexes(ctx context.Context, database, tableName string) ([]map[string]interface{}, error) {
|
||||
query := "SHOW INDEX FROM "
|
||||
if database != "" {
|
||||
query += fmt.Sprintf("`%s`.", database)
|
||||
}
|
||||
query += fmt.Sprintf("`%s`", tableName)
|
||||
|
||||
rows, err := c.db.Raw(query).Rows()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取索引列表失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取列名失败: %v", err)
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, fmt.Errorf("扫描数据失败: %v", err)
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// PreviewTableStructure 预览表结构变更,只生成 SQL 语句不执行
|
||||
func (c *MySQLClient) PreviewTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 获取当前表结构
|
||||
currentColumns, err := c.GetTableStructure(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前表结构失败: %v", err)
|
||||
}
|
||||
|
||||
currentIndexes, err := c.GetIndexes(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前索引失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析新的结构数据
|
||||
var newColumns []map[string]interface{}
|
||||
var newIndexes []map[string]interface{}
|
||||
|
||||
if cols, ok := structure["columns"].([]interface{}); ok {
|
||||
for _, col := range cols {
|
||||
if colMap, ok := col.(map[string]interface{}); ok {
|
||||
newColumns = append(newColumns, colMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if idxs, ok := structure["indexes"].([]interface{}); ok {
|
||||
for _, idx := range idxs {
|
||||
if idxMap, ok := idx.(map[string]interface{}); ok {
|
||||
newIndexes = append(newIndexes, idxMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 ALTER TABLE 语句
|
||||
var alterStatements []string
|
||||
|
||||
// 处理字段变更
|
||||
alterStatements = append(alterStatements, c.buildColumnAlterStatements(tableName, currentColumns, newColumns)...)
|
||||
|
||||
// 处理索引变更
|
||||
alterStatements = append(alterStatements, c.buildIndexAlterStatements(tableName, currentIndexes, newIndexes)...)
|
||||
|
||||
return alterStatements, nil
|
||||
}
|
||||
|
||||
// UpdateTableStructure 更新表结构,返回生成的 SQL 语句列表
|
||||
func (c *MySQLClient) UpdateTableStructure(ctx context.Context, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
// 先预览生成 SQL 语句
|
||||
alterStatements, err := c.PreviewTableStructure(ctx, database, tableName, structure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 执行所有 ALTER TABLE 语句
|
||||
if len(alterStatements) > 0 {
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = c.config.Database
|
||||
}
|
||||
|
||||
db := c.db.Session(&gorm.Session{})
|
||||
if dbName != "" {
|
||||
if err := db.Exec(fmt.Sprintf("USE `%s`", dbName)).Error; err != nil {
|
||||
return alterStatements, fmt.Errorf("切换数据库失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, stmt := range alterStatements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return alterStatements, fmt.Errorf("执行 ALTER TABLE 失败: %v, SQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return alterStatements, nil
|
||||
}
|
||||
|
||||
// buildColumnAlterStatements 构建字段变更的 ALTER TABLE 语句
|
||||
func (c *MySQLClient) buildColumnAlterStatements(tableName string, currentColumns, newColumns []map[string]interface{}) []string {
|
||||
var statements []string
|
||||
|
||||
// 创建字段名映射和顺序映射
|
||||
currentFieldMap := make(map[string]map[string]interface{})
|
||||
currentFieldOrder := make([]string, 0, len(currentColumns))
|
||||
for _, col := range currentColumns {
|
||||
if field, ok := col["Field"].(string); ok {
|
||||
currentFieldMap[field] = col
|
||||
currentFieldOrder = append(currentFieldOrder, field)
|
||||
}
|
||||
}
|
||||
|
||||
newFieldMap := make(map[string]bool)
|
||||
newFieldOrder := make([]string, 0, len(newColumns))
|
||||
newColumnsMap := make(map[string]map[string]interface{})
|
||||
for _, col := range newColumns {
|
||||
if field, ok := col["Field"].(string); ok && field != "" {
|
||||
newFieldMap[field] = true
|
||||
newFieldOrder = append(newFieldOrder, field)
|
||||
newColumnsMap[field] = col
|
||||
}
|
||||
}
|
||||
|
||||
// 检测字段重命名:优先使用位置匹配,如果位置相同但字段名不同,认为是重命名
|
||||
renameMap := make(map[string]string) // oldName -> newName
|
||||
processedNewFields := make(map[string]bool)
|
||||
|
||||
// 第一步:使用位置匹配检测重命名(最可靠)
|
||||
for oldIndex, oldFieldName := range currentFieldOrder {
|
||||
if newFieldMap[oldFieldName] {
|
||||
continue // 字段名未改变,跳过
|
||||
}
|
||||
|
||||
// 检查新字段列表中相同位置是否有字段
|
||||
if oldIndex < len(newFieldOrder) {
|
||||
newFieldName := newFieldOrder[oldIndex]
|
||||
_, existsInCurrent := currentFieldMap[newFieldName]
|
||||
if !existsInCurrent && !processedNewFields[newFieldName] {
|
||||
// 新字段不在当前字段列表中,且位置相同,很可能是重命名
|
||||
// 进一步验证:检查类型是否相同(类型相同更可能是重命名)
|
||||
oldCol := currentFieldMap[oldFieldName]
|
||||
newCol := newColumnsMap[newFieldName]
|
||||
oldType := getStringValue(oldCol["Type"])
|
||||
newType := getStringValue(newCol["Type"])
|
||||
|
||||
// 如果类型相同,认为是重命名
|
||||
if oldType == newType {
|
||||
renameMap[oldFieldName] = newFieldName
|
||||
processedNewFields[newFieldName] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:对于未匹配的字段,使用属性匹配(兼容旧逻辑)
|
||||
for oldFieldName, oldCol := range currentFieldMap {
|
||||
if newFieldMap[oldFieldName] {
|
||||
continue // 字段名未改变,跳过
|
||||
}
|
||||
if renameMap[oldFieldName] != "" {
|
||||
continue // 已经通过位置匹配识别为重命名
|
||||
}
|
||||
|
||||
// 查找属性完全匹配的新字段
|
||||
var matchedNewField string
|
||||
for newFieldName, newCol := range newColumnsMap {
|
||||
if processedNewFields[newFieldName] {
|
||||
continue // 已经被匹配过了
|
||||
}
|
||||
_, existsInCurrent := currentFieldMap[newFieldName]
|
||||
if !existsInCurrent {
|
||||
// 这是一个新增字段,检查属性是否匹配
|
||||
if c.isColumnPropertiesEqual(oldCol, newCol) {
|
||||
if matchedNewField == "" {
|
||||
matchedNewField = newFieldName
|
||||
} else {
|
||||
// 有多个匹配,无法确定,不认为是重命名
|
||||
matchedNewField = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到唯一匹配,认为是重命名
|
||||
if matchedNewField != "" {
|
||||
renameMap[oldFieldName] = matchedNewField
|
||||
processedNewFields[matchedNewField] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理字段重命名
|
||||
for oldName, newName := range renameMap {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` RENAME COLUMN `%s` TO `%s`", tableName, oldName, newName)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
|
||||
// 处理字段添加、修改和位置调整(排除已重命名的字段)
|
||||
for i, newCol := range newColumns {
|
||||
field, _ := newCol["Field"].(string)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是重命名的字段
|
||||
isRenamed := false
|
||||
var oldName string
|
||||
for old, new := range renameMap {
|
||||
if new == field {
|
||||
isRenamed = true
|
||||
oldName = old
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isRenamed {
|
||||
// 重命名的字段:如果属性有变化,需要 MODIFY COLUMN
|
||||
oldCol := currentFieldMap[oldName]
|
||||
needsModify := c.isColumnChanged(oldCol, newCol)
|
||||
|
||||
// 检查顺序变化:使用旧字段名在 currentOrder 中查找位置,与新位置比较
|
||||
oldIndex := -1
|
||||
for idx, name := range currentFieldOrder {
|
||||
if name == oldName {
|
||||
oldIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
needsReorder := (oldIndex != -1 && oldIndex != i)
|
||||
|
||||
if needsModify || needsReorder {
|
||||
// 重命名后需要修改属性或位置
|
||||
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentCol, exists := currentFieldMap[field]; exists {
|
||||
// 修改现有字段
|
||||
needsModify := c.isColumnChanged(currentCol, newCol)
|
||||
needsReorder := c.isColumnOrderChanged(currentFieldOrder, newFieldOrder, field, i)
|
||||
|
||||
if needsModify || needsReorder {
|
||||
stmt := c.buildModifyColumnStatement(tableName, field, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加新字段(排除重命名的字段)
|
||||
stmt := c.buildAddColumnStatement(tableName, newCol, newFieldOrder, i)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的字段(排除已重命名的字段)
|
||||
for field := range currentFieldMap {
|
||||
if !newFieldMap[field] && renameMap[field] == "" {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP COLUMN `%s`", tableName, field)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
// buildIndexAlterStatements 构建索引变更的 ALTER TABLE 语句
|
||||
func (c *MySQLClient) buildIndexAlterStatements(tableName string, currentIndexes, newIndexes []map[string]interface{}) []string {
|
||||
var statements []string
|
||||
|
||||
// 创建索引名映射
|
||||
currentIndexMap := make(map[string]map[string]interface{})
|
||||
for _, idx := range currentIndexes {
|
||||
if keyName, ok := idx["Key_name"].(string); ok && keyName != "PRIMARY" {
|
||||
currentIndexMap[keyName] = idx
|
||||
}
|
||||
}
|
||||
|
||||
newIndexMap := make(map[string]bool)
|
||||
for _, idx := range newIndexes {
|
||||
if keyName, ok := idx["Key_name"].(string); ok && keyName != "" && keyName != "PRIMARY" {
|
||||
newIndexMap[keyName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理索引变更
|
||||
for _, newIdx := range newIndexes {
|
||||
keyName, _ := newIdx["Key_name"].(string)
|
||||
if keyName == "" || keyName == "PRIMARY" {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentIdx, exists := currentIndexMap[keyName]; exists {
|
||||
// 修改现有索引
|
||||
if c.isIndexChanged(currentIdx, newIdx) {
|
||||
dropStmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
|
||||
addStmt := c.buildAddIndexStatement(tableName, newIdx)
|
||||
if addStmt != "" {
|
||||
statements = append(statements, dropStmt)
|
||||
statements = append(statements, addStmt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加新索引
|
||||
stmt := c.buildAddIndexStatement(tableName, newIdx)
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除不存在的索引
|
||||
for keyName := range currentIndexMap {
|
||||
if !newIndexMap[keyName] {
|
||||
stmt := fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, keyName)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
// isColumnChanged 检查字段是否发生变化(不包括字段名)
|
||||
func (c *MySQLClient) isColumnChanged(oldCol, newCol map[string]interface{}) bool {
|
||||
fields := []string{"Type", "Null", "Default", "Extra", "Comment"}
|
||||
for _, field := range fields {
|
||||
oldVal := getStringValue(oldCol[field])
|
||||
newVal := getStringValue(newCol[field])
|
||||
if oldVal != newVal {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isColumnPropertiesEqual 检查字段属性是否完全相等(不包括字段名)
|
||||
func (c *MySQLClient) isColumnPropertiesEqual(oldCol, newCol map[string]interface{}) bool {
|
||||
fields := []string{"Type", "Null", "Default", "Extra", "Key", "Comment"}
|
||||
for _, field := range fields {
|
||||
oldVal := getStringValue(oldCol[field])
|
||||
newVal := getStringValue(newCol[field])
|
||||
if oldVal != newVal {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isColumnOrderChanged 检查字段顺序是否发生变化
|
||||
func (c *MySQLClient) isColumnOrderChanged(currentOrder, newOrder []string, fieldName string, newIndex int) bool {
|
||||
// 查找字段在当前顺序中的位置
|
||||
currentIndex := -1
|
||||
for i, name := range currentOrder {
|
||||
if name == fieldName {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果字段不存在于当前顺序中(新字段),不需要检查顺序
|
||||
if currentIndex == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果索引相同,检查前面的字段是否相同
|
||||
if newIndex == currentIndex {
|
||||
// 检查前面的字段集合是否相同
|
||||
if newIndex > 0 {
|
||||
currentPrevFields := make(map[string]bool)
|
||||
for i := 0; i < currentIndex; i++ {
|
||||
currentPrevFields[currentOrder[i]] = true
|
||||
}
|
||||
|
||||
newPrevFields := make(map[string]bool)
|
||||
for i := 0; i < newIndex; i++ {
|
||||
newPrevFields[newOrder[i]] = true
|
||||
}
|
||||
|
||||
// 如果前面的字段集合不同,说明顺序变了
|
||||
if len(currentPrevFields) != len(newPrevFields) {
|
||||
return true
|
||||
}
|
||||
for f := range currentPrevFields {
|
||||
if !newPrevFields[f] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 索引不同,说明顺序变了
|
||||
return true
|
||||
}
|
||||
|
||||
// isIndexChanged 检查索引是否发生变化
|
||||
func (c *MySQLClient) isIndexChanged(oldIdx, newIdx map[string]interface{}) bool {
|
||||
oldCol := getStringValue(oldIdx["Column_name"])
|
||||
newCol := getStringValue(newIdx["Column_name"])
|
||||
if oldCol != newCol {
|
||||
return true
|
||||
}
|
||||
|
||||
oldUnique := getIntValue(oldIdx["Non_unique"])
|
||||
newUnique := getIntValue(newIdx["Non_unique"])
|
||||
return oldUnique != newUnique
|
||||
}
|
||||
|
||||
// buildAddColumnStatement 构建添加字段的语句
|
||||
func (c *MySQLClient) buildAddColumnStatement(tableName string, col map[string]interface{}, fieldOrder []string, index int) string {
|
||||
field := getStringValue(col["Field"])
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
colDef := c.buildColumnDefinition(col)
|
||||
|
||||
// 确定字段位置
|
||||
position := c.buildColumnPosition(fieldOrder, index)
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN %s%s", tableName, colDef, position)
|
||||
}
|
||||
|
||||
// buildModifyColumnStatement 构建修改字段的语句
|
||||
func (c *MySQLClient) buildModifyColumnStatement(tableName, field string, col map[string]interface{}, fieldOrder []string, index int) string {
|
||||
colDef := c.buildColumnDefinition(col)
|
||||
|
||||
// 确定字段位置
|
||||
position := c.buildColumnPosition(fieldOrder, index)
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN %s%s", tableName, colDef, position)
|
||||
}
|
||||
|
||||
// buildColumnPosition 构建字段位置子句(AFTER 或 FIRST)
|
||||
func (c *MySQLClient) buildColumnPosition(fieldOrder []string, index int) string {
|
||||
if index < 0 || index >= len(fieldOrder) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
// 第一个字段使用 FIRST
|
||||
return " FIRST"
|
||||
}
|
||||
|
||||
// 其他字段使用 AFTER 前一个字段
|
||||
prevField := fieldOrder[index-1]
|
||||
return fmt.Sprintf(" AFTER `%s`", prevField)
|
||||
}
|
||||
|
||||
// buildColumnDefinition 构建字段定义
|
||||
func (c *MySQLClient) buildColumnDefinition(col map[string]interface{}) string {
|
||||
field := getStringValue(col["Field"])
|
||||
colType := getStringValue(col["Type"])
|
||||
null := getStringValue(col["Null"])
|
||||
defaultVal := col["Default"]
|
||||
extra := getStringValue(col["Extra"])
|
||||
comment := getStringValue(col["Comment"])
|
||||
|
||||
def := fmt.Sprintf("`%s` %s", field, colType)
|
||||
|
||||
if null == "NO" {
|
||||
def += " NOT NULL"
|
||||
}
|
||||
|
||||
if defaultVal != nil {
|
||||
if defaultStr, ok := defaultVal.(string); ok {
|
||||
if defaultStr == "" {
|
||||
// 空字符串表示默认值为空字符串
|
||||
def += " DEFAULT ''"
|
||||
} else if defaultStr != "NULL" {
|
||||
// 转义单引号
|
||||
escapedDefault := strings.ReplaceAll(defaultStr, "'", "''")
|
||||
def += fmt.Sprintf(" DEFAULT '%s'", escapedDefault)
|
||||
}
|
||||
// 如果 defaultStr == "NULL",不添加 DEFAULT 子句(允许 NULL)
|
||||
} else {
|
||||
// 非字符串类型的默认值
|
||||
def += fmt.Sprintf(" DEFAULT %v", defaultVal)
|
||||
}
|
||||
}
|
||||
|
||||
if extra != "" {
|
||||
def += " " + extra
|
||||
}
|
||||
|
||||
if comment != "" {
|
||||
// 转义单引号
|
||||
escapedComment := strings.ReplaceAll(comment, "'", "''")
|
||||
def += fmt.Sprintf(" COMMENT '%s'", escapedComment)
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
// buildAddIndexStatement 构建添加索引的语句
|
||||
func (c *MySQLClient) buildAddIndexStatement(tableName string, idx map[string]interface{}) string {
|
||||
keyName := getStringValue(idx["Key_name"])
|
||||
columnName := getStringValue(idx["Column_name"])
|
||||
nonUnique := getIntValue(idx["Non_unique"])
|
||||
|
||||
if keyName == "" || columnName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
indexType := "INDEX"
|
||||
if nonUnique == 0 {
|
||||
indexType = "UNIQUE INDEX"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ALTER TABLE `%s` ADD %s `%s` (`%s`)", tableName, indexType, keyName, columnName)
|
||||
}
|
||||
|
||||
// getStringValue 安全获取字符串值
|
||||
func getStringValue(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// getIntValue 安全获取整数值
|
||||
func getIntValue(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case int64:
|
||||
return int(val)
|
||||
case float64:
|
||||
return int(val)
|
||||
case string:
|
||||
var i int
|
||||
fmt.Sscanf(val, "%d", &i)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/crypto"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// ConnectionPool 连接池管理器
|
||||
type ConnectionPool struct {
|
||||
mysqlClients map[uint]*MySQLClient
|
||||
redisClients map[uint]*RedisClient
|
||||
mongoClients map[uint]*MongoClient
|
||||
|
||||
// 新增:MySQL 真连接池
|
||||
mysqlPool *MySQLConnectionPool
|
||||
|
||||
// 查询优化器
|
||||
queryOptimizer *QueryOptimizer
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalPool *ConnectionPool
|
||||
poolOnce sync.Once
|
||||
)
|
||||
|
||||
// GetPool 获取全局连接池实例
|
||||
func GetPool() *ConnectionPool {
|
||||
poolOnce.Do(func() {
|
||||
// 创建 MySQL 连接池
|
||||
poolConfig := DefaultPoolConfig()
|
||||
|
||||
mysqlPool := NewMySQLConnectionPool(poolConfig)
|
||||
// 启动维护协程
|
||||
mysqlPool.StartMaintenance()
|
||||
|
||||
// 创建查询优化器
|
||||
queryOptimizer := NewQueryOptimizer(nil)
|
||||
|
||||
globalPool = &ConnectionPool{
|
||||
mysqlClients: make(map[uint]*MySQLClient),
|
||||
redisClients: make(map[uint]*RedisClient),
|
||||
mongoClients: make(map[uint]*MongoClient),
|
||||
mysqlPool: mysqlPool,
|
||||
queryOptimizer: queryOptimizer,
|
||||
}
|
||||
})
|
||||
return globalPool
|
||||
}
|
||||
|
||||
// PooledClient 带释放语义的客户端包装
|
||||
type PooledClient struct {
|
||||
Client *MySQLClient
|
||||
entry *MySQLPoolEntry
|
||||
pool *MySQLConnectionPool
|
||||
fromPool bool
|
||||
}
|
||||
|
||||
// Release 释放连接回连接池
|
||||
func (pc *PooledClient) Release() {
|
||||
if pc.fromPool && pc.pool != nil && pc.entry != nil {
|
||||
pc.pool.Release(pc.entry)
|
||||
}
|
||||
}
|
||||
|
||||
// GetMySQLClient 获取或创建 MySQL 客户端(使用连接池)
|
||||
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) *PooledClient {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 尝试从连接池获取连接
|
||||
if p.mysqlPool != nil {
|
||||
entry, err := p.mysqlPool.Acquire(conn)
|
||||
if err == nil {
|
||||
return &PooledClient{Client: entry.Client, entry: entry, pool: p.mysqlPool, fromPool: true}
|
||||
}
|
||||
p.logPoolError("Acquire failed", err)
|
||||
}
|
||||
|
||||
// 降级到原有逻辑
|
||||
client, err := p.getMySQLClientLegacy(conn)
|
||||
if err != nil {
|
||||
return &PooledClient{Client: nil, fromPool: false}
|
||||
}
|
||||
return &PooledClient{Client: client, fromPool: false}
|
||||
}
|
||||
|
||||
// logPoolError 记录连接池错误
|
||||
func (p *ConnectionPool) logPoolError(operation string, err error) {
|
||||
if p.queryOptimizer != nil {
|
||||
// 通过查询优化器记录错误
|
||||
p.queryOptimizer.RecordPoolError(operation, err)
|
||||
}
|
||||
}
|
||||
|
||||
// getMySQLClientLegacy 原有的 MySQL 客户端获取逻辑(向后兼容)
|
||||
func (p *ConnectionPool) getMySQLClientLegacy(conn *models.DbConnection) (*MySQLClient, error) {
|
||||
// 检查是否已存在
|
||||
if client, ok := p.mysqlClients[conn.ID]; ok {
|
||||
// 测试连接是否有效
|
||||
if err := client.sqlDB.Ping(); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
// 连接已断开,移除并重新创建
|
||||
client.Close()
|
||||
delete(p.mysqlClients, conn.ID)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
config := &MySQLConfig{
|
||||
Host: conn.Host,
|
||||
Port: conn.Port,
|
||||
Username: conn.Username,
|
||||
Password: password, // 如果密码为空,MySQL会尝试无密码连接
|
||||
Database: conn.Database,
|
||||
}
|
||||
|
||||
client, err := NewMySQLClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mysqlClients[conn.ID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetMySQLPoolStats 获取 MySQL 连接池统计信息
|
||||
func (p *ConnectionPool) GetMySQLPoolStats() *PoolStats {
|
||||
if p.mysqlPool != nil {
|
||||
stats := p.mysqlPool.Stats()
|
||||
return &stats
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OptimizeQuery 优化查询执行
|
||||
func (p *ConnectionPool) OptimizeQuery(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (*QueryResult, time.Duration, error) {
|
||||
pc := p.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, 0, fmt.Errorf("获取 MySQL 连接失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
|
||||
// 使用查询优化器
|
||||
if p.queryOptimizer != nil {
|
||||
return p.queryOptimizer.OptimizeQuery(ctx, pc.Client, sqlStr, database)
|
||||
}
|
||||
|
||||
// 降级到普通查询
|
||||
startTime := time.Now()
|
||||
result, err := pc.Client.ExecuteQuery(ctx, sqlStr, database)
|
||||
duration := time.Since(startTime)
|
||||
return result, duration, err
|
||||
}
|
||||
|
||||
// ExecuteOptimizedUpdate 执行优化的更新操作
|
||||
func (p *ConnectionPool) ExecuteOptimizedUpdate(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (int64, time.Duration, error) {
|
||||
pc := p.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return 0, 0, fmt.Errorf("获取 MySQL 连接失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
|
||||
// 使用查询优化器
|
||||
if p.queryOptimizer != nil {
|
||||
return p.queryOptimizer.ExecuteOptimizedUpdate(ctx, pc.Client, sqlStr, database)
|
||||
}
|
||||
|
||||
// 降级到普通更新
|
||||
startTime := time.Now()
|
||||
result, err := pc.Client.ExecuteUpdate(ctx, sqlStr, database)
|
||||
duration := time.Since(startTime)
|
||||
return result, duration, err
|
||||
}
|
||||
|
||||
// GetQueryStats 获取查询统计信息
|
||||
func (p *ConnectionPool) GetQueryStats() QueryStats {
|
||||
if p.queryOptimizer != nil {
|
||||
return p.queryOptimizer.GetQueryStats()
|
||||
}
|
||||
return QueryStats{}
|
||||
}
|
||||
|
||||
// GetSlowQueries 获取慢查询记录
|
||||
func (p *ConnectionPool) GetSlowQueries(limit int) []SlowQuery {
|
||||
if p.queryOptimizer != nil {
|
||||
return p.queryOptimizer.GetSlowQueries(limit)
|
||||
}
|
||||
return []SlowQuery{}
|
||||
}
|
||||
|
||||
// GetIndexSuggestions 获取索引建议
|
||||
func (p *ConnectionPool) GetIndexSuggestions(table string) []IndexSuggestion {
|
||||
if p.queryOptimizer != nil {
|
||||
return p.queryOptimizer.GetIndexSuggestions(table)
|
||||
}
|
||||
return []IndexSuggestion{}
|
||||
}
|
||||
|
||||
// GenerateIndexSuggestions 为表生成索引建议
|
||||
func (p *ConnectionPool) GenerateIndexSuggestions(ctx context.Context, conn *models.DbConnection, database, table string) error {
|
||||
pc := p.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return fmt.Errorf("获取 MySQL 连接失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
|
||||
// 使用查询优化器
|
||||
if p.queryOptimizer != nil {
|
||||
return p.queryOptimizer.GenerateIndexSuggestions(ctx, pc.Client, database, table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearQueryCache 清空查询缓存
|
||||
func (p *ConnectionPool) ClearQueryCache() {
|
||||
if p.queryOptimizer != nil {
|
||||
p.queryOptimizer.ClearCache()
|
||||
}
|
||||
}
|
||||
|
||||
// GetRedisClient 获取或创建 Redis 客户端
|
||||
func (p *ConnectionPool) GetRedisClient(conn *models.DbConnection) (*RedisClient, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 检查是否已存在
|
||||
if client, ok := p.redisClients[conn.ID]; ok {
|
||||
// 测试连接是否有效
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
||||
defer cancel()
|
||||
if err := client.client.Ping(ctx).Err(); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
// 连接已断开,移除并重新创建
|
||||
client.Close()
|
||||
delete(p.redisClients, conn.ID)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析 Redis DB 编号(从 Database 字段,默认为 0)
|
||||
dbNum := 0
|
||||
if conn.Database != "" {
|
||||
// 尝试解析 Database 字段为数字
|
||||
_, err := fmt.Sscanf(conn.Database, "%d", &dbNum)
|
||||
if err != nil {
|
||||
// 如果解析失败,使用默认值 0
|
||||
dbNum = 0
|
||||
}
|
||||
// 限制 DB 编号在 0-15 之间
|
||||
if dbNum < 0 || dbNum > 15 {
|
||||
dbNum = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
config := &RedisConfig{
|
||||
Host: conn.Host,
|
||||
Port: conn.Port,
|
||||
Password: password,
|
||||
DB: dbNum,
|
||||
}
|
||||
|
||||
client, err := NewRedisClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.redisClients[conn.ID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetMongoClient 获取或创建 MongoDB 客户端
|
||||
func (p *ConnectionPool) GetMongoClient(conn *models.DbConnection) (*MongoClient, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 检查是否已存在
|
||||
if client, ok := p.mongoClients[conn.ID]; ok {
|
||||
// 测试连接是否有效
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
||||
defer cancel()
|
||||
if err := client.client.Ping(ctx, nil); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
// 连接已断开,移除并重新创建
|
||||
client.Close()
|
||||
delete(p.mongoClients, conn.ID)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if conn.Options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
config := &MongoConfig{
|
||||
Host: conn.Host,
|
||||
Port: conn.Port,
|
||||
Username: conn.Username,
|
||||
Password: password,
|
||||
Database: conn.Database,
|
||||
AuthSource: authSource,
|
||||
AuthMechanism: authMechanism,
|
||||
}
|
||||
|
||||
client, err := NewMongoClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mongoClients[conn.ID] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// CloseConnection 关闭指定连接
|
||||
func (p *ConnectionPool) CloseConnection(connID uint, dbType string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
if client, ok := p.mysqlClients[connID]; ok {
|
||||
client.Close()
|
||||
delete(p.mysqlClients, connID)
|
||||
}
|
||||
case "redis":
|
||||
if client, ok := p.redisClients[connID]; ok {
|
||||
client.Close()
|
||||
delete(p.redisClients, connID)
|
||||
}
|
||||
case "mongo":
|
||||
if client, ok := p.mongoClients[connID]; ok {
|
||||
client.Close()
|
||||
delete(p.mongoClients, connID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CloseAll 关闭所有连接
|
||||
func (p *ConnectionPool) CloseAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for _, client := range p.mysqlClients {
|
||||
client.Close()
|
||||
}
|
||||
for _, client := range p.redisClients {
|
||||
client.Close()
|
||||
}
|
||||
for _, client := range p.mongoClients {
|
||||
client.Close()
|
||||
}
|
||||
|
||||
p.mysqlClients = make(map[uint]*MySQLClient)
|
||||
p.redisClients = make(map[uint]*RedisClient)
|
||||
p.mongoClients = make(map[uint]*MongoClient)
|
||||
}
|
||||
@@ -1,679 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/crypto"
|
||||
"u-desk/internal/storage/models"
|
||||
)
|
||||
|
||||
// PoolConfig 连接池配置
|
||||
type PoolConfig struct {
|
||||
// 最大打开连接数(硬上限)
|
||||
MaxOpenConns int
|
||||
// 最大空闲连接数(超过此数量的空闲连接会被关闭)
|
||||
MaxIdleConns int
|
||||
// 连接最大生命周期(超过此时间的连接会被关闭)
|
||||
ConnMaxLifetime time.Duration
|
||||
// 连接最大空闲时间(超过此时间未使用的连接会被关闭)
|
||||
ConnMaxIdleTime time.Duration
|
||||
// 最小空闲连接数(保持此数量的空闲连接以快速响应)
|
||||
MinIdleConns int
|
||||
// 连接超时时间(建立连接的最长时间)
|
||||
ConnTimeout time.Duration
|
||||
// 健康检查间隔(定期 Ping 连接检查有效性)
|
||||
HealthCheckInterval time.Duration
|
||||
// 是否启用连接预热(启动时建立最小连接)
|
||||
EnableWarmup bool
|
||||
// 是否启用慢连接日志(记录建立时间超过阈值的连接)
|
||||
EnableSlowConnLog bool
|
||||
// 慢连接阈值(超过此时间记录为慢连接)
|
||||
SlowConnThreshold time.Duration
|
||||
// 连接池最大容量(防止资源耗尽)
|
||||
MaxPoolCapacity int
|
||||
|
||||
// 动态连接池配置
|
||||
EnableDynamicScaling bool // 是否启用动态连接池调整
|
||||
DynamicScaleFactor float64 // 动态调整因子(0.5-2.0)
|
||||
ScaleUpThreshold float64 // 扩容阈值(0-1.0,当使用率超过此值时扩容)
|
||||
ScaleDownThreshold float64 // 缩容阈值(0-1.0,当使用率低于此值时缩容)
|
||||
MinScaleUpInterval time.Duration // 最小扩容间隔(防止频繁调整)
|
||||
MinScaleDownInterval time.Duration // 最小缩容间隔
|
||||
MaxIdleTimeForScale time.Duration // 用于动态调整的最大空闲时间
|
||||
}
|
||||
|
||||
// DefaultPoolConfig 返回默认连接池配置
|
||||
func DefaultPoolConfig() *PoolConfig {
|
||||
return &PoolConfig{
|
||||
MaxOpenConns: 50, // 最大50个连接(提高并发)
|
||||
MaxIdleConns: 20, // 最大20个空闲(提高响应速度)
|
||||
ConnMaxLifetime: 60 * time.Minute, // 连接最长60分钟(延长连接生命周期)
|
||||
ConnMaxIdleTime: 15 * time.Minute, // 空闲15分钟关闭(更长的空闲时间)
|
||||
MinIdleConns: 5, // 保持5个最小空闲(更好的响应性能)
|
||||
ConnTimeout: 3 * time.Second, // 连接超时3秒(更快失败)
|
||||
HealthCheckInterval: 20 * time.Second, // 20秒健康检查一次(更频繁的健康检查)
|
||||
EnableWarmup: true, // 启用预热
|
||||
EnableSlowConnLog: true, // 启用慢连接日志
|
||||
SlowConnThreshold: 200 * time.Millisecond, // 超过200ms算慢连接(更严格的性能要求)
|
||||
MaxPoolCapacity: 100, // 连接池最大容量(支持更高并发)
|
||||
|
||||
// 动态连接池配置(更智能的调整策略)
|
||||
EnableDynamicScaling: true, // 启用动态调整
|
||||
DynamicScaleFactor: 1.8, // 调整因子1.8倍(更激进的扩容)
|
||||
ScaleUpThreshold: 0.7, // 使用率超过70%扩容(更早扩容)
|
||||
ScaleDownThreshold: 0.4, // 使用率低于40%缩容(避免频繁调整)
|
||||
MinScaleUpInterval: 1 * time.Minute, // 最小扩容间隔1分钟(更快的响应)
|
||||
MinScaleDownInterval: 3 * time.Minute, // 最小缩容间隔3分钟(稳定缩容)
|
||||
MaxIdleTimeForScale: 20 * time.Minute, // 用于调整的最大空闲时间
|
||||
}
|
||||
}
|
||||
|
||||
// MySQLPoolEntry MySQL 连接池条目
|
||||
type MySQLPoolEntry struct {
|
||||
Client *MySQLClient
|
||||
LastUsed time.Time
|
||||
CreatedAt time.Time
|
||||
InUse bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// AcquireResult 连接获取结果
|
||||
type AcquireResult struct {
|
||||
Entry *MySQLPoolEntry
|
||||
Err error
|
||||
}
|
||||
|
||||
// ReleaseResult 连接释放结果
|
||||
type ReleaseResult struct {
|
||||
Success bool
|
||||
Err error
|
||||
}
|
||||
|
||||
// Stats 连接池统计信息
|
||||
type PoolStats struct {
|
||||
TotalConns int // 总连接数
|
||||
ActiveConns int // 使用中的连接数
|
||||
IdleConns int // 空闲连接数
|
||||
WaitCount int64 // 等待连接的次数
|
||||
WaitDuration time.Duration // 总等待时间
|
||||
SlowConnCount int64 // 慢连接数量
|
||||
}
|
||||
|
||||
// MySQLConnectionPool MySQL 连接池(真正的连接池)
|
||||
type MySQLConnectionPool struct {
|
||||
config *PoolConfig
|
||||
configHash string // 配置哈希,用于检测配置变更
|
||||
mu sync.RWMutex
|
||||
entries []*MySQLPoolEntry // 连接池条目
|
||||
connMap map[uint]*MySQLClient // 连接ID -> 客户端映射(兼容现有代码)
|
||||
stats PoolStats
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// 动态调整相关
|
||||
lastScaleUpTime time.Time // 上次扩容时间
|
||||
lastScaleDownTime time.Time // 上次缩容时间
|
||||
currentTargetSize int // 当前目标连接数
|
||||
usageHistory []float64 // 使用率历史记录(用于智能调整)
|
||||
adaptiveWeights map[uint]float64 // 连接权重(基于性能表现)
|
||||
}
|
||||
|
||||
// NewMySQLConnectionPool 创建新的 MySQL 连接池
|
||||
func NewMySQLConnectionPool(config *PoolConfig) *MySQLConnectionPool {
|
||||
if config == nil {
|
||||
config = DefaultPoolConfig()
|
||||
}
|
||||
|
||||
pool := &MySQLConnectionPool{
|
||||
config: config,
|
||||
entries: make([]*MySQLPoolEntry, 0, config.MaxPoolCapacity),
|
||||
connMap: make(map[uint]*MySQLClient),
|
||||
stopCh: make(chan struct{}),
|
||||
currentTargetSize: config.MinIdleConns,
|
||||
usageHistory: make([]float64, 0, 100), // 保留最近100个使用率记录
|
||||
adaptiveWeights: make(map[uint]float64),
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
// Acquire 获取一个连接(阻塞等待直到有可用连接)
|
||||
func (p *MySQLConnectionPool) Acquire(conn *models.DbConnection) (*MySQLPoolEntry, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 尝试获取最优连接(启用动态调整时)
|
||||
if p.config.EnableDynamicScaling {
|
||||
if entry, err := p.getOptimalConnection(); err == nil {
|
||||
p.updateWaitStats(startTime)
|
||||
return entry, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 降级到标准逻辑 - 查找空闲连接
|
||||
for _, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if !entry.InUse {
|
||||
entry.InUse = true
|
||||
entry.LastUsed = time.Now()
|
||||
entry.mu.Unlock()
|
||||
|
||||
// 更新统计
|
||||
p.updateWaitStats(startTime)
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
// 没有可用连接,创建新连接
|
||||
if len(p.entries) >= p.config.MaxOpenConns {
|
||||
// 已达到最大连接数,等待
|
||||
return p.waitForAvailableConnection(conn)
|
||||
}
|
||||
|
||||
// 创建新连接(使用传入的连接配置)
|
||||
newEntry, err := p.createNewEntry(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建连接失败: %v", err)
|
||||
}
|
||||
|
||||
p.entries = append(p.entries, newEntry)
|
||||
p.updateStats()
|
||||
p.updateWaitStats(startTime)
|
||||
|
||||
return newEntry, nil
|
||||
}
|
||||
|
||||
// Release 释放连接回池中
|
||||
func (p *MySQLConnectionPool) Release(entry *MySQLPoolEntry) error {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
entry.InUse = false
|
||||
entry.LastUsed = time.Now()
|
||||
entry.mu.Unlock()
|
||||
|
||||
p.updateStats()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭连接池
|
||||
func (p *MySQLConnectionPool) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 发送停止信号
|
||||
close(p.stopCh)
|
||||
|
||||
// 等待所有 goroutine 完成
|
||||
p.wg.Wait()
|
||||
|
||||
// 关闭所有连接
|
||||
var lastErr error
|
||||
for _, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if err := entry.Client.Close(); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
entry.InUse = false
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
p.entries = make([]*MySQLPoolEntry, 0, p.config.MaxPoolCapacity)
|
||||
p.connMap = make(map[uint]*MySQLClient)
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Stats 获取连接池统计信息
|
||||
func (p *MySQLConnectionPool) Stats() PoolStats {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.stats
|
||||
}
|
||||
|
||||
// cleanupIdleConnections 清理空闲连接
|
||||
func (p *MySQLConnectionPool) cleanupIdleConnections() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
keepEntries := make([]*MySQLPoolEntry, 0, len(p.entries))
|
||||
|
||||
for _, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
isIdle := !entry.InUse
|
||||
idleDuration := now.Sub(entry.LastUsed)
|
||||
entry.mu.Unlock()
|
||||
|
||||
// 保留条件:正在使用 或 空闲时间未超过阈值 或 数量少于最小空闲数
|
||||
keep := !isIdle ||
|
||||
idleDuration < p.config.ConnMaxIdleTime ||
|
||||
len(keepEntries) < p.config.MinIdleConns
|
||||
|
||||
if keep {
|
||||
keepEntries = append(keepEntries, entry)
|
||||
} else {
|
||||
// 关闭连接
|
||||
entry.Client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
p.entries = keepEntries
|
||||
p.updateStats()
|
||||
}
|
||||
|
||||
// healthCheck 健康检查(增强版本)
|
||||
func (p *MySQLConnectionPool) healthCheck() {
|
||||
p.enhancedHealthCheck()
|
||||
}
|
||||
|
||||
// StartMaintenance 启动维护协程(清理和健康检查)
|
||||
func (p *MySQLConnectionPool) StartMaintenance() {
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
|
||||
// 健康检查Ticker
|
||||
healthTicker := time.NewTicker(p.config.HealthCheckInterval)
|
||||
defer healthTicker.Stop()
|
||||
|
||||
// 动态调整Ticker(较短间隔)
|
||||
scaleTicker := time.NewTicker(1 * time.Minute)
|
||||
defer scaleTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-healthTicker.C:
|
||||
// 清理空闲连接
|
||||
p.cleanupIdleConnections()
|
||||
// 健康检查
|
||||
p.healthCheck()
|
||||
|
||||
case <-scaleTicker.C:
|
||||
// 动态连接池调整
|
||||
if p.config.EnableDynamicScaling {
|
||||
p.adaptiveScaling()
|
||||
}
|
||||
|
||||
case <-p.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// createNewEntry 创建新的连接池条目
|
||||
func (p *MySQLConnectionPool) createNewEntry(conn *models.DbConnection) (*MySQLPoolEntry, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
client, err := createMySQLClient(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
// 慢连接日志
|
||||
if p.config.EnableSlowConnLog && elapsed > p.config.SlowConnThreshold {
|
||||
// 记录慢连接
|
||||
p.mu.Lock()
|
||||
p.stats.SlowConnCount++
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
entry := &MySQLPoolEntry{
|
||||
Client: client,
|
||||
LastUsed: time.Now(),
|
||||
CreatedAt: startTime,
|
||||
InUse: true,
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// waitForAvailableConnection 等待可用连接并获取它
|
||||
func (p *MySQLConnectionPool) waitForAvailableConnection(conn *models.DbConnection) (*MySQLPoolEntry, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ErrPoolExhausted
|
||||
case <-ticker.C:
|
||||
p.mu.Lock()
|
||||
for _, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if !entry.InUse {
|
||||
entry.InUse = true
|
||||
entry.LastUsed = time.Now()
|
||||
entry.mu.Unlock()
|
||||
p.mu.Unlock()
|
||||
return entry, nil
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateWaitStats 更新等待统计(调用方必须持有 p.mu)
|
||||
func (p *MySQLConnectionPool) updateWaitStats(startTime time.Time) {
|
||||
p.stats.WaitCount++
|
||||
p.stats.WaitDuration += time.Since(startTime)
|
||||
}
|
||||
|
||||
// updateStats 更新连接池统计
|
||||
func (p *MySQLConnectionPool) updateStats() {
|
||||
total := len(p.entries)
|
||||
active := 0
|
||||
idle := 0
|
||||
|
||||
for _, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if entry.InUse {
|
||||
active++
|
||||
} else {
|
||||
idle++
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
p.stats.TotalConns = total
|
||||
p.stats.ActiveConns = active
|
||||
p.stats.IdleConns = idle
|
||||
}
|
||||
|
||||
// adaptiveScaling 自适应连接池调整
|
||||
func (p *MySQLConnectionPool) adaptiveScaling() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// 计算当前使用率
|
||||
if len(p.entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
usageRate := float64(p.stats.ActiveConns) / float64(len(p.entries))
|
||||
|
||||
// 记录使用率历史
|
||||
p.usageHistory = append(p.usageHistory, usageRate)
|
||||
if len(p.usageHistory) > 100 {
|
||||
p.usageHistory = p.usageHistory[1:]
|
||||
}
|
||||
|
||||
// 检查是否需要调整
|
||||
now := time.Now()
|
||||
|
||||
// 扩容逻辑
|
||||
if usageRate >= p.config.ScaleUpThreshold {
|
||||
if now.Sub(p.lastScaleUpTime) >= p.config.MinScaleUpInterval {
|
||||
p.scaleUp()
|
||||
p.lastScaleUpTime = now
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 缩容逻辑
|
||||
if usageRate <= p.config.ScaleDownThreshold && len(p.entries) > p.config.MinIdleConns {
|
||||
if now.Sub(p.lastScaleDownTime) >= p.config.MinScaleDownInterval {
|
||||
p.scaleDown()
|
||||
p.lastScaleDownTime = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scaleUp 扩容
|
||||
func (p *MySQLConnectionPool) scaleUp() {
|
||||
// scaleUp 仅更新目标大小,实际连接在 Acquire 时按需创建
|
||||
// 移除了创建无效虚拟连接的逻辑
|
||||
currentSize := len(p.entries)
|
||||
scaleFactor := p.config.DynamicScaleFactor
|
||||
|
||||
newSize := int(float64(currentSize) * scaleFactor)
|
||||
newSize = min(newSize, p.config.MaxOpenConns)
|
||||
newSize = max(newSize, currentSize+1)
|
||||
|
||||
p.currentTargetSize = newSize
|
||||
p.updateStats()
|
||||
}
|
||||
|
||||
// scaleDown 缩容
|
||||
func (p *MySQLConnectionPool) scaleDown() {
|
||||
// 计算新目标大小
|
||||
currentSize := len(p.entries)
|
||||
scaleFactor := 1.0 / p.config.DynamicScaleFactor
|
||||
|
||||
newSize := int(float64(currentSize) * scaleFactor)
|
||||
newSize = max(newSize, p.config.MinIdleConns)
|
||||
newSize = min(newSize, currentSize-1) // 至少减少1个连接
|
||||
|
||||
if newSize < currentSize {
|
||||
// 关闭多余的空闲连接
|
||||
p.closeIdleConnections(currentSize - newSize)
|
||||
p.currentTargetSize = newSize
|
||||
p.updateStats()
|
||||
}
|
||||
}
|
||||
|
||||
// closeIdleConnections 关闭指定数量的空闲连接
|
||||
func (p *MySQLConnectionPool) closeIdleConnections(count int) {
|
||||
// 收集空闲连接
|
||||
idleEntries := make([]*MySQLPoolEntry, 0)
|
||||
for _, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if !entry.InUse {
|
||||
idleEntries = append(idleEntries, entry)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
// 关闭指定数量的空闲连接
|
||||
closedEntries := make(map[*MySQLPoolEntry]bool)
|
||||
for i := 0; i < min(count, len(idleEntries)); i++ {
|
||||
entry := idleEntries[i]
|
||||
entry.mu.Lock()
|
||||
entry.Client.Close()
|
||||
entry.mu.Unlock()
|
||||
closedEntries[entry] = true
|
||||
}
|
||||
|
||||
// 重新构建连接池
|
||||
remainingEntries := make([]*MySQLPoolEntry, 0, len(p.entries))
|
||||
for _, entry := range p.entries {
|
||||
if closedEntries[entry] {
|
||||
continue // 跳过已关闭的连接
|
||||
}
|
||||
remainingEntries = append(remainingEntries, entry)
|
||||
}
|
||||
|
||||
p.entries = remainingEntries
|
||||
}
|
||||
|
||||
// enhancedHealthCheck 增强的健康检查
|
||||
func (p *MySQLConnectionPool) enhancedHealthCheck() {
|
||||
p.mu.RLock()
|
||||
entriesCopy := make([]*MySQLPoolEntry, len(p.entries))
|
||||
copy(entriesCopy, p.entries)
|
||||
p.mu.RUnlock()
|
||||
|
||||
var healthyEntries []*MySQLPoolEntry
|
||||
var performanceWeights []float64
|
||||
|
||||
for _, entry := range entriesCopy {
|
||||
entry.mu.Lock()
|
||||
isIdle := !entry.InUse
|
||||
|
||||
// 测试连接有效性
|
||||
isHealthy := true
|
||||
startTime := time.Now()
|
||||
|
||||
if isIdle {
|
||||
// 空闲连接:简单Ping测试
|
||||
if err := entry.Client.sqlDB.Ping(); err != nil {
|
||||
isHealthy = false
|
||||
// 关闭失效连接
|
||||
entry.Client.Close()
|
||||
}
|
||||
} else {
|
||||
// 使用中的连接:快速测试(避免影响正常查询)
|
||||
func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := entry.Client.sqlDB.PingContext(ctx); err != nil {
|
||||
isHealthy = false
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 计算连接性能权重
|
||||
if isHealthy {
|
||||
healthyEntries = append(healthyEntries, entry)
|
||||
|
||||
// 基于连接性能计算权重
|
||||
responseTime := time.Since(startTime).Microseconds()
|
||||
weight := 1.0 / max(float64(responseTime)/1000.0, 1.0) // 转换为毫秒,避免除零
|
||||
|
||||
performanceWeights = append(performanceWeights, weight)
|
||||
} else {
|
||||
// 不健康的连接
|
||||
if isIdle {
|
||||
entry.Client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
// 更新连接池
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.entries = healthyEntries
|
||||
|
||||
// 更新自适应权重
|
||||
if len(healthyEntries) > 0 {
|
||||
for i := range healthyEntries {
|
||||
if i < len(performanceWeights) {
|
||||
p.adaptiveWeights[uint(i)] = performanceWeights[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.updateStats()
|
||||
}
|
||||
|
||||
// warmUp 连接池预热
|
||||
func (p *MySQLConnectionPool) warmUp() {
|
||||
if !p.config.EnableWarmup {
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
currentIdle := 0
|
||||
for _, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if !entry.InUse {
|
||||
currentIdle++
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
targetIdle := p.config.MinIdleConns
|
||||
needed := targetIdle - currentIdle
|
||||
|
||||
// warmUp 仅记录目标大小,不在无连接配置的情况下创建无效虚拟连接
|
||||
// 实际连接在 Acquire 时按需创建
|
||||
_ = needed
|
||||
|
||||
p.updateStats()
|
||||
}
|
||||
|
||||
// getOptimalConnection 获取最优连接(基于性能权重)
|
||||
// 注意:调用方必须已持有 p.mu
|
||||
func (p *MySQLConnectionPool) getOptimalConnection() (*MySQLPoolEntry, error) {
|
||||
var bestEntry *MySQLPoolEntry
|
||||
var bestWeight float64
|
||||
|
||||
for i, entry := range p.entries {
|
||||
entry.mu.Lock()
|
||||
if !entry.InUse {
|
||||
weight := 1.0 // 默认权重
|
||||
if w, ok := p.adaptiveWeights[uint(i)]; ok {
|
||||
weight = w
|
||||
}
|
||||
|
||||
if bestEntry == nil || weight > bestWeight {
|
||||
bestEntry = entry
|
||||
bestWeight = weight
|
||||
}
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
if bestEntry == nil {
|
||||
return nil, ErrPoolExhausted
|
||||
}
|
||||
|
||||
bestEntry.InUse = true
|
||||
bestEntry.LastUsed = time.Now()
|
||||
return bestEntry, nil
|
||||
}
|
||||
|
||||
// createMySQLClient 创建 MySQL 客户端的辅助函数
|
||||
func createMySQLClient(conn *models.DbConnection) (*MySQLClient, error) {
|
||||
// 解密密码
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
config := &MySQLConfig{
|
||||
Host: conn.Host,
|
||||
Port: conn.Port,
|
||||
Username: conn.Username,
|
||||
Password: password,
|
||||
Database: conn.Database,
|
||||
}
|
||||
|
||||
return NewMySQLClient(config)
|
||||
}
|
||||
|
||||
// 错误定义
|
||||
var (
|
||||
ErrPoolExhausted = &PoolError{Message: "连接池已耗尽"}
|
||||
ErrPoolClosed = &PoolError{Message: "连接池已关闭"}
|
||||
)
|
||||
|
||||
// PoolError 连接池错误
|
||||
type PoolError struct {
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PoolError) Error() string {
|
||||
if e.Err != nil {
|
||||
return e.Message + ": " + e.Err.Error()
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
@@ -1,762 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
reLimitOffset = regexp.MustCompile(`limit\s+(\d+)(?:\s*,\s*(\d+))?`)
|
||||
reFromTable = regexp.MustCompile(`(?i)from\s+([^\s,]+)`)
|
||||
reWhereClause = regexp.MustCompile(`(?i)where\s+(.*?)(?:\s+order\s+by|\s+limit|\s+group\s+by|$)`)
|
||||
reOrderBy = regexp.MustCompile(`(?i)order\s+by\s+(.*?)(?:\s+limit|$)`)
|
||||
reBatchOperation = regexp.MustCompile(`(?i)^\s*(INSERT|UPDATE|DELETE).*VALUES\s*\(`)
|
||||
)
|
||||
|
||||
// CachedQuery 缓存查询结果
|
||||
type CachedQuery struct {
|
||||
Result *QueryResult
|
||||
ExpiryTime time.Time
|
||||
CreatedAt time.Time
|
||||
QueryHash string
|
||||
QueryParams QueryParams
|
||||
LastUsed time.Time // 最后使用时间(用于LRU策略)
|
||||
AccessCount int64 // 访问次数(用于LFU策略)
|
||||
}
|
||||
|
||||
// QueryParams 查询参数(用于缓存键生成)
|
||||
type QueryParams struct {
|
||||
SQL string
|
||||
Database string
|
||||
Limit int
|
||||
Offset int
|
||||
Table string
|
||||
Where string
|
||||
SortBy string
|
||||
IsReadOnly bool
|
||||
}
|
||||
|
||||
// QueryStats 查询统计信息
|
||||
type QueryStats struct {
|
||||
TotalQueries int64
|
||||
CachedQueries int64
|
||||
SlowQueries int64
|
||||
TotalDuration time.Duration
|
||||
AverageDuration time.Duration
|
||||
CacheHitRate float64
|
||||
LastCacheUpdate time.Time
|
||||
}
|
||||
|
||||
// SlowQuery 慢查询记录
|
||||
type SlowQuery struct {
|
||||
Query string
|
||||
Database string
|
||||
Duration time.Duration
|
||||
Timestamp time.Time
|
||||
Params QueryParams
|
||||
Table string
|
||||
IndexUsed string
|
||||
RowsAffected int64
|
||||
Error error
|
||||
}
|
||||
|
||||
// IndexSuggestion 索引建议
|
||||
type IndexSuggestion struct {
|
||||
Table string
|
||||
Columns []string
|
||||
IndexType string // "normal", "unique", "fulltext"
|
||||
Priority string // "high", "medium", "low"
|
||||
Query string
|
||||
Justification string
|
||||
CanBeApplied bool
|
||||
}
|
||||
|
||||
// QueryOptimizer 查询优化器
|
||||
type QueryOptimizer struct {
|
||||
cache *QueryCache
|
||||
stats *QueryStats
|
||||
slowQueries []SlowQuery
|
||||
indexSuggestions []IndexSuggestion
|
||||
mu sync.RWMutex
|
||||
config *OptimizerConfig
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// OptimizerConfig 查询优化器配置
|
||||
type OptimizerConfig struct {
|
||||
// 缓存配置
|
||||
CacheSize int // 最大缓存条目数
|
||||
CacheTTL time.Duration // 缓存过期时间
|
||||
EnableCache bool // 是否启用缓存
|
||||
|
||||
// 慢查询配置
|
||||
SlowQueryThreshold time.Duration // 慢查询阈值
|
||||
EnableSlowLog bool // 是否启用慢查询日志
|
||||
MaxSlowLogs int // 最大慢查询记录数
|
||||
|
||||
// 索引建议配置
|
||||
EnableIndexSuggestions bool // 是否启用索引建议
|
||||
MaxSuggestions int // 最大索引建议数
|
||||
|
||||
// 查询分析配置
|
||||
EnableQueryAnalysis bool // 是否启用查询分析
|
||||
MaxAnalysisDepth int // 查询分析深度
|
||||
}
|
||||
|
||||
// DefaultOptimizerConfig 返回默认的查询优化器配置
|
||||
func DefaultOptimizerConfig() *OptimizerConfig {
|
||||
return &OptimizerConfig{
|
||||
CacheSize: 1000, // 最多缓存1000个查询
|
||||
CacheTTL: 30 * time.Minute, // 缓存30分钟
|
||||
EnableCache: true, // 启用缓存
|
||||
SlowQueryThreshold: 100 * time.Millisecond, // 100ms以上为慢查询
|
||||
EnableSlowLog: true, // 启用慢查询日志
|
||||
MaxSlowLogs: 1000, // 最多记录1000条慢查询
|
||||
EnableIndexSuggestions: true, // 启用索引建议
|
||||
MaxSuggestions: 100, // 最多100个索引建议
|
||||
EnableQueryAnalysis: true, // 启用查询分析
|
||||
MaxAnalysisDepth: 3, // 分析深度3
|
||||
}
|
||||
}
|
||||
|
||||
// NewQueryOptimizer 创建新的查询优化器
|
||||
func NewQueryOptimizer(config *OptimizerConfig) *QueryOptimizer {
|
||||
if config == nil {
|
||||
config = DefaultOptimizerConfig()
|
||||
}
|
||||
|
||||
optimizer := &QueryOptimizer{
|
||||
cache: NewQueryCache(config.CacheSize, config.CacheTTL),
|
||||
stats: &QueryStats{},
|
||||
config: config,
|
||||
stopCh: make(chan struct{}),
|
||||
slowQueries: make([]SlowQuery, 0),
|
||||
indexSuggestions: make([]IndexSuggestion, 0),
|
||||
}
|
||||
|
||||
// 启动维护协程
|
||||
optimizer.StartMaintenance()
|
||||
|
||||
return optimizer
|
||||
}
|
||||
|
||||
// OptimizeQuery 优化查询执行
|
||||
func (o *QueryOptimizer) OptimizeQuery(ctx context.Context, client *MySQLClient, sqlStr string, database string) (*QueryResult, time.Duration, error) {
|
||||
startTime := time.Now()
|
||||
queryParams := o.parseQueryParams(sqlStr, database)
|
||||
|
||||
// 检查缓存
|
||||
if o.config.EnableCache && queryParams.IsReadOnly {
|
||||
cached, err := o.cache.Get(queryParams)
|
||||
if err == nil && cached != nil {
|
||||
o.recordCacheHit()
|
||||
return cached.Result, time.Since(startTime), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
result, err := client.ExecuteQuery(ctx, sqlStr, database)
|
||||
if err != nil {
|
||||
duration := time.Since(startTime)
|
||||
o.recordSlowQuery(sqlStr, database, duration, queryParams, result, err)
|
||||
return nil, duration, err
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// 检查是否为慢查询
|
||||
if duration > o.config.SlowQueryThreshold {
|
||||
o.recordSlowQuery(sqlStr, database, duration, queryParams, result, err)
|
||||
}
|
||||
|
||||
// 缓存只读查询结果
|
||||
if o.config.EnableCache && queryParams.IsReadOnly && err == nil {
|
||||
cachedResult := &CachedQuery{
|
||||
Result: result,
|
||||
ExpiryTime: time.Now().Add(o.config.CacheTTL),
|
||||
CreatedAt: time.Now(),
|
||||
QueryHash: o.generateQueryHash(queryParams),
|
||||
QueryParams: queryParams,
|
||||
LastUsed: time.Now(),
|
||||
AccessCount: 1,
|
||||
}
|
||||
o.cache.Set(queryParams, cachedResult)
|
||||
}
|
||||
|
||||
o.recordQuery(duration)
|
||||
return result, duration, err
|
||||
}
|
||||
|
||||
// ExecuteOptimizedUpdate 执行优化的更新操作
|
||||
func (o *QueryOptimizer) ExecuteOptimizedUpdate(ctx context.Context, client *MySQLClient, sqlStr string, database string) (int64, time.Duration, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// 分析更新查询
|
||||
queryParams := o.parseQueryParams(sqlStr, database)
|
||||
|
||||
// 检查是否为批量操作
|
||||
if o.isBatchOperation(sqlStr) {
|
||||
// 优化批量操作
|
||||
rowsAffected, duration, err := o.optimizeBatchUpdate(ctx, client, sqlStr, database)
|
||||
if err != nil {
|
||||
o.recordSlowQuery(sqlStr, database, duration, queryParams, nil, err)
|
||||
return 0, duration, err
|
||||
}
|
||||
|
||||
o.recordQuery(duration)
|
||||
return rowsAffected, duration, nil
|
||||
}
|
||||
|
||||
// 执行普通更新
|
||||
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, database)
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if duration > o.config.SlowQueryThreshold {
|
||||
o.recordSlowQuery(sqlStr, database, duration, queryParams, nil, err)
|
||||
}
|
||||
|
||||
o.recordQuery(duration)
|
||||
return rowsAffected, duration, err
|
||||
}
|
||||
|
||||
// GetIndexSuggestions 获取索引建议
|
||||
func (o *QueryOptimizer) GetIndexSuggestions(table string) []IndexSuggestion {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
var suggestions []IndexSuggestion
|
||||
for _, suggestion := range o.indexSuggestions {
|
||||
if suggestion.Table == table || table == "" {
|
||||
suggestions = append(suggestions, suggestion)
|
||||
}
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// GenerateIndexSuggestions 为表生成索引建议
|
||||
func (o *QueryOptimizer) GenerateIndexSuggestions(ctx context.Context, client *MySQLClient, database, table string) error {
|
||||
// 获取表的慢查询记录
|
||||
tableSlowQueries := o.getTableSlowQueries(database, table)
|
||||
|
||||
// 分析查询模式
|
||||
for _, slowQuery := range tableSlowQueries {
|
||||
suggestions := o.analyzeQueryForIndexes(slowQuery.Query, table)
|
||||
o.mu.Lock()
|
||||
o.indexSuggestions = append(o.indexSuggestions, suggestions...)
|
||||
|
||||
// 限制建议数量
|
||||
if len(o.indexSuggestions) > o.config.MaxSuggestions {
|
||||
o.indexSuggestions = o.indexSuggestions[:o.config.MaxSuggestions]
|
||||
}
|
||||
o.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQueryStats 获取查询统计信息
|
||||
func (o *QueryOptimizer) GetQueryStats() QueryStats {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
return *o.stats
|
||||
}
|
||||
|
||||
// GetSlowQueries 获取慢查询记录
|
||||
func (o *QueryOptimizer) GetSlowQueries(limit int) []SlowQuery {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
if limit <= 0 || limit > len(o.slowQueries) {
|
||||
limit = len(o.slowQueries)
|
||||
}
|
||||
|
||||
return o.slowQueries[:limit]
|
||||
}
|
||||
|
||||
// ClearCache 清空缓存
|
||||
func (o *QueryOptimizer) ClearCache() {
|
||||
o.cache.Clear()
|
||||
}
|
||||
|
||||
// Stop 停止优化器
|
||||
func (o *QueryOptimizer) Stop() {
|
||||
close(o.stopCh)
|
||||
o.wg.Wait()
|
||||
}
|
||||
|
||||
// parseQueryParams 解析查询参数
|
||||
func (o *QueryOptimizer) parseQueryParams(sqlStr, database string) QueryParams {
|
||||
params := QueryParams{
|
||||
SQL: sqlStr,
|
||||
Database: database,
|
||||
}
|
||||
|
||||
// 解析LIMIT和OFFSET
|
||||
limit, offset := o.parseLimitOffset(sqlStr)
|
||||
params.Limit = limit
|
||||
params.Offset = offset
|
||||
|
||||
// 解析表名
|
||||
tables := o.parseTables(sqlStr)
|
||||
if len(tables) > 0 {
|
||||
params.Table = tables[0]
|
||||
}
|
||||
|
||||
// 解析WHERE条件
|
||||
where := o.parseWhereCondition(sqlStr)
|
||||
params.Where = where
|
||||
|
||||
// 解析排序
|
||||
sort := o.parseSortOrder(sqlStr)
|
||||
params.SortBy = sort
|
||||
|
||||
// 判断是否为只读查询
|
||||
params.IsReadOnly = o.isReadOnlyQuery(sqlStr)
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// parseLimitOffset 解析LIMIT和OFFSET
|
||||
func (o *QueryOptimizer) parseLimitOffset(sqlStr string) (limit, offset int) {
|
||||
sqlStr = strings.ToLower(sqlStr)
|
||||
|
||||
matches := reLimitOffset.FindStringSubmatch(sqlStr)
|
||||
|
||||
if len(matches) > 1 {
|
||||
fmt.Sscanf(matches[1], "%d", &limit)
|
||||
if len(matches) > 2 && matches[2] != "" {
|
||||
fmt.Sscanf(matches[2], "%d", &offset)
|
||||
}
|
||||
}
|
||||
|
||||
// MySQL LIMIT offset, count: matches[1]=offset, matches[2]=count
|
||||
if len(matches) > 2 && matches[2] != "" {
|
||||
offset, limit = limit, offset
|
||||
}
|
||||
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
// parseTables 解析查询中的表名
|
||||
func (o *QueryOptimizer) parseTables(sqlStr string) []string {
|
||||
// 简单实现:解析FROM和JOIN中的表名
|
||||
tables := make([]string, 0)
|
||||
|
||||
fromMatches := reFromTable.FindAllStringSubmatch(sqlStr, -1)
|
||||
|
||||
for _, match := range fromMatches {
|
||||
if len(match) > 1 {
|
||||
tableName := strings.Trim(match[1], "`\"'[]")
|
||||
tables = append(tables, tableName)
|
||||
}
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
// parseWhereCondition 解析WHERE条件
|
||||
func (o *QueryOptimizer) parseWhereCondition(sqlStr string) string {
|
||||
matches := reWhereClause.FindStringSubmatch(sqlStr)
|
||||
|
||||
if len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseSortOrder 解析排序条件
|
||||
func (o *QueryOptimizer) parseSortOrder(sqlStr string) string {
|
||||
matches := reOrderBy.FindStringSubmatch(sqlStr)
|
||||
|
||||
if len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isReadOnlyQuery 判断是否为只读查询
|
||||
func (o *QueryOptimizer) isReadOnlyQuery(sqlStr string) bool {
|
||||
sqlStr = strings.ToUpper(strings.TrimSpace(sqlStr))
|
||||
|
||||
// SELECT只读查询
|
||||
if strings.HasPrefix(sqlStr, "SELECT") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 支持的只读查询类型
|
||||
readOnlyQueries := []string{
|
||||
"SHOW", "DESCRIBE", "DESC", "EXPLAIN",
|
||||
"WITH", "UNION", "INTERSECT", "EXCEPT",
|
||||
}
|
||||
|
||||
for _, query := range readOnlyQueries {
|
||||
if strings.HasPrefix(sqlStr, query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isBatchOperation 判断是否为批量操作
|
||||
func (o *QueryOptimizer) isBatchOperation(sqlStr string) bool {
|
||||
return reBatchOperation.MatchString(sqlStr)
|
||||
}
|
||||
|
||||
// generateQueryHash 生成查询哈希
|
||||
func (o *QueryOptimizer) generateQueryHash(params QueryParams) string {
|
||||
hashData := fmt.Sprintf("%s|%s|%d|%d|%s|%s|%s|%v",
|
||||
params.SQL, params.Database, params.Limit, params.Offset,
|
||||
params.Table, params.Where, params.SortBy, params.IsReadOnly)
|
||||
|
||||
h := sha256.Sum256([]byte(hashData))
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
|
||||
// recordQuery 记录查询统计
|
||||
func (o *QueryOptimizer) recordQuery(duration time.Duration) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.stats.TotalQueries++
|
||||
o.stats.TotalDuration += duration
|
||||
o.stats.AverageDuration = time.Duration(int64(float64(o.stats.TotalDuration) / float64(o.stats.TotalQueries)))
|
||||
|
||||
now := time.Now()
|
||||
if o.stats.LastCacheUpdate.IsZero() || now.Sub(o.stats.LastCacheUpdate) > 5*time.Minute {
|
||||
// 更新缓存命中率
|
||||
total := o.stats.TotalQueries
|
||||
hit := o.stats.CachedQueries
|
||||
o.stats.CacheHitRate = float64(hit) / float64(total) * 100
|
||||
o.stats.LastCacheUpdate = now
|
||||
}
|
||||
}
|
||||
|
||||
// recordCacheHit 记录缓存命中
|
||||
func (o *QueryOptimizer) recordCacheHit() {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.stats.CachedQueries++
|
||||
}
|
||||
|
||||
// recordSlowQuery 记录慢查询
|
||||
func (o *QueryOptimizer) recordSlowQuery(query, database string, duration time.Duration, params QueryParams, result *QueryResult, err error) {
|
||||
if !o.config.EnableSlowLog {
|
||||
return
|
||||
}
|
||||
|
||||
slowQuery := SlowQuery{
|
||||
Query: query,
|
||||
Database: database,
|
||||
Duration: duration,
|
||||
Timestamp: time.Now(),
|
||||
Params: params,
|
||||
Table: params.Table,
|
||||
IndexUsed: o.extractIndexUsed(query),
|
||||
RowsAffected: o.extractRowsAffected(result),
|
||||
Error: err,
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.slowQueries = append(o.slowQueries, slowQuery)
|
||||
|
||||
// 限制慢查询记录数量
|
||||
if len(o.slowQueries) > o.config.MaxSlowLogs {
|
||||
o.slowQueries = o.slowQueries[1:]
|
||||
}
|
||||
|
||||
o.stats.SlowQueries++
|
||||
}
|
||||
|
||||
// extractIndexUsed 提取使用的索引
|
||||
func (o *QueryOptimizer) extractIndexUsed(query string) string {
|
||||
// 简单实现:从EXPLAIN结果中提取索引信息
|
||||
// 实际项目中应该执行EXPLAIN语句分析
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// extractRowsAffected 提取影响的行数
|
||||
func (o *QueryOptimizer) extractRowsAffected(result *QueryResult) int64 {
|
||||
if result != nil && len(result.Data) > 0 {
|
||||
if rows, ok := result.Data[0]["rows_affected"].(int64); ok {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// analyzeQuery 分析查询性能
|
||||
func (o *QueryOptimizer) analyzeQuery(query, database string, result *QueryResult, duration time.Duration) {
|
||||
// 这里可以实现更复杂的查询分析逻辑
|
||||
// 比如分析查询计划、检测N+1查询问题等
|
||||
|
||||
// 简单实现:记录查询到统计信息中
|
||||
_ = query
|
||||
_ = database
|
||||
_ = result
|
||||
_ = duration
|
||||
}
|
||||
|
||||
// analyzeQueryForIndexes 分析查询为索引建议
|
||||
func (o *QueryOptimizer) analyzeQueryForIndexes(query, table string) []IndexSuggestion {
|
||||
var suggestions []IndexSuggestion
|
||||
|
||||
// 解析查询中的WHERE条件
|
||||
where := o.parseWhereCondition(query)
|
||||
if where != "" {
|
||||
// 提取WHERE条件中的列
|
||||
columns := o.extractColumnsFromWhere(where)
|
||||
|
||||
if len(columns) > 0 {
|
||||
// 创建索引建议
|
||||
suggestion := IndexSuggestion{
|
||||
Table: table,
|
||||
Columns: columns,
|
||||
IndexType: "normal",
|
||||
Priority: "medium",
|
||||
Query: query,
|
||||
Justification: fmt.Sprintf("查询经常使用WHERE条件 %s", where),
|
||||
CanBeApplied: true,
|
||||
}
|
||||
suggestions = append(suggestions, suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析ORDER BY条件
|
||||
order := o.parseSortOrder(query)
|
||||
if order != "" {
|
||||
// 提取排序的列
|
||||
columns := o.extractColumnsFromOrder(order)
|
||||
|
||||
if len(columns) > 0 {
|
||||
// 创建排序索引建议
|
||||
suggestion := IndexSuggestion{
|
||||
Table: table,
|
||||
Columns: columns,
|
||||
IndexType: "normal",
|
||||
Priority: "low",
|
||||
Query: query,
|
||||
Justification: fmt.Sprintf("查询经常使用ORDER BY %s", order),
|
||||
CanBeApplied: true,
|
||||
}
|
||||
suggestions = append(suggestions, suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// extractColumnsFromWhere 从WHERE条件中提取列名
|
||||
func (o *QueryOptimizer) extractColumnsFromWhere(where string) []string {
|
||||
// 简单实现:提取WHERE条件中的列名
|
||||
columns := make([]string, 0)
|
||||
|
||||
// 这里可以实现更复杂的列名解析逻辑
|
||||
// 目前只做简单处理
|
||||
words := strings.Fields(where)
|
||||
for _, word := range words {
|
||||
// 去除运算符和引号
|
||||
if !strings.Contains(word, "=") &&
|
||||
!strings.Contains(word, ">") &&
|
||||
!strings.Contains(word, "<") &&
|
||||
!strings.Contains(word, "!=") &&
|
||||
!strings.HasPrefix(word, "'") &&
|
||||
!strings.HasPrefix(word, "\"") {
|
||||
columns = append(columns, strings.Trim(word, " `\"'[]"))
|
||||
}
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// extractColumnsFromOrder 从ORDER BY条件中提取列名
|
||||
func (o *QueryOptimizer) extractColumnsFromOrder(order string) []string {
|
||||
// 简单实现:提取ORDER BY中的列名
|
||||
columns := strings.Split(order, ",")
|
||||
for i, col := range columns {
|
||||
columns[i] = strings.TrimSpace(strings.Split(col, " ")[0])
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
// getTableSlowQueries 获取表的慢查询记录
|
||||
func (o *QueryOptimizer) getTableSlowQueries(database, table string) []SlowQuery {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
var tableQueries []SlowQuery
|
||||
for _, query := range o.slowQueries {
|
||||
if (database == "" || query.Database == database) &&
|
||||
(table == "" || query.Table == table) {
|
||||
tableQueries = append(tableQueries, query)
|
||||
}
|
||||
}
|
||||
return tableQueries
|
||||
}
|
||||
|
||||
// optimizeBatchUpdate 优化批量更新操作
|
||||
func (o *QueryOptimizer) optimizeBatchUpdate(ctx context.Context, client *MySQLClient, sqlStr string, database string) (int64, time.Duration, error) {
|
||||
// 简单实现:执行原始查询
|
||||
// 实际项目中可以实现批量操作优化
|
||||
startTime := time.Now()
|
||||
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, database)
|
||||
duration := time.Since(startTime)
|
||||
return rowsAffected, duration, err
|
||||
}
|
||||
|
||||
// StartMaintenance 启动维护协程
|
||||
func (o *QueryOptimizer) StartMaintenance() {
|
||||
o.wg.Add(1)
|
||||
go func() {
|
||||
defer o.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 清理过期的缓存
|
||||
o.cache.CleanupExpired()
|
||||
|
||||
// 分析慢查询生成新的索引建议
|
||||
o.analyzeSlowQueriesForSuggestions()
|
||||
|
||||
case <-o.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RecordPoolError 记录连接池错误
|
||||
func (o *QueryOptimizer) RecordPoolError(operation string, err error) {
|
||||
if !o.config.EnableSlowLog || err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
poolError := SlowQuery{
|
||||
Query: operation,
|
||||
Database: "pool",
|
||||
Duration: 0,
|
||||
Timestamp: time.Now(),
|
||||
Params: QueryParams{SQL: operation},
|
||||
Table: "connection_pool",
|
||||
IndexUsed: "N/A",
|
||||
RowsAffected: 0,
|
||||
Error: err,
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.slowQueries = append(o.slowQueries, poolError)
|
||||
|
||||
// 限制慢查询记录数量
|
||||
if len(o.slowQueries) > o.config.MaxSlowLogs {
|
||||
o.slowQueries = o.slowQueries[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// analyzeSlowQueriesForSuggestions 分析慢查询生成索引建议
|
||||
func (o *QueryOptimizer) analyzeSlowQueriesForSuggestions() {
|
||||
// 这里可以实现更复杂的慢查询分析逻辑
|
||||
// 比如分析查询模式、统计索引使用情况等
|
||||
|
||||
// 分析慢查询模式
|
||||
o.analyzeSlowQueryPatterns()
|
||||
}
|
||||
|
||||
// analyzeSlowQueryPatterns 分析慢查询模式
|
||||
func (o *QueryOptimizer) analyzeSlowQueryPatterns() {
|
||||
o.mu.RLock()
|
||||
queryTypes := make(map[string]int)
|
||||
tableQueries := make(map[string]int)
|
||||
|
||||
for _, query := range o.slowQueries {
|
||||
queryType := o.detectQueryType(query.Query)
|
||||
queryTypes[queryType]++
|
||||
|
||||
if query.Table != "" {
|
||||
tableQueries[query.Table]++
|
||||
}
|
||||
}
|
||||
o.mu.RUnlock()
|
||||
|
||||
// 根据统计结果生成智能建议(在锁外执行,避免死锁)
|
||||
o.generateSmartSuggestions(queryTypes, tableQueries)
|
||||
}
|
||||
|
||||
// detectQueryType 检测查询类型
|
||||
func (o *QueryOptimizer) detectQueryType(sqlStr string) string {
|
||||
sqlStr = strings.ToUpper(strings.TrimSpace(sqlStr))
|
||||
|
||||
if strings.HasPrefix(sqlStr, "SELECT") {
|
||||
if strings.Contains(sqlStr, "JOIN") {
|
||||
return "SELECT_JOIN"
|
||||
} else if strings.Contains(sqlStr, "GROUP BY") {
|
||||
return "SELECT_GROUP"
|
||||
} else {
|
||||
return "SELECT_SIMPLE"
|
||||
}
|
||||
} else if strings.HasPrefix(sqlStr, "INSERT") {
|
||||
return "INSERT"
|
||||
} else if strings.HasPrefix(sqlStr, "UPDATE") {
|
||||
return "UPDATE"
|
||||
} else if strings.HasPrefix(sqlStr, "DELETE") {
|
||||
return "DELETE"
|
||||
}
|
||||
|
||||
return "OTHER"
|
||||
}
|
||||
|
||||
// generateSmartSuggestions 生成智能建议
|
||||
func (o *QueryOptimizer) generateSmartSuggestions(queryTypes map[string]int, tableQueries map[string]int) {
|
||||
// 分析频繁执行的查询类型
|
||||
var mostFrequentType string
|
||||
var maxCount int
|
||||
|
||||
for queryType, count := range queryTypes {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
mostFrequentType = queryType
|
||||
}
|
||||
}
|
||||
|
||||
// 生成针对性的索引建议
|
||||
switch mostFrequentType {
|
||||
case "SELECT_JOIN":
|
||||
// 为JOIN查询建议复合索引
|
||||
o.generateJoinSuggestions()
|
||||
case "SELECT_GROUP":
|
||||
// 为GROUP BY查询建议索引
|
||||
o.generateGroupSuggestions()
|
||||
case "INSERT":
|
||||
// 为批量插入建议优化
|
||||
o.generateInsertSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
// generateJoinSuggestions 生成JOIN查询建议
|
||||
func (o *QueryOptimizer) generateJoinSuggestions() {
|
||||
}
|
||||
|
||||
// generateGroupSuggestions 生成GROUP BY查询建议
|
||||
func (o *QueryOptimizer) generateGroupSuggestions() {
|
||||
}
|
||||
|
||||
// generateInsertSuggestions 生成批量插入建议
|
||||
func (o *QueryOptimizer) generateInsertSuggestions() {
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisClient Redis 客户端
|
||||
type RedisClient struct {
|
||||
client *redis.Client
|
||||
config *RedisConfig
|
||||
}
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int // 数据库编号,默认 0
|
||||
}
|
||||
|
||||
// NewRedisClient 创建 Redis 客户端
|
||||
func NewRedisClient(config *RedisConfig) (*RedisClient, error) {
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: config.Password,
|
||||
DB: config.DB,
|
||||
DialTimeout: common.TimeoutConnect,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
})
|
||||
|
||||
// 测试连接
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutConnect)
|
||||
defer cancel()
|
||||
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("Redis 连接测试失败: %v", err)
|
||||
}
|
||||
|
||||
return &RedisClient{
|
||||
client: rdb,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestRedisConnection 测试连接
|
||||
func TestRedisConnection(host string, port int, password string) error {
|
||||
config := &RedisConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Password: password,
|
||||
DB: 0,
|
||||
}
|
||||
client, err := NewRedisClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRedisClientByDB 根据参数创建指定 DB 的 Redis 客户端(用于多 DB 场景)
|
||||
func NewRedisClientByDB(host string, port int, password string, dbNum int) (*RedisClient, error) {
|
||||
config := &RedisConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Password: password,
|
||||
DB: dbNum,
|
||||
}
|
||||
return NewRedisClient(config)
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *RedisClient) Close() error {
|
||||
if c.client != nil {
|
||||
return c.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCommand 执行 Redis 命令
|
||||
func (c *RedisClient) ExecuteCommand(ctx context.Context, cmd string, args ...interface{}) (interface{}, error) {
|
||||
return c.client.Do(ctx, append([]interface{}{cmd}, args...)...).Result()
|
||||
}
|
||||
|
||||
// GetKeys 获取 Key 列表(支持 pattern,使用 SCAN 代替 KEYS 以提高性能)
|
||||
func (c *RedisClient) GetKeys(ctx context.Context, pattern string) ([]string, error) {
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
|
||||
var keys []string
|
||||
var cursor uint64
|
||||
const count = 100 // 每次扫描的数量
|
||||
|
||||
for {
|
||||
var err error
|
||||
var batch []string
|
||||
batch, cursor, err = c.client.Scan(ctx, cursor, pattern, count).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys = append(keys, batch...)
|
||||
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetKeyType 获取 Key 类型
|
||||
func (c *RedisClient) GetKeyType(ctx context.Context, key string) (string, error) {
|
||||
return c.client.Type(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetKeyValue 获取 Key 值
|
||||
func (c *RedisClient) GetKeyValue(ctx context.Context, key string) (interface{}, error) {
|
||||
keyType, err := c.GetKeyType(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch keyType {
|
||||
case "string":
|
||||
return c.client.Get(ctx, key).Result()
|
||||
case "list":
|
||||
return c.client.LRange(ctx, key, 0, -1).Result()
|
||||
case "set":
|
||||
return c.client.SMembers(ctx, key).Result()
|
||||
case "zset":
|
||||
// 对于有序集合,返回带分数的结果
|
||||
zMembers, err := c.client.ZRangeWithScores(ctx, key, 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 转换为 map 格式,便于展示
|
||||
result := make([]map[string]interface{}, len(zMembers))
|
||||
for i, member := range zMembers {
|
||||
result[i] = map[string]interface{}{
|
||||
"member": member.Member,
|
||||
"score": member.Score,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
case "hash":
|
||||
return c.client.HGetAll(ctx, key).Result()
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的类型: %s", keyType)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTTL 获取 Key 的 TTL
|
||||
func (c *RedisClient) GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
return c.client.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetKeyInfo 获取 Key 详细信息
|
||||
func (c *RedisClient) GetKeyInfo(ctx context.Context, key string) (map[string]interface{}, error) {
|
||||
info := map[string]interface{}{
|
||||
"key": key,
|
||||
"type": "",
|
||||
"value": nil,
|
||||
"ttl": 0,
|
||||
}
|
||||
|
||||
// 获取 Key 类型
|
||||
keyType, err := c.GetKeyType(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Key 类型失败: %v", err)
|
||||
}
|
||||
info["type"] = keyType
|
||||
|
||||
// 获取 TTL
|
||||
ttl, err := c.GetTTL(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 TTL 失败: %v", err)
|
||||
}
|
||||
info["ttl"] = ttl.Seconds()
|
||||
|
||||
// 获取 Key 值(限制大小,避免过大)
|
||||
value, err := c.GetKeyValue(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Key 值失败: %v", err)
|
||||
}
|
||||
info["value"] = formatValuePreview(value)
|
||||
|
||||
// 获取 Key 长度(使用 STRLEN、HLEN、SCARD、ZCARD)
|
||||
var keyLength int64
|
||||
switch keyType {
|
||||
case "string":
|
||||
keyLength, err = c.client.StrLen(ctx, key).Result()
|
||||
case "list":
|
||||
keyLength, err = c.client.LLen(ctx, key).Result()
|
||||
case "set":
|
||||
keyLength, err = c.client.SCard(ctx, key).Result()
|
||||
case "zset":
|
||||
keyLength, err = c.client.ZCard(ctx, key).Result()
|
||||
case "hash":
|
||||
keyLength, err = c.client.HLen(ctx, key).Result()
|
||||
}
|
||||
if err == nil {
|
||||
info["length"] = keyLength
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// formatValuePreview 格式化值预览(限制长度)
|
||||
func formatValuePreview(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
const maxPreviewLength = 200
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
if len(valueStr) > maxPreviewLength {
|
||||
valueStr = valueStr[:maxPreviewLength] + "..."
|
||||
}
|
||||
|
||||
return valueStr
|
||||
}
|
||||
|
||||
// ListDatabases 获取数据库列表(Redis 使用 DB number)
|
||||
// Redis 没有传统数据库概念,这里返回空数组
|
||||
func (c *RedisClient) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
// Redis 可以使用 DB number 来隔离数据
|
||||
// 这里可以返回当前配置的 DB 或者所有可用的 DB
|
||||
// 为简单起见,返回空数组,让用户直接操作 Key
|
||||
return []string{}, nil
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package dbclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisPipeline Redis Pipeline 操作
|
||||
type RedisPipeline struct {
|
||||
client *RedisClient
|
||||
commands []RedisCommand
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// RedisCommand Redis 命令结构
|
||||
type RedisCommand struct {
|
||||
Command string
|
||||
Args []interface{}
|
||||
Result interface{}
|
||||
Error error
|
||||
}
|
||||
|
||||
// NewRedisPipeline 创建新的 Redis Pipeline
|
||||
func (r *RedisClient) NewPipeline(ctx context.Context) *RedisPipeline {
|
||||
return &RedisPipeline{
|
||||
client: r,
|
||||
commands: make([]RedisCommand, 0),
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCommand 添加命令到 Pipeline
|
||||
func (p *RedisPipeline) AddCommand(command string, args ...interface{}) {
|
||||
p.commands = append(p.commands, RedisCommand{
|
||||
Command: command,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
|
||||
// Execute 使用 go-redis 原生 Pipeline 执行所有命令
|
||||
func (p *RedisPipeline) Execute() ([]interface{}, error) {
|
||||
if len(p.commands) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pipe := p.client.client.Pipeline()
|
||||
|
||||
cmds := make([]*redis.Cmd, len(p.commands))
|
||||
for i, c := range p.commands {
|
||||
cmds[i] = pipe.Do(p.ctx, append([]interface{}{c.Command}, c.Args...)...)
|
||||
}
|
||||
|
||||
// 一次性发送所有命令
|
||||
results := make([]interface{}, len(p.commands))
|
||||
cmdResults, err := pipe.Exec(p.ctx)
|
||||
if err != nil && err != redis.Nil {
|
||||
log.Printf("[RedisPipeline] Exec 错误: %v", err)
|
||||
}
|
||||
|
||||
for i, cmd := range cmds {
|
||||
result, cmdErr := cmd.Result()
|
||||
results[i] = result
|
||||
p.commands[i].Result = result
|
||||
p.commands[i].Error = cmdErr
|
||||
}
|
||||
|
||||
// 如果 Exec 返回了命令结果(部分 Redis 版本),使用它们
|
||||
for i, cr := range cmdResults {
|
||||
if cr.Err() != nil && cr.Err() != redis.Nil {
|
||||
p.commands[i].Error = cr.Err()
|
||||
if i < len(results) {
|
||||
results[i] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = results // 已经通过 cmds 获取
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCommands 获取 Pipeline 中的命令列表
|
||||
func (p *RedisPipeline) GetCommands() []RedisCommand {
|
||||
return p.commands
|
||||
}
|
||||
|
||||
// Len 获取 Pipeline 中的命令数量
|
||||
func (p *RedisPipeline) Len() int {
|
||||
return len(p.commands)
|
||||
}
|
||||
|
||||
// Clear 清空 Pipeline
|
||||
func (p *RedisPipeline) Clear() {
|
||||
p.commands = make([]RedisCommand, 0)
|
||||
}
|
||||
|
||||
// RedisTransaction Redis 事务支持
|
||||
type RedisTransaction struct {
|
||||
client *RedisClient
|
||||
watch []string
|
||||
cmds []RedisCommand
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewRedisTransaction 创建新的 Redis 事务
|
||||
func (r *RedisClient) NewTransaction(ctx context.Context, watch ...string) *RedisTransaction {
|
||||
return &RedisTransaction{
|
||||
client: r,
|
||||
watch: watch,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCommand 添加命令到事务
|
||||
func (tx *RedisTransaction) AddCommand(command string, args ...interface{}) {
|
||||
tx.cmds = append(tx.cmds, RedisCommand{
|
||||
Command: command,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
|
||||
// Exec 使用 go-redis Watch + TxPipeline 执行事务(MULTI/EXEC)
|
||||
func (tx *RedisTransaction) Exec() ([]interface{}, error) {
|
||||
pipe := tx.client.client.TxPipeline()
|
||||
|
||||
// 添加所有命令
|
||||
cmds := make([]*redis.Cmd, len(tx.cmds))
|
||||
for i, c := range tx.cmds {
|
||||
cmds[i] = pipe.Do(tx.ctx, append([]interface{}{c.Command}, c.Args...)...)
|
||||
}
|
||||
|
||||
// TxPipeline 自动发送 MULTI/EXEC
|
||||
results := make([]interface{}, len(tx.cmds))
|
||||
_, err := pipe.Exec(tx.ctx)
|
||||
|
||||
for i, cmd := range cmds {
|
||||
result, cmdErr := cmd.Result()
|
||||
results[i] = result
|
||||
tx.cmds[i].Result = result
|
||||
tx.cmds[i].Error = cmdErr
|
||||
}
|
||||
|
||||
if err != nil && err != redis.Nil {
|
||||
return results, fmt.Errorf("事务执行失败: %v", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -48,6 +49,48 @@ var (
|
||||
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||
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 服务器)
|
||||
type LocalFileServer struct {
|
||||
server *http.Server
|
||||
@@ -75,7 +118,7 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
// 创建服务器(固定端口)
|
||||
server := &http.Server{
|
||||
Addr: "localhost:18765",
|
||||
Addr: "localhost:8073",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
@@ -90,7 +133,7 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
localFileServer = &LocalFileServer{
|
||||
server: server,
|
||||
addr: "localhost:18765",
|
||||
addr: "localhost:8073",
|
||||
}
|
||||
|
||||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
||||
@@ -123,9 +166,20 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
|
||||
|
||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
||||
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
|
||||
// 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀)
|
||||
pathPart := r.URL.Path
|
||||
for strings.HasPrefix(pathPart, "/localfs/") {
|
||||
pathPart = strings.TrimPrefix(pathPart, "/localfs/")
|
||||
}
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path)
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /)
|
||||
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
|
||||
pathPart = "/" + pathPart
|
||||
}
|
||||
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||
@@ -133,33 +187,23 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔒 修复:先进行URL解码,防止路径遍历攻击
|
||||
decodedPath, err := url.QueryUnescape(pathPart)
|
||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
||||
if err != nil {
|
||||
log.Printf("[LocalFileHandler] URL解码失败: %v", err)
|
||||
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
|
||||
switch {
|
||||
case errors.Is(err, ErrPathInvalidEncoding):
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
|
||||
|
||||
// 🔒 修复:在路径转换前检查是否包含危险字符
|
||||
if strings.Contains(decodedPath, "..") {
|
||||
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
|
||||
case errors.Is(err, ErrPathTraversal):
|
||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 路径转换(统一使用反斜杠)
|
||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
||||
filePath = filepath.Clean(filePath)
|
||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||
|
||||
// 安全检查
|
||||
if !isSafePath(filePath) {
|
||||
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
|
||||
case errors.Is(err, ErrPathUnsafe):
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||
|
||||
// 🔒 文件类型白名单检查
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
@@ -459,33 +503,31 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
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
|
||||
}
|
||||
rawPath := r.URL.Query().Get("path")
|
||||
theme := r.URL.Query().Get("theme")
|
||||
if theme == "" {
|
||||
theme = "light"
|
||||
}
|
||||
|
||||
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||
|
||||
// 安全检查
|
||||
if !isSafePath(filePath) {
|
||||
log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath)
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查路径遍历攻击
|
||||
if strings.Contains(filePath, "..") {
|
||||
log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath)
|
||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
|
||||
if err != nil {
|
||||
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
|
||||
switch {
|
||||
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
|
||||
}
|
||||
|
||||
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||
|
||||
// 读取文件
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
|
||||
19
internal/filesystem/file_lock_linux.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package filesystem
|
||||
|
||||
// FileLockChecker 文件锁检查器(Linux 空实现)
|
||||
type FileLockChecker struct{}
|
||||
|
||||
func NewFileLockChecker() *FileLockChecker {
|
||||
return &FileLockChecker{}
|
||||
}
|
||||
|
||||
func (c *FileLockChecker) IsFileLocked(_path string) (bool, string, error) {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func (c *FileLockChecker) SafeDeleteWithLockCheck(_path string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package model
|
||||
|
||||
// MemberInfo 用户信息表
|
||||
type MemberInfo struct {
|
||||
Memberid int `gorm:"primaryKey;column:memberid;type:int;comment:用户ID" json:"memberid"`
|
||||
Membername string `gorm:"column:membername;type:varchar(100);comment:姓名" json:"membername"`
|
||||
Account string `gorm:"column:account;type:varchar(100);comment:账号" json:"account"`
|
||||
Password string `gorm:"column:password;type:varchar(100);comment:密码" json:"-"`
|
||||
Contactphone string `gorm:"column:contactphone;type:varchar(50);comment:联系电话" json:"contactphone"`
|
||||
Organid int `gorm:"column:organid;type:int;comment:所属机构ID" json:"organid"`
|
||||
Createtime string `gorm:"column:createtime;type:varchar(50);comment:创建时间" json:"createtime"`
|
||||
Updatetime string `gorm:"column:updatetime;type:varchar(50);comment:修改时间" json:"updatetime"`
|
||||
Role int16 `gorm:"column:role;type:smallint;comment:角色类别" json:"role"`
|
||||
Status int16 `gorm:"column:status;type:smallint;comment:状态 1正常 2停用 3删除" json:"status"`
|
||||
Calluserid string `gorm:"column:calluserid;type:varchar(100);comment:坐席用户ID" json:"calluserid"`
|
||||
Remainingexport int `gorm:"column:remainingexport;type:int;comment:本月剩余导出次数" json:"remainingexport"`
|
||||
|
||||
// 虚拟字段(关联查询)
|
||||
Organname string `gorm:"-" json:"organname"` // 机构名称
|
||||
Rolename string `gorm:"-" json:"rolename"` // 角色名称
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (MemberInfo) TableName() string {
|
||||
return "member_info"
|
||||
}
|
||||
@@ -42,11 +42,10 @@ type TabConfig struct {
|
||||
var defaultTabConfig = TabConfig{
|
||||
AvailableTabs: []TabDefinition{
|
||||
{Key: "file-system", Title: "文件管理", Enabled: true},
|
||||
{Key: "db-cli", Title: "数据库", Enabled: true},
|
||||
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
|
||||
{Key: "openclaw-manager", Title: "OpenClaw", Enabled: true},
|
||||
{Key: "version", Title: "版本历史", Enabled: true},
|
||||
},
|
||||
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "openclaw-manager"},
|
||||
VisibleTabs: []string{"file-system", "markdown-editor", "version"},
|
||||
DefaultTab: "file-system",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"u-desk/internal/crypto"
|
||||
"u-desk/internal/dbclient"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConnectionService 连接管理服务
|
||||
type ConnectionService struct {
|
||||
repo repository.ConnectionRepository
|
||||
}
|
||||
|
||||
// NewConnectionService 创建连接服务
|
||||
func NewConnectionService() (*ConnectionService, error) {
|
||||
repo, err := repository.NewConnectionRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建连接仓库失败: %v", err)
|
||||
}
|
||||
return &ConnectionService{repo: repo}, nil
|
||||
}
|
||||
|
||||
// SaveConnection 保存连接配置
|
||||
func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
|
||||
// 验证
|
||||
if conn.Name == "" {
|
||||
return fmt.Errorf("连接名称不能为空")
|
||||
}
|
||||
if conn.Type == "" {
|
||||
return fmt.Errorf("数据库类型不能为空")
|
||||
}
|
||||
if conn.Host == "" {
|
||||
return fmt.Errorf("主机地址不能为空")
|
||||
}
|
||||
|
||||
// 检查名称是否重复
|
||||
existing, err := s.repo.FindByName(conn.Name, conn.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查连接名称失败: %v", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return fmt.Errorf("连接名称已存在")
|
||||
}
|
||||
|
||||
// 处理密码
|
||||
if conn.ID > 0 {
|
||||
if conn.Password == "" {
|
||||
// 更新模式:保留原密码
|
||||
conn.Password, err = s.getPassword(conn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 加密新密码
|
||||
conn.Password, err = crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 新增模式:加密密码
|
||||
conn.Password, err = crypto.EncryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.Save(conn)
|
||||
}
|
||||
|
||||
// getPassword 获取原始密码
|
||||
func (s *ConnectionService) getPassword(id uint) (string, error) {
|
||||
existing, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取原连接配置失败: %v", err)
|
||||
}
|
||||
return existing.Password, nil
|
||||
}
|
||||
|
||||
// ListConnections 获取连接列表
|
||||
func (s *ConnectionService) ListConnections() ([]models.DbConnection, error) {
|
||||
return s.repo.FindAll()
|
||||
}
|
||||
|
||||
// GetConnection 获取连接详情
|
||||
func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error) {
|
||||
return s.repo.FindByID(id)
|
||||
}
|
||||
|
||||
// DeleteConnection 删除连接配置(含关联数据和连接池清理)
|
||||
func (s *ConnectionService) DeleteConnection(id uint) error {
|
||||
conn, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil // 连接不存在视为成功
|
||||
}
|
||||
return fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 关闭连接池中的连接
|
||||
dbclient.GetPool().CloseConnection(id, conn.Type)
|
||||
|
||||
// 删除连接记录
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// TestConnection 测试连接(通过已保存的连接ID)
|
||||
func (s *ConnectionService) TestConnection(id uint) error {
|
||||
conn, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 解密密码用于测试
|
||||
password, err := crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
|
||||
// 根据类型测试连接
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
return dbclient.TestMySQLConnection(conn.Host, conn.Port, conn.Username, password, conn.Database)
|
||||
case "redis":
|
||||
return dbclient.TestRedisConnection(conn.Host, conn.Port, password)
|
||||
case "mongo":
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if conn.Options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
return dbclient.TestMongoConnectionWithOptions(conn.Host, conn.Port, conn.Username, password, conn.Database, authSource, authMechanism)
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectionWithParams 测试连接(直接传入参数,不保存数据)
|
||||
func (s *ConnectionService) TestConnectionWithParams(connType, host string, port int, username, password, database, options string, existingId uint) error {
|
||||
// 验证必填项
|
||||
if connType == "" {
|
||||
return fmt.Errorf("数据库类型不能为空")
|
||||
}
|
||||
if host == "" {
|
||||
return fmt.Errorf("主机地址不能为空")
|
||||
}
|
||||
|
||||
// 如果是编辑模式且密码为空,尝试获取已保存的密码
|
||||
actualPassword := password
|
||||
if existingId > 0 && password == "" {
|
||||
conn, err := s.repo.FindByID(existingId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取原连接配置失败: %v", err)
|
||||
}
|
||||
// 解密原密码
|
||||
actualPassword, err = crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据类型测试连接
|
||||
switch connType {
|
||||
case "mysql":
|
||||
return dbclient.TestMySQLConnection(host, port, username, actualPassword, database)
|
||||
case "redis":
|
||||
return dbclient.TestRedisConnection(host, port, actualPassword)
|
||||
case "mongo":
|
||||
// 解析 Options 获取 MongoDB 连接参数
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(options), &opts); err == nil {
|
||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
||||
authSource = as
|
||||
}
|
||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
||||
authMechanism = am
|
||||
}
|
||||
}
|
||||
}
|
||||
return dbclient.TestMongoConnectionWithOptions(host, port, username, actualPassword, database, authSource, authMechanism)
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库类型: %s", connType)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAllDatabases 加载全部数据库列表
|
||||
func (s *ConnectionService) LoadAllDatabases(dbType, host string, port int, username, password, database, options string, existingId uint) ([]string, error) {
|
||||
// 如果是编辑模式且密码为空,尝试获取已保存的密码
|
||||
actualPassword := password
|
||||
if existingId > 0 && password == "" {
|
||||
conn, err := s.repo.FindByID(existingId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取原连接配置失败: %v", err)
|
||||
}
|
||||
actualPassword, err = crypto.DecryptPassword(conn.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 MongoDB 选项
|
||||
authSource := ""
|
||||
authMechanism := ""
|
||||
if options != "" {
|
||||
var opts map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(options), &opts); err == nil {
|
||||
authSource, _ = opts["authSource"].(string)
|
||||
authMechanism, _ = opts["authMechanism"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
return loadDatabasesForMySQL(host, port, username, actualPassword, database)
|
||||
case "mongo":
|
||||
return loadDatabasesForMongo(host, port, username, actualPassword, database, authSource, authMechanism)
|
||||
case "redis":
|
||||
return []string{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", dbType)
|
||||
}
|
||||
}
|
||||
|
||||
func loadDatabasesForMySQL(host string, port int, username, password, defaultDatabase string) ([]string, error) {
|
||||
config := &dbclient.MySQLConfig{
|
||||
Host: host, Port: port, Username: username,
|
||||
Password: password, Database: defaultDatabase,
|
||||
}
|
||||
client, err := dbclient.NewMySQLClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
return client.ListDatabases(context.Background())
|
||||
}
|
||||
|
||||
func loadDatabasesForMongo(host string, port int, username, password, defaultDatabase, authSource, authMechanism string) ([]string, error) {
|
||||
config := &dbclient.MongoConfig{
|
||||
Host: host, Port: port, Username: username,
|
||||
Password: password, Database: defaultDatabase,
|
||||
AuthSource: authSource, AuthMechanism: authMechanism,
|
||||
}
|
||||
client, err := dbclient.NewMongoClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
return client.ListDatabases(context.Background())
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/dbclient"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
)
|
||||
|
||||
// SqlExecService SQL执行服务
|
||||
type SqlExecService struct {
|
||||
connRepo repository.ConnectionRepository
|
||||
pool *dbclient.ConnectionPool
|
||||
}
|
||||
|
||||
// NewSqlExecService 创建SQL执行服务
|
||||
func NewSqlExecService() (*SqlExecService, error) {
|
||||
connRepo, err := repository.NewConnectionRepository()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SqlExecService{
|
||||
connRepo: connRepo,
|
||||
pool: dbclient.GetPool(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SqlResult SQL执行结果
|
||||
type SqlResult struct {
|
||||
Type string `json:"type"` // query/update/command
|
||||
Data interface{} `json:"data"` // 查询结果数据
|
||||
Columns []string `json:"columns"` // 列顺序(仅查询时有效)
|
||||
RowsAffected int `json:"rowsAffected"` // 影响行数
|
||||
ExecutionTime int64 `json:"executionTime"` // 执行时间(毫秒)
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行SQL语句
|
||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||
func (s *SqlExecService) ExecuteSQL(connectionID uint, sqlStr string, database string) (*SqlResult, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
return s.executeMySQL(ctx, conn, sqlStr, database, startTime)
|
||||
case "redis":
|
||||
return s.executeRedis(ctx, conn, sqlStr, startTime)
|
||||
case "mongo":
|
||||
return s.executeMongo(ctx, conn, sqlStr, database, startTime)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// executeMySQL 执行MySQL SQL
|
||||
func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
|
||||
pc := s.pool.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
|
||||
sqlStr = strings.TrimSpace(sqlStr)
|
||||
sqlUpper := strings.ToUpper(sqlStr)
|
||||
|
||||
// 获取数据库参数
|
||||
dbName := database
|
||||
if dbName == "" {
|
||||
dbName = conn.Database
|
||||
}
|
||||
|
||||
result := &SqlResult{
|
||||
ExecutionTime: time.Since(startTime).Milliseconds(),
|
||||
}
|
||||
|
||||
// 判断是查询还是更新
|
||||
if strings.HasPrefix(sqlUpper, "SELECT") || strings.HasPrefix(sqlUpper, "SHOW") ||
|
||||
strings.HasPrefix(sqlUpper, "DESCRIBE") || strings.HasPrefix(sqlUpper, "DESC") ||
|
||||
strings.HasPrefix(sqlUpper, "EXPLAIN") {
|
||||
// 查询语句
|
||||
queryResult, err := pc.Client.ExecuteQuery(ctx, sqlStr, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Type = "query"
|
||||
result.Data = queryResult.Data
|
||||
result.Columns = queryResult.Columns
|
||||
result.RowsAffected = len(queryResult.Data)
|
||||
} else {
|
||||
// 更新语句
|
||||
rowsAffected, err := pc.Client.ExecuteUpdate(ctx, sqlStr, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Type = "update"
|
||||
result.RowsAffected = int(rowsAffected)
|
||||
result.Data = nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// executeRedis 执行Redis命令
|
||||
func (s *SqlExecService) executeRedis(ctx context.Context, conn *models.DbConnection, sqlStr string, startTime time.Time) (*SqlResult, error) {
|
||||
client, err := s.pool.GetRedisClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析Redis命令
|
||||
parts := parseRedisCommand(sqlStr)
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("Redis 命令不能为空")
|
||||
}
|
||||
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
args := make([]interface{}, 0)
|
||||
for i := 1; i < len(parts); i++ {
|
||||
args = append(args, parts[i])
|
||||
}
|
||||
|
||||
data, err := client.ExecuteCommand(ctx, cmd, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SqlResult{
|
||||
Type: "command",
|
||||
Data: data,
|
||||
RowsAffected: 1,
|
||||
ExecutionTime: time.Since(startTime).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// executeMongo 执行MongoDB命令
|
||||
func (s *SqlExecService) executeMongo(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
|
||||
// 解析MongoDB命令(JSON格式)
|
||||
var command map[string]interface{}
|
||||
sqlStr = strings.TrimSpace(sqlStr)
|
||||
if err := json.Unmarshal([]byte(sqlStr), &command); err != nil {
|
||||
return nil, fmt.Errorf("MongoDB 命令必须是有效的 JSON 格式: %v", err)
|
||||
}
|
||||
|
||||
// 确定数据库
|
||||
dbName := conn.Database
|
||||
if db, ok := command["database"].(string); ok && db != "" {
|
||||
dbName = db
|
||||
}
|
||||
if database != "" {
|
||||
dbName = database
|
||||
}
|
||||
if dbName == "" {
|
||||
return nil, fmt.Errorf("需要指定数据库名称")
|
||||
}
|
||||
|
||||
// 执行命令
|
||||
data, err := client.ExecuteCommand(ctx, dbName, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SqlResult{
|
||||
Type: "command",
|
||||
Data: data,
|
||||
ExecutionTime: time.Since(startTime).Milliseconds(),
|
||||
}
|
||||
|
||||
// 根据操作类型确定影响行数
|
||||
if op, ok := command["op"].(string); ok {
|
||||
switch op {
|
||||
case "find":
|
||||
if results, ok := data.([]map[string]interface{}); ok {
|
||||
result.RowsAffected = len(results)
|
||||
}
|
||||
case "count":
|
||||
if count, ok := data.(int64); ok {
|
||||
result.RowsAffected = int(count)
|
||||
}
|
||||
case "insertOne", "deleteOne":
|
||||
result.RowsAffected = 1
|
||||
case "insertMany":
|
||||
if resultMap, ok := data.(map[string]interface{}); ok {
|
||||
if count, ok := resultMap["insertedCount"].(int); ok {
|
||||
result.RowsAffected = count
|
||||
}
|
||||
}
|
||||
default:
|
||||
result.RowsAffected = 0
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDatabases 获取数据库列表
|
||||
func (s *SqlExecService) GetDatabases(connectionID uint) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
pc := s.pool.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
return pc.Client.ListDatabases(ctx)
|
||||
case "redis":
|
||||
databases := make([]string, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
databases[i] = fmt.Sprintf("%d", i)
|
||||
}
|
||||
return databases, nil
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.ListDatabases(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTables 获取表列表(MySQL/MongoDB)或Key列表(Redis)
|
||||
func (s *SqlExecService) GetTables(connectionID uint, database string) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
pc := s.pool.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
return pc.Client.ListTables(ctx, database)
|
||||
case "redis":
|
||||
client, err := s.pool.GetRedisClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
|
||||
}
|
||||
return client.GetKeys(ctx, database)
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.ListCollections(ctx, database)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// parseRedisCommand 解析Redis命令
|
||||
func parseRedisCommand(cmd string) []string {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
inQuotes := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
char := cmd[i]
|
||||
if !inQuotes {
|
||||
if char == '"' || char == '\'' {
|
||||
inQuotes = true
|
||||
quoteChar = char
|
||||
} else if char == ' ' || char == '\t' {
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
} else {
|
||||
current.WriteByte(char)
|
||||
}
|
||||
} else {
|
||||
if char == quoteChar {
|
||||
inQuotes = false
|
||||
quoteChar = byte(0)
|
||||
} else {
|
||||
current.WriteByte(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// GetTableStructure 获取表结构
|
||||
func (s *SqlExecService) GetTableStructure(connectionID uint, database, tableName string) (map[string]interface{}, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
pc := s.pool.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
structure, err := pc.Client.GetTableStructure(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "mysql",
|
||||
"database": database,
|
||||
"table": tableName,
|
||||
"columns": structure,
|
||||
}, nil
|
||||
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
structure, err := client.GetCollectionStructure(ctx, database, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "mongo",
|
||||
"database": database,
|
||||
"collection": tableName,
|
||||
"structure": structure,
|
||||
}, nil
|
||||
|
||||
case "redis":
|
||||
client, err := s.pool.GetRedisClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 Redis 客户端失败: %v", err)
|
||||
}
|
||||
info, err := client.GetKeyInfo(ctx, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "redis",
|
||||
"key": tableName,
|
||||
"info": info,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// GetIndexes 获取索引列表
|
||||
func (s *SqlExecService) GetIndexes(connectionID uint, database, tableName string) ([]map[string]interface{}, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
pc := s.pool.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
return pc.Client.GetIndexes(ctx, database, tableName)
|
||||
|
||||
case "mongo", "redis":
|
||||
return []map[string]interface{}{}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewTableStructure 预览表结构变更
|
||||
func (s *SqlExecService) PreviewTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
pc := s.pool.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
return pc.Client.PreviewTableStructure(ctx, database, tableName, structure)
|
||||
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.PreviewCollectionIndexes(ctx, database, tableName, structure)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTableStructure 更新表结构
|
||||
func (s *SqlExecService) UpdateTableStructure(connectionID uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
||||
conn, err := s.connRepo.FindByID(connectionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
||||
defer cancel()
|
||||
|
||||
switch conn.Type {
|
||||
case "mysql":
|
||||
pc := s.pool.GetMySQLClient(conn)
|
||||
if pc.Client == nil {
|
||||
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||
}
|
||||
defer pc.Release()
|
||||
return pc.Client.UpdateTableStructure(ctx, database, tableName, structure)
|
||||
|
||||
case "mongo":
|
||||
client, err := s.pool.GetMongoClient(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 MongoDB 客户端失败: %v", err)
|
||||
}
|
||||
return client.UpdateCollectionIndexes(ctx, database, tableName, structure)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"u-desk/internal/storage/models"
|
||||
"u-desk/internal/storage/repository"
|
||||
)
|
||||
|
||||
// TabService 标签页管理服务
|
||||
type TabService struct {
|
||||
repo repository.TabRepository
|
||||
}
|
||||
|
||||
// NewTabService 创建标签页服务
|
||||
func NewTabService() (*TabService, error) {
|
||||
repo, err := repository.NewTabRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建标签页仓库失败: %v", err)
|
||||
}
|
||||
return &TabService{repo: repo}, nil
|
||||
}
|
||||
|
||||
// SaveTabs 保存标签页列表
|
||||
func (s *TabService) SaveTabs(tabs []models.SqlTab) error {
|
||||
return s.repo.SaveAll(tabs)
|
||||
}
|
||||
|
||||
// ListTabs 获取标签页列表
|
||||
func (s *TabService) ListTabs() ([]models.SqlTab, error) {
|
||||
return s.repo.FindAll()
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// AppVersion 应用版本号(发布时直接修改此处)
|
||||
const AppVersion = "0.3.3"
|
||||
const AppVersion = "0.4.0"
|
||||
|
||||
// 版本号缓存
|
||||
var (
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DbConnection 数据库连接配置
|
||||
type DbConnection struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"` // 连接名称
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"` // 数据库类型: mysql/redis/mongo
|
||||
Host string `gorm:"type:varchar(255);not null" json:"host"` // 主机地址
|
||||
Port int `gorm:"not null" json:"port"` // 端口
|
||||
Username string `gorm:"type:varchar(100)" json:"username"` // 用户名
|
||||
Password string `gorm:"type:varchar(500)" json:"-"` // 密码(加密存储,不返回)
|
||||
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名(MySQL/MongoDB)
|
||||
Options string `gorm:"type:text" json:"options"` // 额外选项(JSON格式)
|
||||
VisibleDatabases string `gorm:"type:text" json:"visible_databases"` // 可见数据库列表(JSON数组,为空则全部可见)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (DbConnection) TableName() string {
|
||||
return "db_connection"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SqlResultHistory SQL 执行结果历史
|
||||
type SqlResultHistory struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ConnectionID uint `gorm:"index;not null" json:"connection_id"` // 连接ID
|
||||
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名
|
||||
Sql string `gorm:"type:text;not null" json:"sql"` // SQL语句
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"` // 结果类型: query/update/command
|
||||
Data string `gorm:"type:text" json:"data"` // 结果数据(JSON)
|
||||
Columns string `gorm:"type:text" json:"columns"` // 列信息(JSON)
|
||||
RowsAffected int `gorm:"default:0" json:"rows_affected"` // 影响行数
|
||||
ExecutionTime int64 `gorm:"default:0" json:"execution_time"` // 执行时间(毫秒)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SqlResultHistory) TableName() string {
|
||||
return "sql_result_history"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SqlTab SQL 编辑器标签页
|
||||
type SqlTab struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Title string `gorm:"type:varchar(100);not null" json:"title"` // 标签页标题
|
||||
Content string `gorm:"type:text" json:"content"` // SQL 内容
|
||||
ConnectionID *uint `gorm:"index" json:"connection_id"` // 关联的连接ID(可为空)
|
||||
Order int `gorm:"default:0" json:"order"` // 排序顺序
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SqlTab) TableName() string {
|
||||
return "sql_tab"
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConnectionRepository interface {
|
||||
Save(conn *models.DbConnection) error
|
||||
FindAll() ([]models.DbConnection, error)
|
||||
FindByID(id uint) (*models.DbConnection, error)
|
||||
Delete(id uint) error
|
||||
FindByName(name string, excludeID uint) (*models.DbConnection, error)
|
||||
}
|
||||
|
||||
type connectionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConnectionRepository() (ConnectionRepository, error) {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
var err error
|
||||
db, err = storage.Init()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &connectionRepository{db}, nil
|
||||
}
|
||||
|
||||
func (r *connectionRepository) Save(conn *models.DbConnection) error {
|
||||
if conn.ID > 0 {
|
||||
return r.db.Model(&models.DbConnection{}).Where("id = ?", conn.ID).Updates(conn).Error
|
||||
}
|
||||
return r.db.Create(conn).Error
|
||||
}
|
||||
|
||||
func (r *connectionRepository) FindAll() ([]models.DbConnection, error) {
|
||||
var connections []models.DbConnection
|
||||
return connections, r.db.Order("created_at DESC").Find(&connections).Error
|
||||
}
|
||||
|
||||
func (r *connectionRepository) FindByID(id uint) (*models.DbConnection, error) {
|
||||
var conn models.DbConnection
|
||||
err := r.db.First(&conn, id).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return &conn, err
|
||||
}
|
||||
|
||||
func (r *connectionRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.DbConnection{}, id).Error
|
||||
}
|
||||
|
||||
func (r *connectionRepository) FindByName(name string, excludeID uint) (*models.DbConnection, error) {
|
||||
var conn models.DbConnection
|
||||
query := r.db.Where("name = ?", name)
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id != ?", excludeID)
|
||||
}
|
||||
err := query.First(&conn).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return &conn, err
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ResultRepository interface {
|
||||
Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error)
|
||||
FindByID(id uint) (*models.SqlResultHistory, error)
|
||||
Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error)
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type resultRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewResultRepository() (ResultRepository, error) {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
var err error
|
||||
db, err = storage.Init()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &resultRepository{db}, nil
|
||||
}
|
||||
|
||||
func (r *resultRepository) Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
columnsJSON, _ := json.Marshal(columns)
|
||||
|
||||
history := &models.SqlResultHistory{
|
||||
ConnectionID: connectionID,
|
||||
Database: database,
|
||||
Sql: sql,
|
||||
Type: resultType,
|
||||
Data: string(dataJSON),
|
||||
Columns: string(columnsJSON),
|
||||
RowsAffected: rowsAffected,
|
||||
ExecutionTime: executionTime,
|
||||
}
|
||||
|
||||
return history, r.db.Create(history).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) FindByID(id uint) (*models.SqlResultHistory, error) {
|
||||
var history models.SqlResultHistory
|
||||
err := r.db.First(&history, id).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return &history, err
|
||||
}
|
||||
|
||||
func (r *resultRepository) Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error) {
|
||||
query := r.db.Model(&models.SqlResultHistory{})
|
||||
|
||||
if connectionID != nil {
|
||||
query = query.Where("connection_id = ?", *connectionID)
|
||||
}
|
||||
if keyword != "" {
|
||||
query = query.Where("sql LIKE ? OR database LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var histories []models.SqlResultHistory
|
||||
query = query.Order("created_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
if offset > 0 {
|
||||
query = query.Offset(offset)
|
||||
}
|
||||
|
||||
return histories, total, query.Find(&histories).Error
|
||||
}
|
||||
|
||||
func (r *resultRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.SqlResultHistory{}, id).Error
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"u-desk/internal/storage"
|
||||
"u-desk/internal/storage/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TabRepository interface {
|
||||
SaveAll(tabs []models.SqlTab) error
|
||||
FindAll() ([]models.SqlTab, error)
|
||||
Delete(id uint) error
|
||||
}
|
||||
|
||||
type tabRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTabRepository() (TabRepository, error) {
|
||||
db := storage.GetDB()
|
||||
if db == nil {
|
||||
var err error
|
||||
db, err = storage.Init()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &tabRepository{db}, nil
|
||||
}
|
||||
|
||||
func (r *tabRepository) SaveAll(tabs []models.SqlTab) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("1=1").Delete(&models.SqlTab{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tabs) > 0 {
|
||||
return tx.Create(&tabs).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *tabRepository) FindAll() ([]models.SqlTab, error) {
|
||||
var tabs []models.SqlTab
|
||||
return tabs, r.db.Order("`order` ASC, created_at ASC").Find(&tabs).Error
|
||||
}
|
||||
|
||||
func (r *tabRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.SqlTab{}, id).Error
|
||||
}
|
||||
|
||||
@@ -62,9 +62,6 @@ func InitFast() (*gorm.DB, error) {
|
||||
// AutoMigrate 在启动时执行,但只在表结构不存在时创建
|
||||
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
|
||||
if err := db.AutoMigrate(
|
||||
&models.DbConnection{},
|
||||
&models.SqlTab{},
|
||||
&models.SqlResultHistory{},
|
||||
&models.AppConfig{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "u-desk",
|
||||
"outputfilename": "u-desk",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.0",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"author": {
|
||||
|
||||
12
web/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
@@ -414,6 +415,17 @@
|
||||
"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": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
|
||||
@@ -1 +1 @@
|
||||
0e1fafcbb6b28922a38f6c5316932015
|
||||
c0e9e27e045c6118704c87fcf34a03de
|
||||
@@ -57,7 +57,7 @@
|
||||
<a-layout-content class="content">
|
||||
<!-- 动态渲染 Tab 内容 -->
|
||||
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
|
||||
<KeepAlive include="FileSystem,DbCli">
|
||||
<KeepAlive include="FileSystem">
|
||||
<component :is="getComponent(activeTab)"/>
|
||||
</KeepAlive>
|
||||
</a-layout-content>
|
||||
@@ -94,19 +94,19 @@
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
||||
import MarkdownEditor from './views/markdown-editor/index.vue'
|
||||
import DbCli from './views/db-cli/index.vue'
|
||||
import VersionHistory from './views/version/index.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
import {useUpdateStore} from './stores/update'
|
||||
import {useConfigStore} from './stores/config'
|
||||
import {useConfigStore, type AppConfig} from './stores/config'
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
|
||||
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
||||
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移
|
||||
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||
const showSettings = ref(false)
|
||||
@@ -125,7 +125,7 @@ const appConfig = computed(() => configStore.appConfig)
|
||||
const visibleTabs = computed(() => configStore.visibleTabs)
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (config) => {
|
||||
const handleSaveConfig = async (config: AppConfig) => {
|
||||
try {
|
||||
await configStore.saveConfig(config)
|
||||
showSettings.value = false
|
||||
@@ -148,10 +148,9 @@ const loadConfig = async () => {
|
||||
}
|
||||
|
||||
// 获取组件
|
||||
const getComponent = (key) => {
|
||||
const getComponent = (key: string) => {
|
||||
const components = {
|
||||
'file-system': FileSystem,
|
||||
'db-cli': DbCli,
|
||||
'markdown-editor': MarkdownEditor
|
||||
}
|
||||
return components[key] || null
|
||||
@@ -376,4 +375,9 @@ watch(activeTab, (newTab) => {
|
||||
.arco-tooltip {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
199
web/src/api/connection-manager.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 连接管理器 — 管理本地/远程传输层切换
|
||||
*/
|
||||
|
||||
import type { FsTransport } from './transport'
|
||||
import { WailsTransport } from './wails-transport'
|
||||
import { HttpTransport } from './http-transport'
|
||||
|
||||
export type ConnectionType = 'local' | 'remote'
|
||||
|
||||
export interface ConnectionProfile {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
token: string
|
||||
type: ConnectionType
|
||||
lastConnected?: number
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
const PROFILES_KEY = 'fs_connection_profiles'
|
||||
const ACTIVE_KEY = 'fs_active_connection'
|
||||
|
||||
class ConnectionManagerImpl {
|
||||
private _transport: FsTransport | null = null
|
||||
private _profiles: ConnectionProfile[] = []
|
||||
private _activeId: string | null = null
|
||||
private _state: ConnectionState = 'disconnected'
|
||||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||
private _connectSeq = 0
|
||||
|
||||
constructor() {
|
||||
this.loadProfiles()
|
||||
this.initDefaultLocal()
|
||||
}
|
||||
|
||||
private initDefaultLocal() {
|
||||
const localProfile: ConnectionProfile = {
|
||||
id: 'local-default',
|
||||
name: '本地',
|
||||
host: '',
|
||||
port: 0,
|
||||
token: '',
|
||||
type: 'local',
|
||||
}
|
||||
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
||||
this._profiles.unshift(localProfile)
|
||||
}
|
||||
// 默认连接本地
|
||||
if (!this._activeId) {
|
||||
this._activeId = localProfile.id
|
||||
}
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
private loadProfiles() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_KEY)
|
||||
if (raw) this._profiles = JSON.parse(raw)
|
||||
this._activeId = localStorage.getItem(ACTIVE_KEY)
|
||||
} catch { /* 首次使用 */ }
|
||||
}
|
||||
|
||||
private saveProfiles() {
|
||||
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
|
||||
if (this._activeId) {
|
||||
localStorage.setItem(ACTIVE_KEY, this._activeId)
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState) {
|
||||
this._state = state
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private notifyChange() {
|
||||
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
||||
}
|
||||
|
||||
onStateChange(cb: (state: ConnectionState) => void) {
|
||||
this._stateChangeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
get profiles(): ConnectionProfile[] {
|
||||
return [...this._profiles]
|
||||
}
|
||||
|
||||
get activeProfile(): ConnectionProfile | null {
|
||||
return this._profiles.find(p => p.id === this._activeId) ?? null
|
||||
}
|
||||
|
||||
getTransport(): FsTransport {
|
||||
if (!this._transport) {
|
||||
this.applyActive()
|
||||
}
|
||||
return this._transport!
|
||||
}
|
||||
|
||||
getFileServerBaseURL(): string {
|
||||
if (this._transport instanceof HttpTransport) {
|
||||
const profile = this.activeProfile
|
||||
if (!profile) return ''
|
||||
const scheme = profile.port === 443 ? 'https' : 'http'
|
||||
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
|
||||
return `${scheme}://${profile.host}${port}`
|
||||
}
|
||||
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
|
||||
return ''
|
||||
}
|
||||
|
||||
isRemote(): boolean {
|
||||
return this.activeProfile?.type === 'remote'
|
||||
}
|
||||
|
||||
connect(profileId: string): void {
|
||||
const profile = this._profiles.find(p => p.id === profileId)
|
||||
if (!profile) return
|
||||
|
||||
this._activeId = profileId
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._activeId = 'local-default'
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
|
||||
const newProfile: ConnectionProfile = {
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
this._profiles.push(newProfile)
|
||||
this.saveProfiles()
|
||||
this.notifyChange()
|
||||
return newProfile
|
||||
}
|
||||
|
||||
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
|
||||
const idx = this._profiles.findIndex(p => p.id === id)
|
||||
if (idx >= 0) {
|
||||
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||||
this.saveProfiles()
|
||||
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
|
||||
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
|
||||
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
|
||||
if (needsReapply && id === this._activeId) {
|
||||
this.applyActive()
|
||||
}
|
||||
this.notifyChange()
|
||||
}
|
||||
}
|
||||
|
||||
removeProfile(id: string): void {
|
||||
if (id === 'local-default') return // 不允许删除本地配置
|
||||
this._profiles = this._profiles.filter(p => p.id !== id)
|
||||
if (this._activeId === id) {
|
||||
this._activeId = 'local-default'
|
||||
}
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private applyActive() {
|
||||
const profile = this.activeProfile
|
||||
const seq = ++this._connectSeq
|
||||
if (!profile || profile.type === 'local') {
|
||||
this._transport = new WailsTransport()
|
||||
this.setState('connected')
|
||||
} else {
|
||||
this.setState('connecting')
|
||||
try {
|
||||
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
|
||||
// 快速连通性检查(用轻量 ping 代替 getCommonPaths)
|
||||
this._transport.getFileInfo('/').then(() => {
|
||||
if (seq !== this._connectSeq) return // 已被后续连接覆盖
|
||||
this.setState('connected')
|
||||
this.updateProfile(profile.id!, { lastConnected: Date.now() })
|
||||
}).catch(() => {
|
||||
if (seq !== this._connectSeq) return
|
||||
this.setState('error')
|
||||
})
|
||||
} catch {
|
||||
this.setState('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionManager = new ConnectionManagerImpl()
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 连接相关 API
|
||||
*/
|
||||
|
||||
import type { Connection } from './types'
|
||||
|
||||
/**
|
||||
* 获取连接列表
|
||||
*/
|
||||
export async function listConnections(): Promise<Connection[]> {
|
||||
if (!window.go?.main?.App?.ListDbConnections) {
|
||||
throw new Error('ListDbConnections API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListDbConnections()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除连接
|
||||
*/
|
||||
export async function deleteConnection(id: number): Promise<void> {
|
||||
if (!window.go?.main?.App?.DeleteDbConnection) {
|
||||
throw new Error('DeleteDbConnection API 不可用')
|
||||
}
|
||||
await window.go.main.App.DeleteDbConnection(id)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 数据库和表相关 API
|
||||
*/
|
||||
|
||||
import type { Database, Table } from './types'
|
||||
|
||||
/**
|
||||
* 获取数据库列表
|
||||
*/
|
||||
export async function getDatabases(connectionId: number): Promise<Database[]> {
|
||||
if (!window.go?.main?.App?.GetDatabases) {
|
||||
throw new Error('GetDatabases API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetDatabases(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表列表
|
||||
*/
|
||||
export async function getTables(connectionId: number, database: string): Promise<Table[]> {
|
||||
if (!window.go?.main?.App?.GetTables) {
|
||||
throw new Error('GetTables API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetTables(connectionId, database)
|
||||
}
|
||||
136
web/src/api/http-transport.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Http Transport — 远程文件操作(通过 u-fs-agent REST API)
|
||||
*/
|
||||
|
||||
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||
|
||||
const CONTENT_TYPE = 'application/json'
|
||||
|
||||
export class HttpTransport implements FsTransport {
|
||||
private baseUrl: string
|
||||
private token: string
|
||||
|
||||
constructor(host: string, port: number, token: string) {
|
||||
const scheme = port === 443 ? 'https' : 'http'
|
||||
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
|
||||
this.token = token
|
||||
}
|
||||
|
||||
private headers(): HeadersInit {
|
||||
const h: Record<string, string> = { 'Content-Type': CONTENT_TYPE }
|
||||
if (this.token) h['Authorization'] = `Bearer ${this.token}`
|
||||
return h
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, params?: Record<string, string>, body?: any): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`
|
||||
const searchParams = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||
const opts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers(),
|
||||
}
|
||||
if (body !== undefined) opts.body = JSON.stringify(body)
|
||||
|
||||
const res = await fetch(url + searchParams, opts)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
if (data.code >= 400) {
|
||||
throw new Error(data.message || `请求失败 (code=${data.code})`)
|
||||
}
|
||||
return data.data ?? data
|
||||
}
|
||||
|
||||
async listDir(path: string): Promise<FileItem[]> {
|
||||
return this.request<FileItem[]>('GET', '/api/v1/fs', { path })
|
||||
}
|
||||
|
||||
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||
return this.request<Record<string, any>>('GET', '/api/v1/fs', { path, get: 'stat' })
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const data = await this.request<{ content: string }>('GET', '/api/v1/fs/read', { path })
|
||||
return data.content
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
await this.request('PUT', '/api/v1/fs/write', { path }, { content })
|
||||
}
|
||||
|
||||
async saveBase64File(path: string, content: string): Promise<void> {
|
||||
if (!content) throw new Error('无效的 base64 内容')
|
||||
await this.request('POST', '/api/v1/fs/upload', { path }, { content })
|
||||
}
|
||||
|
||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: dirPath }, { type: 'file', name: filename })
|
||||
}
|
||||
|
||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: parentPath }, { type: 'dir', name: dirname })
|
||||
}
|
||||
|
||||
async deletePath(path: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('DELETE', '/api/v1/fs/delete', { path })
|
||||
}
|
||||
|
||||
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
|
||||
}
|
||||
|
||||
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||
// Wave 3 实现
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async openPath(_path: string): Promise<void> {
|
||||
throw new Error('远程模式不支持打开本地路径')
|
||||
}
|
||||
|
||||
async getFileServerURL(): Promise<string> {
|
||||
return `${this.baseUrl}/api/v1/proxy/localfs`
|
||||
}
|
||||
|
||||
/** 远程模式预览用的认证 token(拼接到 URL query) */
|
||||
getPreviewToken(): string {
|
||||
return this.token
|
||||
}
|
||||
|
||||
async resolveShortcut(_lnkPath: string): Promise<any> {
|
||||
return null
|
||||
}
|
||||
|
||||
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
|
||||
return this.request<DetectTypeResult>('GET', '/api/v1/fs/detect', { path })
|
||||
}
|
||||
|
||||
async getCommonPaths(): Promise<Record<string, string>> {
|
||||
return this.request<Record<string, string>>('GET', '/api/v1/system/common-paths')
|
||||
}
|
||||
|
||||
async getRecycleBinEntries(): Promise<any[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
async restoreFromRecycleBin(_path: string): Promise<void> {}
|
||||
|
||||
async deletePermanently(_path: string): Promise<void> {}
|
||||
|
||||
async emptyRecycleBin(): Promise<void> {}
|
||||
}
|
||||
@@ -3,9 +3,4 @@
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './connection'
|
||||
export * from './database'
|
||||
export * from './structure'
|
||||
export * from './query'
|
||||
export * from './tab'
|
||||
export * from './system'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* SQL 查询相关 API
|
||||
*/
|
||||
|
||||
import type { QueryResult } from './types'
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询
|
||||
*/
|
||||
export async function executeQuery(
|
||||
connectionId: number,
|
||||
sql: string,
|
||||
database?: string,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<QueryResult> {
|
||||
if (!window.go?.main?.App?.ExecuteSQL) {
|
||||
throw new Error('ExecuteSQL API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ExecuteSQL(connectionId, sql, database, page, pageSize)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* 表结构相关 API
|
||||
*/
|
||||
|
||||
import type { Structure } from './types'
|
||||
|
||||
/**
|
||||
* 获取表结构
|
||||
*/
|
||||
export async function getTableStructure(
|
||||
connectionId: number,
|
||||
database: string,
|
||||
table: string
|
||||
): Promise<Structure> {
|
||||
if (!window.go?.main?.App?.GetTableStructure) {
|
||||
throw new Error('GetTableStructure API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetTableStructure(connectionId, database, table)
|
||||
}
|
||||
@@ -1,306 +1,110 @@
|
||||
/**
|
||||
* 系统信息相关 API
|
||||
* 系统信息相关 API — 委托给 Transport 层
|
||||
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||
*/
|
||||
|
||||
import type { SystemInfo, CPU, Memory, Disk, File } from './types'
|
||||
import { debugError } from '@/utils/debugLog'
|
||||
import type { File } from './types'
|
||||
import { connectionManager } from './connection-manager'
|
||||
|
||||
/**
|
||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
||||
* 后端返回 is_dir,前端使用 isDir
|
||||
*/
|
||||
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[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统信息
|
||||
*/
|
||||
export async function getSystemInfo(): Promise<SystemInfo> {
|
||||
if (!window.go?.main?.App?.GetSystemInfo) {
|
||||
throw new Error('GetSystemInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetSystemInfo()
|
||||
const t = () => connectionManager.getTransport()
|
||||
|
||||
export async function getSystemInfo() { return t().getFileInfo('/') }
|
||||
|
||||
export async function getCPUInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CPU 信息
|
||||
*/
|
||||
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 getMemoryInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } 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() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取磁盘信息
|
||||
*/
|
||||
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[]> {
|
||||
if (!window.go?.main?.App?.ListDir) {
|
||||
throw new Error('ListDir API 不可用')
|
||||
}
|
||||
|
||||
const files = await window.go.main.App.ListDir(path)
|
||||
return transformFileList(files)
|
||||
return transformFileList(await t().listDir(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件
|
||||
*/
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
if (!window.go?.main?.App?.ReadFile) {
|
||||
throw new Error('ReadFile API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ReadFile(path)
|
||||
return t().readFile(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件
|
||||
*/
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.WriteFile) {
|
||||
throw new Error('WriteFile API 不可用')
|
||||
}
|
||||
// 确保传递的是字符串类型
|
||||
await window.go.main.App.WriteFile({
|
||||
path: String(path),
|
||||
content: String(content)
|
||||
})
|
||||
await t().writeFile(path, String(content))
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Base64 编码的二进制文件(图片等)
|
||||
*/
|
||||
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.SaveBase64File) {
|
||||
throw new Error('SaveBase64File API 不可用')
|
||||
}
|
||||
if (!base64Content) {
|
||||
throw new Error('无效的 base64 内容')
|
||||
}
|
||||
await window.go.main.App.SaveBase64File({
|
||||
path: String(path),
|
||||
content: base64Content
|
||||
})
|
||||
if (!base64Content) throw new Error('无效的 base64 内容')
|
||||
await t().saveBase64File(path, base64Content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件或目录
|
||||
*/
|
||||
export async function deletePath(path: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.DeletePath) {
|
||||
throw new Error('DeletePath API 不可用')
|
||||
}
|
||||
return await window.go.main.App.DeletePath(path)
|
||||
return t().deletePath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录(parentPath + dirname 拼接为完整路径)
|
||||
*/
|
||||
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.CreateDir) {
|
||||
throw new Error('CreateDir API 不可用')
|
||||
}
|
||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||
return await window.go.main.App.CreateDir(fullPath)
|
||||
return t().createDir(parentPath, dirname)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件(dirPath + filename 拼接为完整路径)
|
||||
*/
|
||||
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.CreateFile) {
|
||||
throw new Error('CreateFile API 不可用')
|
||||
}
|
||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||
return await window.go.main.App.CreateFile(fullPath)
|
||||
return t().createFile(dirPath, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或目录
|
||||
*/
|
||||
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
||||
if (!window.go?.main?.App?.RenamePath) {
|
||||
throw new Error('RenamePath API 不可用')
|
||||
}
|
||||
return await window.go.main.App.RenamePath({
|
||||
oldPath: String(oldPath),
|
||||
newPath: String(newPath)
|
||||
})
|
||||
return t().renamePath(oldPath, String(newPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境变量
|
||||
*/
|
||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||
if (!window.go?.main?.App?.GetEnvVars) {
|
||||
throw new Error('GetEnvVars API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetEnvVars()
|
||||
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 zip 文件内容
|
||||
*/
|
||||
export async function listZipContents(zipPath: string): Promise<File[]> {
|
||||
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
|
||||
}
|
||||
return transformFileList(await t().listZipContents(zipPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 zip 文件中提取单个文件内容
|
||||
*/
|
||||
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||
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
|
||||
}
|
||||
return t().extractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 zip 文件中提取单个文件到临时目录
|
||||
* 返回临时文件的完整路径,适用于图片等二进制文件
|
||||
*/
|
||||
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||
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
|
||||
}
|
||||
return t().extractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 zip 文件中特定文件的信息
|
||||
*/
|
||||
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
||||
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
|
||||
}
|
||||
return transformFile(await t().getZipFileInfo(zipPath, filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用系统默认程序打开文件或目录
|
||||
*/
|
||||
export async function openPath(path: string): Promise<void> {
|
||||
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
|
||||
}
|
||||
await t().openPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地文件服务器URL
|
||||
*/
|
||||
export async function getFileServerURL(): Promise<string> {
|
||||
if (!window.go?.main?.App?.GetFileServerURL) {
|
||||
throw new Error('GetFileServerURL API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetFileServerURL()
|
||||
return t().getFileServerURL()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析快捷方式文件,返回目标路径信息
|
||||
*/
|
||||
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 resolveShortcut(lnkPath: string): Promise<any> {
|
||||
return t().resolveShortcut(lnkPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件内容检测文件类型(用于小文件,500KB以内)
|
||||
*/
|
||||
export async function detectFileTypeByContent(path: string): Promise<{
|
||||
extension: string
|
||||
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
|
||||
}
|
||||
export async function detectFileTypeByContent(path: string) {
|
||||
return t().detectFileTypeByContent(path)
|
||||
}
|
||||
|
||||
export async function getCommonPaths() {
|
||||
return t().getCommonPaths()
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 标签页相关 API
|
||||
*/
|
||||
|
||||
import type { Tab } from './types'
|
||||
|
||||
/**
|
||||
* 保存标签页
|
||||
*/
|
||||
export async function saveTabs(tabs: Tab[]): Promise<void> {
|
||||
if (!window.go?.main?.App?.SaveSqlTabs) {
|
||||
throw new Error('SaveSqlTabs API 不可用')
|
||||
}
|
||||
await window.go.main.App.SaveSqlTabs(tabs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签页列表
|
||||
*/
|
||||
export async function listTabs(): Promise<Tab[]> {
|
||||
if (!window.go?.main?.App?.ListSqlTabs) {
|
||||
throw new Error('ListSqlTabs API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListSqlTabs()
|
||||
}
|
||||
71
web/src/api/transport.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 文件系统传输层接口
|
||||
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||
* Composable 和组件不感知底层差异
|
||||
*/
|
||||
|
||||
export type FileItem = {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
size_str?: string
|
||||
is_dir: boolean
|
||||
mod_time?: string
|
||||
mode?: string
|
||||
}
|
||||
|
||||
export type FileOperationResult = {
|
||||
path: string
|
||||
name: string
|
||||
size: number
|
||||
size_str?: string
|
||||
is_dir: boolean
|
||||
mod_time?: string
|
||||
mode?: string
|
||||
old_path?: string
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
export type DetectTypeResult = {
|
||||
extension: string
|
||||
category: string
|
||||
mime_type: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface FsTransport {
|
||||
// 文件列表与信息
|
||||
listDir(path: string): Promise<FileItem[]>
|
||||
getFileInfo(path: string): Promise<Record<string, any>>
|
||||
|
||||
// 文件读写
|
||||
readFile(path: string): Promise<string>
|
||||
writeFile(path: string, content: string): Promise<void>
|
||||
saveBase64File(path: string, content: string): Promise<void>
|
||||
|
||||
// 文件操作
|
||||
createFile(dirPath: string, filename: string): Promise<FileOperationResult>
|
||||
createDir(parentPath: string, dirname: string): Promise<FileOperationResult>
|
||||
deletePath(path: string): Promise<FileOperationResult>
|
||||
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
|
||||
|
||||
// ZIP 操作(Wave 3)
|
||||
listZipContents(zipPath: string): Promise<FileItem[]>
|
||||
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
|
||||
extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string>
|
||||
getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem>
|
||||
|
||||
// 系统操作
|
||||
openPath(path: string): Promise<void>
|
||||
getFileServerURL(): Promise<string>
|
||||
getPreviewToken(): string
|
||||
resolveShortcut(lnkPath: string): Promise<any>
|
||||
detectFileTypeByContent(path: string): Promise<DetectTypeResult>
|
||||
getCommonPaths(): Promise<Record<string, string>>
|
||||
|
||||
// 回收站(Wave 3)
|
||||
getRecycleBinEntries(): Promise<any[]>
|
||||
restoreFromRecycleBin(path: string): Promise<void>
|
||||
deletePermanently(path: string): Promise<void>
|
||||
emptyRecycleBin(): Promise<void>
|
||||
}
|
||||
@@ -2,75 +2,6 @@
|
||||
* API 类型定义
|
||||
*/
|
||||
|
||||
// 连接
|
||||
export interface Connection {
|
||||
id: number
|
||||
name: string
|
||||
dbType: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
database?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// 数据库和表
|
||||
export interface Database {
|
||||
name: string
|
||||
tableCount?: number
|
||||
}
|
||||
|
||||
export interface Table {
|
||||
name: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
// 表结构
|
||||
export interface Column {
|
||||
Field: string
|
||||
Type: string
|
||||
Null: string
|
||||
Key: string
|
||||
Default: string | null
|
||||
Comment: string
|
||||
Extra?: string
|
||||
}
|
||||
|
||||
export interface Index {
|
||||
Key_name: string
|
||||
Column_name: string
|
||||
Non_unique: number
|
||||
Seq_in_index: number
|
||||
Index_type: string
|
||||
}
|
||||
|
||||
export interface Structure {
|
||||
database: string
|
||||
table: string
|
||||
type: 'mysql' | 'mongo' | 'redis'
|
||||
columns?: Column[]
|
||||
indexes?: Index[]
|
||||
structure?: any
|
||||
info?: any
|
||||
}
|
||||
|
||||
// SQL 查询
|
||||
export interface QueryResult {
|
||||
columns: string[]
|
||||
data: any[]
|
||||
rowsAffected: number
|
||||
executionTime: number
|
||||
}
|
||||
|
||||
// 标签页
|
||||
export interface Tab {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
connectionId?: number | null
|
||||
order?: number
|
||||
}
|
||||
|
||||
// 系统信息
|
||||
export interface SystemInfo {
|
||||
os: string
|
||||
|
||||
139
web/src/api/wails-transport.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Wails Transport — 本地文件操作(通过 Wails IPC)
|
||||
*/
|
||||
|
||||
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||
|
||||
function transformFile(file: any): FileItem {
|
||||
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
|
||||
}
|
||||
|
||||
function transformFileList(files: any[]): FileItem[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
export class WailsTransport implements FsTransport {
|
||||
private checkAvailable(method: string) {
|
||||
if (!window.go?.main?.App?.[method]) {
|
||||
throw new Error(`${method} API 不可用`)
|
||||
}
|
||||
}
|
||||
|
||||
async listDir(path: string): Promise<FileItem[]> {
|
||||
this.checkAvailable('ListDir')
|
||||
return transformFileList(await window.go.main.App.ListDir(path))
|
||||
}
|
||||
|
||||
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||
this.checkAvailable('GetFileInfo')
|
||||
return window.go.main.App.GetFileInfo(path)
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
this.checkAvailable('ReadFile')
|
||||
return window.go.main.App.ReadFile(path)
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
this.checkAvailable('WriteFile')
|
||||
await window.go.main.App.WriteFile({ path: String(path), content: String(content) })
|
||||
}
|
||||
|
||||
async saveBase64File(path: string, content: string): Promise<void> {
|
||||
this.checkAvailable('SaveBase64File')
|
||||
if (!content) throw new Error('无效的 base64 内容')
|
||||
await window.go.main.App.SaveBase64File({ path: String(path), content })
|
||||
}
|
||||
|
||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||
this.checkAvailable('CreateFile')
|
||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||
return window.go.main.App.CreateFile(fullPath)
|
||||
}
|
||||
|
||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||
this.checkAvailable('CreateDir')
|
||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||
return window.go.main.App.CreateDir(fullPath)
|
||||
}
|
||||
|
||||
async deletePath(path: string): Promise<FileOperationResult> {
|
||||
this.checkAvailable('DeletePath')
|
||||
return window.go.main.App.DeletePath(path)
|
||||
}
|
||||
|
||||
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||
this.checkAvailable('RenamePath')
|
||||
return window.go.main.App.RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
|
||||
}
|
||||
|
||||
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||
this.checkAvailable('ListZipContents')
|
||||
return transformFileList(await window.go.main.App.ListZipContents(zipPath))
|
||||
}
|
||||
|
||||
async extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||
this.checkAvailable('ExtractFileFromZip')
|
||||
return window.go.main.App.ExtractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
async extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||
this.checkAvailable('ExtractFileFromZipToTemp')
|
||||
return window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
|
||||
this.checkAvailable('GetZipFileInfo')
|
||||
return transformFile(await window.go.main.App.GetZipFileInfo(zipPath, filePath))
|
||||
}
|
||||
|
||||
async openPath(path: string): Promise<void> {
|
||||
this.checkAvailable('OpenPath')
|
||||
await window.go.main.App.OpenPath(path)
|
||||
}
|
||||
|
||||
async getFileServerURL(): Promise<string> {
|
||||
this.checkAvailable('GetFileServerURL')
|
||||
return window.go.main.App.GetFileServerURL()
|
||||
}
|
||||
|
||||
getPreviewToken(): string {
|
||||
return '' // 本地模式无需 token
|
||||
}
|
||||
|
||||
async resolveShortcut(lnkPath: string): Promise<any> {
|
||||
this.checkAvailable('ResolveShortcut')
|
||||
return window.go.main.App.ResolveShortcut(lnkPath)
|
||||
}
|
||||
|
||||
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
|
||||
this.checkAvailable('DetectFileTypeByContent')
|
||||
const result = await window.go.main.App.DetectFileTypeByContent(path)
|
||||
return result as unknown as DetectTypeResult
|
||||
}
|
||||
|
||||
async getCommonPaths(): Promise<Record<string, string>> {
|
||||
this.checkAvailable('GetCommonPaths')
|
||||
return window.go.main.App.GetCommonPaths()
|
||||
}
|
||||
|
||||
async getRecycleBinEntries(): Promise<any[]> {
|
||||
this.checkAvailable('GetRecycleBinEntries')
|
||||
return window.go.main.App.GetRecycleBinEntries()
|
||||
}
|
||||
|
||||
async restoreFromRecycleBin(path: string): Promise<void> {
|
||||
this.checkAvailable('RestoreFromRecycleBin')
|
||||
await window.go.main.App.RestoreFromRecycleBin(path)
|
||||
}
|
||||
|
||||
async deletePermanently(path: string): Promise<void> {
|
||||
this.checkAvailable('DeletePermanently')
|
||||
await window.go.main.App.DeletePermanently(path)
|
||||
}
|
||||
|
||||
async emptyRecycleBin(): Promise<void> {
|
||||
this.checkAvailable('EmptyRecycleBin')
|
||||
await window.go.main.App.EmptyRecycleBin()
|
||||
}
|
||||
}
|
||||
@@ -3,34 +3,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onBeforeUnmount, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import {
|
||||
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
|
||||
EditorState, Compartment,
|
||||
defaultKeymap, history,
|
||||
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
|
||||
oneDark
|
||||
oneDark,
|
||||
openSearchPanel, search
|
||||
} from '@/utils/codemirrorExports'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
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 ====================
|
||||
|
||||
const props = defineProps({
|
||||
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'])
|
||||
@@ -41,6 +32,36 @@ const themeStore = useThemeStore()
|
||||
const editorContainer = ref(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 实现动态切换,避免重建编辑器
|
||||
const themeCompartment = new Compartment()
|
||||
const languageCompartment = new Compartment()
|
||||
@@ -87,6 +108,9 @@ const createExtensions = () => {
|
||||
keymap.of(defaultKeymap),
|
||||
bracketMatching(),
|
||||
|
||||
// 查找替换(Ctrl+F / Ctrl+H)
|
||||
search(),
|
||||
|
||||
// 内容更新监听(带防抖)
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
@@ -98,7 +122,7 @@ const createExtensions = () => {
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '13px' },
|
||||
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
||||
'.cm-content': { padding: '8px', minHeight: '100%' },
|
||||
'.cm-content': { padding: '8px' },
|
||||
'.cm-line': { padding: '0 0' },
|
||||
'&.cm-focused': { outline: 'none' }
|
||||
}),
|
||||
@@ -143,6 +167,12 @@ const createEditor = (docContent = '') => {
|
||||
|
||||
view = new EditorView({ state, parent: editorContainer.value })
|
||||
|
||||
// 滚动时防抖保存位置
|
||||
view.scrollDOM.addEventListener('scroll', () => {
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||
}, { passive: true })
|
||||
|
||||
// 初始化语言
|
||||
initLanguage()
|
||||
}
|
||||
@@ -163,8 +193,10 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (emitTimeout) {
|
||||
clearTimeout(emitTimeout)
|
||||
if (emitTimeout) clearTimeout(emitTimeout)
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
if (view?.scrollDOM) {
|
||||
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
|
||||
}
|
||||
view?.destroy()
|
||||
view = null
|
||||
@@ -172,12 +204,64 @@ onBeforeUnmount(() => {
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
// 监听外部内容变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (view && newValue !== view.state.doc.toString()) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }
|
||||
// 保存当前文件滚动位置(防抖)
|
||||
const saveScrollPosition = () => {
|
||||
if (!view || !currentFilePath) return
|
||||
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({
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -214,6 +298,6 @@ watch(() => props.fileExtension, () => {
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-content) {
|
||||
height: 100%;
|
||||
/* 不设 height,让 CodeMirror 虚拟滚动自行计算文档高度 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<a-modal :visible="props.visible" :title="editingId ? '编辑连接' : '添加服务器'" unmount-on-close @cancel="emit('update:visible', false)" @before-ok="handleOk" :ok-loading="submitting">
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 400px">
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">名称</div>
|
||||
<a-input v-model="form.name" placeholder="如:生产服务器" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">地址</div>
|
||||
<a-input v-model="form.host" placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">端口</div>
|
||||
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="9876" style="width: 100%" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">
|
||||
Token <span style="color: var(--color-text-3); font-size: 12px">API 认证令牌(与服务器配置一致)</span>
|
||||
</div>
|
||||
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
||||
|
||||
const editingId = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 9876,
|
||||
token: '',
|
||||
})
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (!val) return
|
||||
editingId.value = null
|
||||
Object.assign(form, { name: '', host: '', port: 9876, token: '' })
|
||||
})
|
||||
|
||||
async function handleOk(): Promise<boolean> {
|
||||
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
|
||||
if (!form.host?.trim()) { Message.warning('请输入地址'); return false }
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
connectionManager.updateProfile(editingId.value, { ...form })
|
||||
Message.success('已更新')
|
||||
} else {
|
||||
connectionManager.addProfile({ ...form, type: 'remote' })
|
||||
Message.success('已添加')
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editProfile(id: string) {
|
||||
const profile = connectionManager.profiles.find(p => p.id === id)
|
||||
if (!profile) return
|
||||
editingId.value = id
|
||||
Object.assign(form, { name: profile.name, host: profile.host, port: profile.port, token: profile.token || '' })
|
||||
}
|
||||
|
||||
defineExpose({ editProfile })
|
||||
</script>
|
||||
270
web/src/components/FileSystem/components/ConnectionIndicator.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<!-- 无远程配置:极简入口按钮 -->
|
||||
<div v-if="!hasRemote" class="connection-indicator mini" @click="$emit('add')">
|
||||
<icon-cloud />
|
||||
</div>
|
||||
|
||||
<!-- 有远程配置:完整标签 + 下拉菜单 -->
|
||||
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
|
||||
<span :class="['dot', state]" />
|
||||
<span class="label">{{ label }}</span>
|
||||
|
||||
<div v-if="showMenu" class="menu" @click.stop>
|
||||
<div class="menu-header">远程连接</div>
|
||||
<div
|
||||
v-for="p in profiles"
|
||||
:key="p.id"
|
||||
:class="['menu-item', { active: p.id === activeId }]"
|
||||
@click="handleSelect(p)"
|
||||
>
|
||||
<span :class="['dot', p.type === 'remote' ? 'remote' : 'local']"></span>
|
||||
<span class="menu-name">{{ p.name }}</span>
|
||||
<span
|
||||
v-if="p.type === 'remote'"
|
||||
class="more-btn"
|
||||
title="更多操作"
|
||||
@click.stop="toggleMore(p)"
|
||||
>···</span>
|
||||
<!-- 更多操作子菜单 -->
|
||||
<div v-if="moreOpenId === p.id" class="more-menu" @click.stop>
|
||||
<div class="more-item" @click="handleEdit(p)">编辑</div>
|
||||
<div class="more-item danger" @click="handleDelete(p)">删除</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-divider" />
|
||||
<button class="menu-item add-btn" @click="$emit('add')">
|
||||
+ 添加服务器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
|
||||
|
||||
const showMenu = ref(false)
|
||||
const moreOpenId = ref<string | null>(null)
|
||||
const profiles = shallowRef(connectionManager.profiles)
|
||||
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
|
||||
// 是否有远程 profile(决定显示模式)
|
||||
const hasRemote = computed(() => profiles.value.some(p => p.type === 'remote'))
|
||||
|
||||
// 防抖:避免 connecting→connected 快速切换导致闪烁
|
||||
const displayState = ref(connectionManager.state)
|
||||
let _stateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const state = computed(() => displayState.value)
|
||||
const label = computed(() => {
|
||||
const p = profiles.value.find(p => p.id === activeId.value)
|
||||
if (!p || p.type === 'local') return '本地'
|
||||
return p.name
|
||||
})
|
||||
|
||||
// 监听连接变化,主动触发更新(带防抖)
|
||||
connectionManager.onStateChange((newState) => {
|
||||
profiles.value = connectionManager.profiles
|
||||
activeId.value = connectionManager.activeProfile?.id ?? ''
|
||||
if (_stateTimer) clearTimeout(_stateTimer)
|
||||
if (newState === 'connecting') {
|
||||
_stateTimer = setTimeout(() => { displayState.value = newState }, 300)
|
||||
} else {
|
||||
displayState.value = newState
|
||||
}
|
||||
})
|
||||
|
||||
// 点击外部关闭菜单
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const el = e.target as HTMLElement
|
||||
if (!el.closest('.connection-indicator')) {
|
||||
showMenu.value = false
|
||||
moreOpenId.value = null
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||
|
||||
function handleSelect(p: { id: string }) {
|
||||
connectionManager.connect(p.id)
|
||||
showMenu.value = false
|
||||
emit('select', p.id)
|
||||
}
|
||||
|
||||
function toggleMore(p: { id: string }) {
|
||||
moreOpenId.value = moreOpenId.value === p.id ? null : p.id
|
||||
}
|
||||
|
||||
function handleEdit(p: { id: string }) {
|
||||
moreOpenId.value = null
|
||||
showMenu.value = false
|
||||
emit('edit', p.id)
|
||||
}
|
||||
|
||||
function handleDelete(p: { id: string; name: string }) {
|
||||
connectionManager.removeProfile(p.id)
|
||||
moreOpenId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connection-indicator {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-indicator:hover {
|
||||
background: var(--color-fill-2);
|
||||
border-color: var(--color-border-2);
|
||||
}
|
||||
|
||||
/* 极简模式:仅图标 */
|
||||
.connection-indicator.mini {
|
||||
padding: 3px 6px;
|
||||
border: none;
|
||||
gap: 0;
|
||||
}
|
||||
.connection-indicator.mini:hover {
|
||||
background: var(--color-fill-2);
|
||||
border-color: transparent;
|
||||
}
|
||||
.connection-indicator.mini :deep(.arco-icon) {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.connected { background: rgb(var(--green-6)); }
|
||||
.dot.connecting { background: #f5a623; animation: pulse 1.5s infinite; }
|
||||
.dot.disconnected { background: var(--color-danger-6); }
|
||||
.dot.error { background: var(--color-danger-6); }
|
||||
.dot.local { background: var(--color-text-3); }
|
||||
.dot.remote { background: #165dff; }
|
||||
|
||||
.label {
|
||||
max-width: 70px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1000;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
border-bottom: 1px solid var(--color-fill-1);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.menu-item:hover { background: var(--color-fill-1); }
|
||||
.menu-item.active { background: var(--color-primary-light-1); color: var(--color-primary-6); }
|
||||
|
||||
.menu-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
transition: opacity 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.menu-item:hover .more-btn { opacity: 1; }
|
||||
|
||||
/* 更多操作子菜单 */
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(100%);
|
||||
min-width: 90px;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.more-item {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.more-item:hover { background: var(--color-fill-1); }
|
||||
.more-item.danger { color: var(--color-danger-6); }
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: var(--color-fill-1);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: var(--color-primary-6);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.add-btn:hover { background: var(--color-primary-light-1); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
||||
<!-- 有选中文件时显示表头和内容 -->
|
||||
<template v-if="config.currentFileName">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
||||
@@ -72,7 +74,8 @@
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<div v-else-if="config.isVideoView" class="media-preview">
|
||||
<video :src="config.previewUrl" controls class="preview-video"></video>
|
||||
<video :src="config.previewUrl" controls class="preview-video" @error="handleMediaError('视频')"></video>
|
||||
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||
<div class="media-meta">
|
||||
<a-tag color="arcoblue">🎬 视频</a-tag>
|
||||
</div>
|
||||
@@ -80,7 +83,8 @@
|
||||
|
||||
<!-- 音频预览 -->
|
||||
<div v-else-if="config.isAudioView" class="media-preview">
|
||||
<audio :src="config.previewUrl" controls class="preview-audio"></audio>
|
||||
<audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')"></audio>
|
||||
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||
<div class="media-meta">
|
||||
<a-tag color="green">🎵 音频</a-tag>
|
||||
</div>
|
||||
@@ -88,7 +92,8 @@
|
||||
|
||||
<!-- PDF 预览 -->
|
||||
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
|
||||
<iframe :src="config.previewUrl" class="preview-pdf"></iframe>
|
||||
<iframe :src="config.previewUrl" class="preview-pdf" @load="handlePdfLoad"></iframe>
|
||||
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||
<div class="media-meta">
|
||||
<a-tag color="orangered">📕 PDF</a-tag>
|
||||
</div>
|
||||
@@ -155,6 +160,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -218,6 +225,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -284,6 +293,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -337,6 +348,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -346,6 +359,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -359,6 +373,7 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||
const AsyncCodeEditor = defineAsyncComponent({
|
||||
@@ -424,13 +439,27 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// HTML 预览 URL(使用后端接口)
|
||||
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||
function resolveHtmlPreviewBase(): string {
|
||||
if (!connectionManager.isRemote()) return 'http://localhost:8073'
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return 'http://localhost:8073'
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs(htmlPreviewUrl 会替换为 html-preview)
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
|
||||
const htmlPreviewUrl = computed(() => {
|
||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
|
||||
return ''
|
||||
}
|
||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return ''
|
||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||
return `http://localhost:18765/localfs/html-preview?path=${encodedPath}`
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = resolveHtmlPreviewBase()
|
||||
if (isRemote) {
|
||||
// 远程模式:走 /api/v1/proxy/html-preview 路由
|
||||
const baseUrl = base.replace(/\/proxy\/localfs\/?$/, '')
|
||||
return `${baseUrl}/proxy/html-preview?path=${encodedPath}`
|
||||
}
|
||||
// 本地模式:直连文件服务器
|
||||
return `${base}/localfs/html-preview?path=${encodedPath}`
|
||||
})
|
||||
|
||||
// 计算属性:判断文件是否在当前目录
|
||||
@@ -490,6 +519,30 @@ const handleImageError = () => {
|
||||
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 公共函数
|
||||
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
|
||||
const printWindow = window.open('', '_blank')
|
||||
@@ -642,7 +695,7 @@ const loadExcelPreview = async (filePath: string) => {
|
||||
|
||||
// 直接从本地文件服务器获取(不走 base64)
|
||||
const fileUrl = props.config.previewUrl
|
||||
const response = await fetch(fileUrl)
|
||||
const response = await authFetch(fileUrl)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
||||
|
||||
@@ -671,7 +724,7 @@ const loadWordPreview = async (filePath: string) => {
|
||||
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||
|
||||
const fileUrl = props.config.previewUrl
|
||||
const response = await fetch(fileUrl)
|
||||
const response = await authFetch(fileUrl)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
||||
|
||||
@@ -701,7 +754,7 @@ const loadCsvPreview = async (filePath: string) => {
|
||||
|
||||
const blob = props.config.fileContent && !props.config.isBinaryFile
|
||||
? new Blob([props.config.fileContent], { type: 'text/csv' })
|
||||
: await (await fetch(props.config.previewUrl)).blob()
|
||||
: await (await authFetch(props.config.previewUrl)).blob()
|
||||
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
|
||||
|
||||
const result = await previewCsv(file, csvPreviewRef.value)
|
||||
@@ -775,11 +828,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
||||
// 处理 HTML iframe 发送的消息(链接点击)
|
||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null', // about:blank 或 data: URL
|
||||
'http://localhost:18765', // 本地文件服务器
|
||||
'null',
|
||||
resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
@@ -827,11 +880,9 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-fill-1);
|
||||
padding: 3px 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -936,6 +987,13 @@ onUnmounted(() => {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.media-error {
|
||||
color: var(--color-danger-6);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📋 文件列表</span>
|
||||
<div class="panel-header-right">
|
||||
<span class="panel-count">{{ config.fileList.length }} 项</span>
|
||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '150px' }">
|
||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
||||
<a-button size="mini" type="text" class="settings-btn">
|
||||
<icon-more />
|
||||
</a-button>
|
||||
@@ -31,6 +30,18 @@
|
||||
:disabled="col.key === 'name' && visibleCount <= 1"
|
||||
@change="(val: boolean) => toggleColumn(col.key, val)"
|
||||
>{{ 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>
|
||||
</template>
|
||||
</a-popover>
|
||||
@@ -38,21 +49,20 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="file-list-wrapper"
|
||||
class="file-list-wrapper thin-dark-scrollbar"
|
||||
@contextmenu.prevent="handleWrapperContextMenu"
|
||||
>
|
||||
<!-- 文件列表(a-table) -->
|
||||
<!-- 文件列表(滚动区域) -->
|
||||
<a-table
|
||||
v-if="config.fileList.length > 0 || config.fileLoading"
|
||||
:columns="tableColumns"
|
||||
:data="config.fileList"
|
||||
:data="pagedFileList"
|
||||
:loading="config.fileLoading"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
:show-header="showHeader"
|
||||
size="mini"
|
||||
:row-class-name="getRowClassName"
|
||||
:scroll="{ y: 'auto' }"
|
||||
class="file-table"
|
||||
@row-click="handleRowClick"
|
||||
@row-contextmenu="handleRowContextMenu"
|
||||
@@ -64,13 +74,27 @@
|
||||
<span>此文件夹为空</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, computed, nextTick, ref } from 'vue'
|
||||
import { h, computed, nextTick, ref, watch } from 'vue'
|
||||
import { Input, Button } from '@arco-design/web-vue'
|
||||
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } from '@arco-design/web-vue/es/icon'
|
||||
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore, IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
|
||||
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
||||
@@ -101,6 +125,14 @@ interface 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 SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
|
||||
@@ -139,7 +171,8 @@ function loadColSettings(): ColumnConfig[] {
|
||||
}
|
||||
|
||||
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
|
||||
// 默认隐藏表头(localStorage 无值时默认不显示)
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
|
||||
|
||||
// 手动持久化(避免 deep watch 频繁写入)
|
||||
function saveColSettings() {
|
||||
@@ -311,6 +344,26 @@ const tableColumns = computed(() => {
|
||||
.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 target = ev.target as HTMLElement
|
||||
@@ -351,12 +404,13 @@ defineExpose({ focusEditingItem })
|
||||
.file-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */
|
||||
min-height: 0; /* 允许收缩到小于内容高度 */
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 6px 12px;
|
||||
padding: 3px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-2);
|
||||
flex-shrink: 0;
|
||||
@@ -382,15 +436,39 @@ defineExpose({ focusEditingItem })
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ====== Table 全局覆盖 ====== */
|
||||
/* ====== Table ====== */
|
||||
.file-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.file-table :deep(.arco-table) {
|
||||
font-size: 13px;
|
||||
table-layout: fixed;
|
||||
@@ -524,4 +602,35 @@ defineExpose({ focusEditingItem })
|
||||
gap: 8px;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- 路径段 -->
|
||||
<div
|
||||
class="breadcrumb-segment"
|
||||
:class="{ 'is-hoverable': index < segments.length - 1 }"
|
||||
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
|
||||
@mouseenter="onHover(segment, index)"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick(segment)"
|
||||
@@ -152,7 +152,8 @@ const resetAndClose = () => {
|
||||
}
|
||||
|
||||
const onHover = (segment: PathSegment, index: number) => {
|
||||
if (index === segments.value.length - 1) return
|
||||
// 根目录(如 C:)只有一段,也允许悬停弹出子目录
|
||||
if (index === segments.value.length - 1 && segments.value.length > 1) return
|
||||
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||
@@ -209,7 +210,7 @@ watch(() => props.path, () => {
|
||||
.breadcrumb-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -242,7 +243,7 @@ watch(() => props.path, () => {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 2px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
/* 弹出菜单 */
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<div v-show="config.visible" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">⭐ 收藏夹</span>
|
||||
<span class="sidebar-count">{{ config.favoriteFiles.length }}</span>
|
||||
<!-- 收藏夹区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header" @click="favCollapsed = !favCollapsed">
|
||||
<span class="section-title">⭐ 收藏夹</span>
|
||||
<span class="section-count">共{{ config.favoriteFiles.length }}项</span>
|
||||
<icon-down v-if="!favCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="section-content" :class="{ collapsed: favCollapsed }">
|
||||
<div
|
||||
v-for="(fav, index) in config.favoriteFiles"
|
||||
:key="fav.path"
|
||||
@@ -17,7 +21,7 @@
|
||||
'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"
|
||||
:draggable="config.draggingState.pressedIndex === index || config.draggingState.isDragging"
|
||||
@click="handleOpenFavorite(fav)"
|
||||
@mousedown="handleLongPressStart($event, index)"
|
||||
@mouseup="handleLongPressCancel"
|
||||
@@ -61,11 +65,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 帮助文档区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
|
||||
<span class="section-title">📖 帮助</span>
|
||||
<icon-down v-if="!helpCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</div>
|
||||
<div class="section-content help-content" :class="{ collapsed: helpCollapsed }">
|
||||
<div class="help-item" v-for="item in helpItems" :key="item.key">
|
||||
<span class="help-key">{{ item.key }}</span>
|
||||
<span class="help-desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||
|
||||
// Props
|
||||
@@ -75,6 +95,10 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 折叠状态(组件内部,不污染父组件)
|
||||
const favCollapsed = ref(false)
|
||||
const helpCollapsed = ref(true)
|
||||
|
||||
// 计算第一个和最后一个置顶项的索引
|
||||
const pinnedIndices = computed(() => {
|
||||
return props.config.favoriteFiles
|
||||
@@ -85,6 +109,15 @@ const pinnedIndices = computed(() => {
|
||||
const firstPinnedIndex = computed(() => pinnedIndices.value[0] ?? -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
|
||||
interface Emits {
|
||||
(e: 'openFavorite', file: FavoriteFile): void
|
||||
@@ -101,7 +134,7 @@ interface Emits {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 图标导入
|
||||
import { IconStar, IconClose, IconPushpin } from '@arco-design/web-vue/es/icon'
|
||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileIcon } from '@/utils/fileUtils'
|
||||
|
||||
// 事件处理
|
||||
@@ -151,37 +184,100 @@ const handleDragEnd = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
/* 区块 */
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-bg-2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
/* 区块头部 - 可点击折叠 */
|
||||
.section-header {
|
||||
padding: 5px 12px;
|
||||
background: var(--color-bg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.sidebar-count {
|
||||
.section-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
.section-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 区块内容 - 可折叠 */
|
||||
.section-content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||
max-height: calc(100vh - 80px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-content.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* 收藏夹内容 */
|
||||
.section-content:not(.help-content) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
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);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-2);
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-desc {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* 收藏项 */
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -257,6 +353,7 @@ const handleDragEnd = () => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.sidebar-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -283,7 +380,7 @@ const handleDragEnd = () => {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 滑动动画 */
|
||||
/* 侧边栏整体滑入滑出动画 */
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
📦 {{ config.zipFileName }}
|
||||
</a-tag>
|
||||
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
|
||||
<icon-right class="breadcrumb-separator" />
|
||||
<icon-right class="breadcrumb-sep" />
|
||||
<a-tag
|
||||
v-for="(crumb, index) in config.zipBreadcrumbs"
|
||||
:key="index"
|
||||
@@ -25,13 +25,36 @@
|
||||
退出 ZIP
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- 正常模式:面包屑导航 -->
|
||||
<!-- 正常模式:连接指示器 + 面包屑导航(融合布局) -->
|
||||
<div v-else class="path-breadcrumb-wrapper">
|
||||
<!-- 连接指示器(紧凑标签样式,作为面包屑首段) -->
|
||||
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
|
||||
<span class="breadcrumb-sep">›</span>
|
||||
<!-- 路径面包屑 -->
|
||||
<PathBreadcrumb
|
||||
:path="config.filePath"
|
||||
@navigate="handleGoToPath"
|
||||
@openFile="handleOpenFile"
|
||||
/>
|
||||
<!-- 右侧操作:快捷路径 + 复制 -->
|
||||
<div class="breadcrumb-right-actions">
|
||||
<a-tooltip content="快捷路径" position="bottom">
|
||||
<a-dropdown>
|
||||
<a-button size="mini" type="text" class="shortcut-btn">
|
||||
<template #icon><icon-forward /></template>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<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-tooltip>
|
||||
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
|
||||
<a-button
|
||||
size="mini"
|
||||
@@ -47,47 +70,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<!-- 快捷路径下拉 -->
|
||||
<a-dropdown v-if="!config.isBrowsingZip">
|
||||
<a-button size="small">
|
||||
<template #icon>
|
||||
<icon-forward />
|
||||
</template>
|
||||
快捷访问
|
||||
</a-button>
|
||||
<template #content>
|
||||
<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-input-search
|
||||
:model-value="config.searchKeyword"
|
||||
placeholder="搜索文件..."
|
||||
size="small"
|
||||
class="toolbar-search"
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleSearch"
|
||||
@keyup.escape="handleClearSearch"
|
||||
/>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button
|
||||
@@ -101,6 +97,29 @@
|
||||
刷新
|
||||
</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
|
||||
size="small"
|
||||
@@ -113,13 +132,18 @@
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionDialog ref="connectionDialogRef" v-model:visible="showConnectionDialog" />
|
||||
</template>
|
||||
|
||||
<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 type { ToolbarConfig } from '@/types/file-system'
|
||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
import ConnectionIndicator from './ConnectionIndicator.vue'
|
||||
import ConnectionDialog from './ConnectionDialog.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
@@ -132,15 +156,33 @@ const props = defineProps<Props>()
|
||||
interface Emits {
|
||||
(e: 'update:filePath', path: string): void
|
||||
(e: 'update:showSidebar', show: boolean): void
|
||||
(e: 'update:searchKeyword', keyword: string): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'exitZip'): void
|
||||
(e: 'goToPath', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
(e: 'navigateToZipDirectory', path: string): void
|
||||
(e: 'connectionChanged'): void
|
||||
}
|
||||
|
||||
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) => {
|
||||
emit('goToPath', path)
|
||||
@@ -162,16 +204,28 @@ const handleNavigateToZipRoot = () => {
|
||||
emit('navigateToZipDirectory', '')
|
||||
}
|
||||
|
||||
const handleNavigateToZipDirectory = (path: string) => {
|
||||
emit('navigateToZipDirectory', path)
|
||||
}
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
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()
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ toggleHistoryDropdown })
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.filePath)
|
||||
}
|
||||
@@ -202,32 +256,59 @@ const handleCopyPath = async () => {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.path-breadcrumb-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color 0.2s;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.path-breadcrumb-wrapper:hover {
|
||||
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 {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
@@ -253,12 +334,6 @@ const handleCopyPath = async () => {
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-tag {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
@@ -272,12 +347,18 @@ const handleCopyPath = async () => {
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
.zip-path-text {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
white-space: nowrap;
|
||||
/* 历史记录下拉 */
|
||||
.history-dropdown-content {
|
||||
max-width: 420px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-path-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,90 +5,56 @@
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { PATH_ICONS } from '@/utils/constants'
|
||||
import { getCommonPaths } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { ShortcutPath } from '@/types/file-system'
|
||||
|
||||
export function useCommonPaths() {
|
||||
// 系统路径
|
||||
const commonPaths = ref<ShortcutPath[]>([])
|
||||
const systemPaths = ref<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* 加载常用系统路径
|
||||
*/
|
||||
const loadCommonPaths = async () => {
|
||||
try {
|
||||
// 检查 Wails API 是否可用
|
||||
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('无法获取系统路径')
|
||||
}
|
||||
const paths = await getCommonPaths()
|
||||
if (!paths) throw new Error('无法获取系统路径')
|
||||
|
||||
systemPaths.value = paths
|
||||
const platform = window.navigator.platform
|
||||
const pathList: ShortcutPath[] = []
|
||||
// 根据返回数据判断平台(Linux agent 返回 root key,Windows 返回 root_ 前缀)
|
||||
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
|
||||
|
||||
if (platform.includes('Win')) {
|
||||
// Windows: 先添加基础路径,再添加所有盘符
|
||||
if (isWin) {
|
||||
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 })
|
||||
|
||||
// 动态添加所有盘符(按字母顺序)
|
||||
const drives: Array<{ letter: string; path: string }> = []
|
||||
for (const key in paths) {
|
||||
if (key.startsWith('root_')) {
|
||||
const driveLetter = key.substring(5)
|
||||
drives.push({
|
||||
letter: driveLetter,
|
||||
path: paths[key]
|
||||
})
|
||||
drives.push({ letter: key.substring(5), path: paths[key] })
|
||||
}
|
||||
}
|
||||
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
||||
|
||||
// 添加盘符到路径列表
|
||||
drives.forEach(drive => {
|
||||
pathList.push({
|
||||
name: `${PATH_ICONS.DRIVE} ${drive.letter}盘`,
|
||||
path: drive.path
|
||||
})
|
||||
})
|
||||
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}盘`, path: d.path }))
|
||||
} else {
|
||||
// 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 })
|
||||
// Linux 远程模式
|
||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
|
||||
pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
||||
if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
||||
if (paths.users) pathList.push({ name: `👥 /home`, path: paths.users })
|
||||
}
|
||||
|
||||
commonPaths.value = pathList.length > 0 ? pathList : [
|
||||
{ name: '💿 C盘', path: 'C:\\' },
|
||||
{ name: '💿 D盘', path: 'D:\\' }
|
||||
]
|
||||
commonPaths.value = pathList.length > 0 ? pathList : (
|
||||
connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('加载系统路径失败:', error)
|
||||
// 降级方案
|
||||
commonPaths.value = [
|
||||
{ name: '💿 C盘', path: 'C:\\' },
|
||||
{ name: '💿 D盘', path: 'D:\\' }
|
||||
]
|
||||
commonPaths.value = connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commonPaths,
|
||||
systemPaths,
|
||||
loadCommonPaths
|
||||
}
|
||||
return { commonPaths, systemPaths, loadCommonPaths }
|
||||
}
|
||||
|
||||
@@ -21,18 +21,12 @@ export function useFavorites() {
|
||||
})
|
||||
|
||||
/**
|
||||
* 排序收藏列表:置顶项在前(按 pinnedAt 降序),非置顶项按添加时间降序
|
||||
* 排序收藏列表:置顶项归到前面,组内保持原有顺序(尊重拖拽)
|
||||
*/
|
||||
const sortFavorites = () => {
|
||||
favorites.value = [...favorites.value].sort((a, b) => {
|
||||
// 置顶项优先
|
||||
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
|
||||
})
|
||||
const pinned = favorites.value.filter(f => f.pinnedAt)
|
||||
const unpinned = favorites.value.filter(f => !f.pinnedAt)
|
||||
favorites.value = [...pinned, ...unpinned]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +44,7 @@ export function useFavorites() {
|
||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||
}))
|
||||
|
||||
// 排序
|
||||
// 仅排序(置顶项归组到前面),保持用户拖拽顺序
|
||||
sortFavorites()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -171,15 +165,23 @@ export function useFavorites() {
|
||||
}
|
||||
|
||||
// 拖拽方法
|
||||
const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
if (event instanceof MouseEvent && event.button !== 0) return
|
||||
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
|
||||
|
||||
longPressTimer = setTimeout(() => {
|
||||
draggingState.value.pressedIndex = index
|
||||
draggingState.value.draggedIndex = index
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onLongPressCancel = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
if (!draggingState.value.isDragging) {
|
||||
draggingState.value.pressedIndex = -1
|
||||
draggingState.value.draggedIndex = -1
|
||||
@@ -191,7 +193,6 @@ export function useFavorites() {
|
||||
draggingState.value.draggedIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', index.toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import { useFileOperations } from './useFileOperations'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface UseFileEditOptions {
|
||||
currentFilePath?: any
|
||||
currentDirectory?: any
|
||||
currentFilePath?: import('vue').Ref<FileItem | null>
|
||||
currentDirectory?: import('vue').Ref<string>
|
||||
}
|
||||
|
||||
// 文件大小限制(5MB)
|
||||
@@ -46,9 +47,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||
const fileVersion = ref(0)
|
||||
|
||||
// 最后一次文件加载的时间戳,用于过滤过期更新
|
||||
const lastLoadTime = ref(0)
|
||||
|
||||
// 使用文件操作 composable
|
||||
const { readFile, writeFile } = useFileOperations({
|
||||
onSuccess: (operation, data) => {
|
||||
@@ -81,7 +79,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
||||
*/
|
||||
const isBinaryFileByExt = (filepath: any): boolean | null => {
|
||||
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
|
||||
const path = getFilePath(filepath)
|
||||
const ext = getExt(path)
|
||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||
@@ -185,9 +183,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 增加文件版本号,使之前的过期更新失效
|
||||
fileVersion.value++
|
||||
|
||||
// 记录加载时间戳,用于过滤过期更新
|
||||
lastLoadTime.value = Date.now()
|
||||
|
||||
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
||||
// 新内容加载完成后会直接替换旧内容
|
||||
|
||||
@@ -456,6 +451,33 @@ ${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
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置文件内容
|
||||
*/
|
||||
@@ -501,14 +523,10 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件内容
|
||||
* 注意:需要确保更新后 fileContent 和 originalContent 保持正确的同步关系
|
||||
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容)
|
||||
*/
|
||||
const updateContent = (content: string, expectedVersion?: number) => {
|
||||
// 如果提供了期望的版本号,检查是否匹配
|
||||
// 这用于防止快速切换文件时,旧文件的防抖更新覆盖新文件的内容
|
||||
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
||||
// 版本不匹配,这是一个过期的更新,忽略它
|
||||
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
||||
expected: expectedVersion,
|
||||
current: fileVersion.value,
|
||||
@@ -517,25 +535,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
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) {
|
||||
fileContent.value = content
|
||||
}
|
||||
|
||||
// 自动保存草稿(防抖)
|
||||
// 实际实现应该使用防抖函数
|
||||
// saveDraft()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -556,12 +558,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
return filePath.startsWith(currentDirectory.value)
|
||||
}
|
||||
|
||||
// 监听文件内容变化,自动保存草稿
|
||||
watch(fileContent, () => {
|
||||
// 实际实现应该使用防抖
|
||||
// saveDraft()
|
||||
}, { deep: true })
|
||||
|
||||
// 监听文件路径变化,清除草稿
|
||||
watch(currentFilePath, (newPath, oldPath) => {
|
||||
if (newPath !== oldPath) {
|
||||
@@ -604,6 +600,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
// 其他
|
||||
resetContent,
|
||||
clearContent,
|
||||
updateFilePath,
|
||||
setEditorHeight,
|
||||
|
||||
// 文件类型检查
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ref } from 'vue'
|
||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||
import { detectFileTypeByContent } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
@@ -26,12 +27,22 @@ export interface UseFilePreviewOptions {
|
||||
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 = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
|
||||
// 文件服务器 URL(硬编码,与旧版本保持一致)
|
||||
const fileServerURL = 'http://localhost:18765'
|
||||
|
||||
// 预览 URL
|
||||
const previewUrl = ref('')
|
||||
|
||||
@@ -40,12 +51,19 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const currentImageDimensions = ref('')
|
||||
|
||||
/**
|
||||
* 获取预览 URL(与旧版本保持一致)
|
||||
* 获取预览 URL(本地/远程自适应,每次实时计算)
|
||||
* 本地: http://localhost:8073/localfs/{encoded_path}
|
||||
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
|
||||
*/
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
||||
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = resolveFileServerBase()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
|
||||
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +94,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
/**
|
||||
* 更新预览 URL
|
||||
*/
|
||||
const updatePreviewUrl = (path: string) => {
|
||||
const updatePreviewUrl = async (path: string) => {
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
@@ -188,12 +206,6 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
// 文件类型判断(同步,基于扩展名)
|
||||
getFileType,
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
isAudioFile,
|
||||
isPdfFile,
|
||||
isHtmlFile,
|
||||
isMarkdownFile,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="file-system-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<Toolbar
|
||||
ref="toolbarRef"
|
||||
:config="toolbarConfig"
|
||||
@update:file-path="handleFilePathUpdate"
|
||||
@update:show-sidebar="handleSidebarToggle"
|
||||
@@ -10,7 +11,9 @@
|
||||
@go-to-path="handleGoToPath"
|
||||
@open-file="handleOpenFile"
|
||||
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
||||
@update:search-keyword="handleSearchKeywordUpdate"
|
||||
@show-message="handleShowMessage"
|
||||
@connection-changed="handleConnectionChanged"
|
||||
/>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
@@ -54,9 +57,8 @@
|
||||
<!-- 分隔条 -->
|
||||
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
||||
|
||||
<!-- 文件编辑器面板 -->
|
||||
<!-- 文件编辑器面板(始终显示,无选中文件时为空白预览区) -->
|
||||
<FileEditorPanel
|
||||
v-if="hasSelectedFile"
|
||||
:config="fileEditorPanelConfig"
|
||||
:width="panelWidth.right"
|
||||
:current-directory="filePath"
|
||||
@@ -105,7 +107,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
// 导入子组件
|
||||
@@ -127,6 +129,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||
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 { createResizeHandler } from '@/utils/resize'
|
||||
|
||||
@@ -152,6 +155,7 @@ const isEditableWithPreview = (filename: string): boolean => {
|
||||
const fileList = ref<FileItem[]>([])
|
||||
const fileLoading = ref(false)
|
||||
const selectedFileItem = ref<FileItem | null>(null)
|
||||
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||
|
||||
// 排序状态(带 localStorage 持久化)
|
||||
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
||||
@@ -187,6 +191,18 @@ const editingFileName = ref('')
|
||||
// 侧边栏
|
||||
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 持久化)
|
||||
const restorePanelWidth = (): { left: number; right: number } => {
|
||||
try {
|
||||
@@ -271,7 +287,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
|
||||
})
|
||||
|
||||
// 文件编辑
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
useFileEdit({
|
||||
currentFilePath: selectedFileItem,
|
||||
currentDirectory: filePath
|
||||
@@ -295,7 +311,8 @@ const toolbarConfig = computed(() => ({
|
||||
fileLoading: fileLoading.value,
|
||||
showSidebar: showSidebar.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value
|
||||
sortOrder: sortOrder.value,
|
||||
searchKeyword: searchKeyword.value
|
||||
}))
|
||||
|
||||
// 侧边栏配置
|
||||
@@ -307,7 +324,7 @@ const sidebarConfig = computed(() => ({
|
||||
|
||||
// 文件列表面板配置
|
||||
const fileListPanelConfig = computed(() => ({
|
||||
fileList: fileList.value,
|
||||
fileList: filteredFileList.value,
|
||||
fileLoading: fileLoading.value,
|
||||
selectedFileItem: selectedFileItem.value,
|
||||
editingFilePath: editingFilePath.value,
|
||||
@@ -320,10 +337,24 @@ const computeRendered = computed(() => {
|
||||
if (isHtmlFile(currentFileName)) {
|
||||
return fileContent.value || ''
|
||||
} else if (isMarkdownFile(currentFileName)) {
|
||||
// 使用配置好的 marked 渲染 Markdown(支持 mermaid)
|
||||
// 使用配置好的 marked 渲染 Markdown(支持 mermaid + 图片相对路径转换)
|
||||
try {
|
||||
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) {
|
||||
console.error('Markdown 解析失败:', error)
|
||||
return fileContent.value || ''
|
||||
@@ -363,7 +394,8 @@ const fileEditorPanelConfig = computed(() => {
|
||||
imageLoading: imageLoading.value,
|
||||
currentImageDimensions: currentImageDimensions.value,
|
||||
currentFileExtension,
|
||||
isBinaryFile: isBinaryFileRef.value
|
||||
isBinaryFile: isBinaryFileRef.value,
|
||||
fileMtime: selectedFileItem.value?.modified_time || ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -382,6 +414,23 @@ const handleRefresh = async () => {
|
||||
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) => {
|
||||
await navigate(path)
|
||||
}
|
||||
@@ -479,6 +528,26 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
|
||||
|
||||
// 侧边栏事件
|
||||
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) {
|
||||
await navigate(file.path)
|
||||
} else {
|
||||
@@ -619,24 +688,12 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果重命名的是当前打开的文件,先关闭编辑器和预览
|
||||
if (selectedFileItem.value?.path === oldPath) {
|
||||
// 如果是文件(不是文件夹),才需要关闭编辑器
|
||||
if (!selectedFileItem.value.isDir) {
|
||||
// 清空编辑器内容
|
||||
await clearContent()
|
||||
// 标记是否需要重命名后仅更新路径(内容不变,零闪烁)
|
||||
let needUpdatePath = false
|
||||
|
||||
// 清空预览URL
|
||||
if (previewUrl.value) {
|
||||
previewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 取消选中状态
|
||||
selectedFileItem.value = null
|
||||
|
||||
// 等待文件句柄释放(文件需要更长时间)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
// 如果重命名的是当前打开的文件
|
||||
if (selectedFileItem.value?.path === oldPath && !selectedFileItem.value.isDir) {
|
||||
needUpdatePath = true
|
||||
}
|
||||
|
||||
const renamedFile = await fileOps.rename(oldPath, trimmedName)
|
||||
@@ -650,6 +707,13 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
}
|
||||
|
||||
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
||||
|
||||
// 仅更新路径关联,不重新加载内容(编辑器内容不变,零闪烁)
|
||||
if (needUpdatePath && !renamedFile.isDir) {
|
||||
selectedFileItem.value = renamedFile
|
||||
updateFilePath(newPath)
|
||||
updatePreviewUrl(newPath)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 提取错误信息
|
||||
let errorMsg = error?.message || error?.toString() || '未知错误'
|
||||
@@ -1008,6 +1072,9 @@ const selectFile = async (path: string) => {
|
||||
|
||||
// 加载文件内容
|
||||
await loadFileContent(path)
|
||||
|
||||
// 记住上次打开的文件
|
||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
|
||||
}
|
||||
|
||||
const loadFileContent = async (path: string) => {
|
||||
@@ -1162,7 +1229,7 @@ const extractZipImageAndPreview = async (zipPath: string, filePath: string): Pro
|
||||
const temp = await fileOps.extractZipFileToTemp(zipPath, filePath)
|
||||
const url = await fileOps.getFileServerURL()
|
||||
const normalized = temp.replace(/\\/g, '/')
|
||||
updatePreviewUrl(`${url}/localfs/${encodeURIComponent(normalized)}`)
|
||||
updatePreviewUrl(normalized)
|
||||
} catch (error) {
|
||||
console.error('提取图片失败:', error)
|
||||
Message.error(`提取图片失败: ${error}`)
|
||||
@@ -1201,18 +1268,32 @@ const handleHorizontalResize = createResizeHandler(
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
|
||||
onMounted(() => {
|
||||
// 加载系统路径
|
||||
loadCommonPaths()
|
||||
onMounted(async () => {
|
||||
// 加载系统路径(阻塞,确保快捷入口就绪)
|
||||
await loadCommonPaths()
|
||||
|
||||
// 初始化加载
|
||||
if (!filePath.value) {
|
||||
// 设置默认路径
|
||||
const defaultPath = commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:\\'
|
||||
filePath.value = defaultPath
|
||||
loadDirectory(defaultPath)
|
||||
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
||||
const startPath = connectionManager.isRemote() ? '/'
|
||||
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
||||
if (filePath.value && !connectionManager.isRemote()) {
|
||||
await loadDirectory(filePath.value)
|
||||
} else {
|
||||
loadDirectory(filePath.value)
|
||||
filePath.value = startPath
|
||||
await loadDirectory(startPath)
|
||||
}
|
||||
|
||||
// 恢复上次打开的文件
|
||||
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
|
||||
if (lastFile) {
|
||||
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const currentDir = filePath.value.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const lastFileDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/'
|
||||
if (lastFileDir.toLowerCase() === currentDir.toLowerCase()) {
|
||||
const found = fileList.value.find(f => f.path.replace(/\\/g, '/').toLowerCase() === normalized.toLowerCase())
|
||||
if (found && !found.isDir) {
|
||||
await selectFile(found.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加键盘快捷键
|
||||
@@ -1237,12 +1318,23 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
return
|
||||
}
|
||||
|
||||
// F5 刷新文件列表
|
||||
// F5 刷新文件列表 + 重载当前预览文件
|
||||
if (event.key === 'F5') {
|
||||
event.preventDefault()
|
||||
if (filePath.value) {
|
||||
loadDirectory(filePath.value)
|
||||
await loadDirectory(filePath.value)
|
||||
// 如果有正在预览的文件,同时重新加载其内容(类似重新点击一次)
|
||||
if (selectedFileItem.value && !selectedFileItem.value.isDir) {
|
||||
await loadFileContent(selectedFileItem.value.path)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+H 打开历史记录面板
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'h') {
|
||||
event.preventDefault()
|
||||
toolbarRef.value?.toggleHistoryDropdown?.()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1470,7 +1562,7 @@ watch(() => themeStore.isDark, async () => {
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 4px;
|
||||
width: 3px;
|
||||
background: var(--color-border);
|
||||
cursor: col-resize;
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
||||
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -66,8 +68,8 @@ const showUpdateModal = () => {
|
||||
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
||||
content: () => {
|
||||
const elements = [
|
||||
h('div', { style: { marginBottom: '12px' } }, [
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '版本:'),
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('span', { style: { fontSize: '13px', 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-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
||||
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
||||
@@ -76,20 +78,23 @@ const showUpdateModal = () => {
|
||||
|
||||
// 更新日志
|
||||
if (changelog.value) {
|
||||
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '12px' } }, [
|
||||
h('div', { style: { fontSize: '13px', color: 'var(--color-text-2)', marginBottom: '8px' } }, '更新内容:'),
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'),
|
||||
h('div', {
|
||||
style: {
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-2)',
|
||||
lineHeight: '1.8',
|
||||
padding: '12px',
|
||||
lineHeight: '1.6',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--color-fill-1)',
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}
|
||||
}, changelog.value)
|
||||
maxHeight: '240px',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
innerHTML: changelogHtml
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -104,7 +109,7 @@ const showUpdateModal = () => {
|
||||
}
|
||||
if (metadata.length > 0) {
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '12px', fontSize: '13px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||
h('div', { style: { marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="changelog-title">
|
||||
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
||||
</div>
|
||||
<div class="changelog">{{ updateInfo.changelog }}</div>
|
||||
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
@@ -92,8 +92,8 @@
|
||||
:status="downloadStatus"
|
||||
/>
|
||||
<div class="progress-info">
|
||||
<span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
|
||||
<span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
|
||||
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
||||
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,8 @@ import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { IconHistory } from '@arco-design/web-vue/es/icon'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
|
||||
// Emits
|
||||
defineEmits(['open-version-history'])
|
||||
@@ -134,13 +136,10 @@ const lastCheckTime = ref('-')
|
||||
const installResult = ref(null)
|
||||
const downloadedFile = ref(null)
|
||||
|
||||
// 工具函数
|
||||
const formatFileSize = (bytes) => {
|
||||
return updateStore.formatFileSize(bytes)
|
||||
}
|
||||
|
||||
const formatSpeed = (bytesPerSecond) => {
|
||||
return updateStore.formatSpeed(bytesPerSecond)
|
||||
/** 渲染 changelog(Markdown → HTML) */
|
||||
function renderChangelog(text: string): string {
|
||||
if (!text) return ''
|
||||
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text }
|
||||
}
|
||||
|
||||
// 加载当前版本
|
||||
@@ -283,29 +282,70 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.changelog-section {
|
||||
margin-top: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.changelog-title {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.changelog {
|
||||
background: var(--color-fill-2);
|
||||
padding: 12px;
|
||||
background: var(--color-fill-1);
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
margin: 8px 0;
|
||||
max-height: 200px;
|
||||
margin: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
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 {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 可见数据库管理 Composable
|
||||
* 封装 visible_databases 字段的解析和过滤逻辑
|
||||
*/
|
||||
|
||||
/**
|
||||
* 解析可见数据库 JSON 字符串
|
||||
* @param jsonStr - JSON 字符串或 null
|
||||
* @returns 解析后的数据库数组,解析失败返回空数组
|
||||
*/
|
||||
export function parseVisibleDatabases(jsonStr: string | null): string[] {
|
||||
if (!jsonStr) return []
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据可见数据库配置过滤数据库列表
|
||||
* @param databases - 完整的数据库列表
|
||||
* @param visibleJson - 可见数据库 JSON 字符串
|
||||
* @returns 过滤后的数据库列表(如果未配置过滤则返回全部)
|
||||
*/
|
||||
export function filterDatabases(databases: string[], visibleJson: string | null): string[] {
|
||||
const visible = parseVisibleDatabases(visibleJson)
|
||||
return visible.length > 0 ? databases.filter(db => visible.includes(db)) : databases
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据库数组序列化为 JSON 字符串(空数组返回空字符串)
|
||||
* @param databases - 数据库数组
|
||||
* @returns JSON 字符串或空字符串
|
||||
*/
|
||||
export function serializeVisibleDatabases(databases: string[]): string {
|
||||
return databases.length > 0 ? JSON.stringify(databases) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见数据库管理 Composable
|
||||
*/
|
||||
export function useVisibleDatabases() {
|
||||
return {
|
||||
parse: parseVisibleDatabases,
|
||||
filter: filterDatabases,
|
||||
serialize: serializeVisibleDatabases,
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ interface TabConfig {
|
||||
/**
|
||||
* 应用配置类型
|
||||
*/
|
||||
interface AppConfig {
|
||||
export interface AppConfig {
|
||||
tabs: TabConfig[]
|
||||
visibleTabs: string[]
|
||||
defaultTab: string
|
||||
@@ -44,8 +44,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
|
||||
if (!tabs?.length) {
|
||||
return [
|
||||
{ key: 'file-system', title: '文件管理' },
|
||||
{ key: 'db-cli', title: '数据库' }
|
||||
{ key: 'file-system', title: '文件管理' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -93,8 +92,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data
|
||||
|
||||
// 一级 Tab 只有文件管理和数据库,其他功能(Markdown、版本历史)不作为独立 Tab
|
||||
const allKeys = ['file-system', 'db-cli']
|
||||
const tabTitles: Record<string, string> = { 'file-system': '文件管理', 'db-cli': '数据库' }
|
||||
const allKeys = ['file-system']
|
||||
const tabTitles: Record<string, string> = { 'file-system': '文件管理' }
|
||||
const mergedTabs = allKeys.map(key => tabs.find(t => t.key === key) || { key, title: tabTitles[key] || key, enabled: true })
|
||||
const mergedVisible = visibleTabs.length
|
||||
? visibleTabs.filter(k => allKeys.includes(k))
|
||||
@@ -119,10 +118,9 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const useDefaultConfig = () => {
|
||||
appConfig.value = {
|
||||
tabs: [
|
||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
|
||||
{ key: 'db-cli', title: '数据库', visible: true, enhanced: true }
|
||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true }
|
||||
],
|
||||
visibleTabs: ['file-system', 'db-cli'],
|
||||
visibleTabs: ['file-system'],
|
||||
defaultTab: 'file-system'
|
||||
}
|
||||
}
|
||||
|
||||
44
web/src/stores/connection.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 连接状态 Pinia Store
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { connectionManager, type ConnectionProfile, type ConnectionState } from '@/api/connection-manager'
|
||||
|
||||
export const useConnectionStore = defineStore('connection', () => {
|
||||
const state = ref<ConnectionState>(connectionManager.state)
|
||||
const activeProfile = ref<ConnectionProfile | null>(connectionManager.activeProfile)
|
||||
|
||||
connectionManager.onStateChange((s) => { state.value = s })
|
||||
|
||||
const isConnected = computed(() => state.value === 'connected')
|
||||
const isRemote = computed(() => connectionManager.isRemote())
|
||||
const fileServerBaseURL = computed(() => connectionManager.getFileServerBaseURL())
|
||||
|
||||
function connect(id: string) {
|
||||
connectionManager.connect(id)
|
||||
activeProfile.value = connectionManager.activeProfile
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
connectionManager.disconnect()
|
||||
activeProfile.value = connectionManager.activeProfile
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
activeProfile.value = connectionManager.activeProfile
|
||||
state.value = connectionManager.state
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
activeProfile,
|
||||
isConnected,
|
||||
isRemote,
|
||||
fileServerBaseURL,
|
||||
connect,
|
||||
disconnect,
|
||||
refresh,
|
||||
}
|
||||
})
|
||||
@@ -115,6 +115,8 @@ export interface ToolbarConfig {
|
||||
sortBy: string
|
||||
/** 排序方向 */
|
||||
sortOrder: string
|
||||
/** 搜索关键词 */
|
||||
searchKeyword: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +201,8 @@ export interface FileEditorPanelConfig {
|
||||
currentFileExtension: string
|
||||
/** 是否为二进制文件 */
|
||||
isBinaryFile: boolean
|
||||
/** 文件修改时间(用于检测外部变更) */
|
||||
fileMtime: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,4 +10,7 @@ export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
export { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
// 查找替换
|
||||
export { openSearchPanel, closeSearchPanel, search, searchKeymap, SearchQuery } from '@codemirror/search'
|
||||
|
||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||
|
||||
@@ -30,6 +30,7 @@ export const STORAGE_KEYS = {
|
||||
SORT: 'app-filesystem-sort', // 排序状态
|
||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||
},
|
||||
|
||||
// 设备测试模块
|
||||
@@ -154,6 +155,7 @@ export const FILE_ICONS = {
|
||||
RUBY: '💎',
|
||||
DART: '🎯',
|
||||
DOCKERFILE: '🐳',
|
||||
VUE: '💚',
|
||||
|
||||
// 数据库
|
||||
DATABASE: '🗄️',
|
||||
@@ -270,6 +272,8 @@ const initIconMap = () => {
|
||||
'dart': FILE_ICONS.DART,
|
||||
// Dockerfile
|
||||
'dockerfile': FILE_ICONS.DOCKERFILE,
|
||||
// Vue
|
||||
'vue': FILE_ICONS.VUE,
|
||||
}
|
||||
|
||||
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* 数据库错误提示工具
|
||||
* 将技术性错误信息转换为用户友好的提示
|
||||
*/
|
||||
|
||||
/**
|
||||
* 解析数据库连接错误,返回友好的提示信息
|
||||
*/
|
||||
export function getFriendlyDatabaseError(error: Error | string | unknown): string {
|
||||
const errorMsg = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error)
|
||||
|
||||
// MySQL 错误码处理
|
||||
if (errorMsg.includes('Error 1045') || errorMsg.includes('28000')) {
|
||||
return '用户名或密码错误,请检查连接配置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 2002') || errorMsg.includes('Error 2003')) {
|
||||
return '无法连接到数据库服务器,请检查主机地址和端口是否正确'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 2003')) {
|
||||
return '无法连接到 MySQL 服务器,请检查:\n1. 主机地址和端口是否正确\n2. MySQL 服务是否已启动\n3. 防火墙是否允许连接'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 1045') || errorMsg.includes('Access denied')) {
|
||||
return '认证失败,请检查:\n1. 用户名是否正确\n2. 密码是否正确\n3. 用户是否有访问权限'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 1130') || errorMsg.includes('host is not allowed')) {
|
||||
return '当前 IP 不被允许连接,请检查 MySQL 用户的主机访问权限配置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 10061') || errorMsg.includes('Connection refused')) {
|
||||
return '连接被拒绝,请检查:\n1. 端口是否正确\n2. 数据库服务是否已启动'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('Error 10060') || errorMsg.includes('timeout')) {
|
||||
return '连接超时,请检查:\n1. 网络连接是否正常\n2. 主机地址是否正确\n3. 防火墙设置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('No connection') || errorMsg.includes('closed')) {
|
||||
return '数据库连接已断开,请尝试重新连接'
|
||||
}
|
||||
|
||||
// MongoDB 错误处理
|
||||
if (errorMsg.includes('Authentication failed')) {
|
||||
return 'MongoDB 认证失败,请检查用户名和密码'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('connection refused') || errorMsg.includes('ECONNREFUSED')) {
|
||||
return '无法连接到 MongoDB 服务器,请检查服务是否已启动'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('ENOTFOUND') || errorMsg.includes('no such host')) {
|
||||
return '主机地址无法解析,请检查主机名是否正确'
|
||||
}
|
||||
|
||||
// Redis 错误处理
|
||||
if (errorMsg.includes('NOAUTH')) {
|
||||
return 'Redis 需要密码认证'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('WRONGPASS')) {
|
||||
return 'Redis 密码错误'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('connection')) {
|
||||
return '无法连接到 Redis 服务器,请检查:\n1. 主机地址和端口是否正确\n2. Redis 服务是否已启动'
|
||||
}
|
||||
|
||||
// 网络相关
|
||||
if (errorMsg.includes('network') || errorMsg.includes('ENETUNREACH')) {
|
||||
return '网络连接失败,请检查网络设置'
|
||||
}
|
||||
|
||||
// 通用错误
|
||||
if (errorMsg.includes('获取 MySQL 客户端失败')) {
|
||||
return '数据库连接初始化失败,请检查连接配置'
|
||||
}
|
||||
|
||||
if (errorMsg.includes('连接.*失败')) {
|
||||
return '数据库连接失败,请检查连接配置'
|
||||
}
|
||||
|
||||
// 返回原始错误信息(去除技术性前缀)
|
||||
const friendly = errorMsg
|
||||
.replace(/^获取 MySQL 客户端失败:\s*/, '')
|
||||
.replace(/^连接 MySQL 失败:\s*/, '')
|
||||
.replace(/^连接 MongoDB 失败:\s*/, '')
|
||||
.replace(/^连接 Redis 失败:\s*/, '')
|
||||
.replace(/^Error \d+:\s*/, '')
|
||||
|
||||
return friendly || '未知错误,请检查连接配置'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接失败的友好提示
|
||||
*/
|
||||
export function getConnectionFailedTip(error: Error | string | unknown, dbType: string = 'mysql'): string {
|
||||
const friendlyError = getFriendlyDatabaseError(error)
|
||||
const dbTypeName = dbType === 'mysql' ? 'MySQL' : dbType === 'mongo' ? 'MongoDB' : 'Redis'
|
||||
|
||||
return `${dbTypeName} 连接失败:${friendlyError}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加载数据失败的友好提示
|
||||
*/
|
||||
export function getLoadFailedTip(error: Error | string | unknown, loadType: 'databases' | 'tables' | 'keys'): string {
|
||||
const friendlyError = getFriendlyDatabaseError(error)
|
||||
const typeText = loadType === 'databases' ? '数据库列表' : loadType === 'tables' ? '表列表' : '键列表'
|
||||
|
||||
return `加载${typeText}失败:${friendlyError}`
|
||||
}
|
||||
@@ -38,6 +38,21 @@ export const escapeHtml = (str) => {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量 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 - 文件路径
|
||||
|
||||
@@ -100,6 +100,80 @@ renderer.heading = function(token: any) {
|
||||
</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 => {
|
||||
if (!href) return false
|
||||
@@ -108,6 +182,23 @@ const isLocalFileLink = (href: string): boolean => {
|
||||
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) {
|
||||
const href = token.href || ''
|
||||
@@ -126,7 +217,7 @@ renderer.link = function(token: any) {
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
marked.use({ renderer, breaks: true, gfm: true })
|
||||
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||
|
||||
export { marked }
|
||||
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
title="数据库连接配置"
|
||||
width="600px"
|
||||
:body-style="{ padding: '16px 20px' }"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<!-- 错误提示区域 -->
|
||||
<a-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
closable
|
||||
@close="errorMessage = ''"
|
||||
class="error-alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</a-alert>
|
||||
|
||||
<a-form :model="form" :rules="rules" ref="formRef" layout="horizontal" :label-col-props="{ span: 6 }"
|
||||
:wrapper-col-props="{ span: 18 }" size="small">
|
||||
<a-form-item label="连接名称" field="name">
|
||||
<a-input v-model="form.name" placeholder="请输入连接名称" size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="数据库类型" field="type">
|
||||
<a-select v-model="form.type" placeholder="请选择数据库类型" @change="handleTypeChange" size="small">
|
||||
<a-option value="mysql">MySQL</a-option>
|
||||
<a-option value="redis">Redis</a-option>
|
||||
<a-option value="mongo">MongoDB</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="主机地址" field="host">
|
||||
<a-input v-model="form.host" placeholder="请输入主机地址" size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="端口" field="port">
|
||||
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="请输入端口" style="width: 100%"
|
||||
size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="用户名" field="username" v-if="form.type !== 'redis'">
|
||||
<a-input v-model="form.username" placeholder="请输入用户名" size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" field="password">
|
||||
<div v-if="props.connectionId && !isPasswordChanged" class="password-display">
|
||||
<a-input
|
||||
value="已保存的密码"
|
||||
disabled
|
||||
class="password-input"
|
||||
size="small"
|
||||
/>
|
||||
<a-button type="text" size="mini" @click="isPasswordChanged = true">
|
||||
修改密码
|
||||
</a-button>
|
||||
</div>
|
||||
<a-input-password
|
||||
v-else
|
||||
v-model="form.password"
|
||||
:placeholder="getPasswordPlaceholder()"
|
||||
size="small"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :label="form.type === 'redis' ? '数据库编号' : '数据库名'" field="database">
|
||||
<a-input v-model="form.database"
|
||||
:placeholder="form.type === 'redis' ? 'Redis DB 编号 (0-15,默认为0)' : '可选,留空则连接所有数据库'"
|
||||
:max-length="100"
|
||||
size="small"/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 数据库过滤选项(仅 MySQL 和 MongoDB) -->
|
||||
<template v-if="form.type === 'mysql' || form.type === 'mongo'">
|
||||
<a-form-item label="可见数据库" field="visibleDatabases">
|
||||
<div class="database-list-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="list-toolbar">
|
||||
<a-button
|
||||
type="outline"
|
||||
size="small"
|
||||
@click="loadAllDatabases"
|
||||
:loading="loadingDatabases"
|
||||
:disabled="!canLoadDatabases"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-refresh />
|
||||
</template>
|
||||
{{ allDatabases.length > 0 ? '重新加载' : '加载数据库列表' }}
|
||||
</a-button>
|
||||
|
||||
<template v-if="allDatabases.length > 0">
|
||||
<div class="toolbar-stats">
|
||||
已选 {{ selectedDatabases.length }} / {{ allDatabases.length }}
|
||||
</div>
|
||||
<a-button size="small" @click="handleSelectAll(true)">全选</a-button>
|
||||
<a-button size="small" @click="handleInvertSelection">反选</a-button>
|
||||
<a-button size="small" @click="handleSelectAll(false)">清空</a-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loadingDatabases && allDatabases.length === 0" class="list-empty">
|
||||
<icon-storage />
|
||||
<span>点击上方按钮加载数据库列表</span>
|
||||
</div>
|
||||
|
||||
<!-- 数据库列表(复选框) -->
|
||||
<div v-else class="database-checkbox-list">
|
||||
<div
|
||||
v-for="db in allDatabases"
|
||||
:key="db"
|
||||
class="database-checkbox-item"
|
||||
>
|
||||
<a-checkbox
|
||||
:model-value="selectedDatabases.includes(db)"
|
||||
:disabled="loadingDatabases"
|
||||
@change="(checked: boolean) => toggleDatabase(db, checked)"
|
||||
>
|
||||
{{ db }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div v-if="allDatabases.length > 0" class="list-tip">
|
||||
<icon-info-circle />
|
||||
未选择任何数据库时,将展示所有数据库
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- MongoDB 专用选项 -->
|
||||
<template v-if="form.type === 'mongo'">
|
||||
<a-form-item label="认证数据库" field="options.authSource">
|
||||
<a-input v-model="optionsForm.authSource" placeholder="留空则使用 admin" size="small"/>
|
||||
<template #extra>
|
||||
<span class="form-item-extra">MongoDB 用户所在的数据库,通常为 admin(可选)</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
<template #footer>
|
||||
<a-space size="small">
|
||||
<a-button @click="handleTest" :loading="testing" size="small">测试连接</a-button>
|
||||
<a-button @click="handleCancel" size="small">取消</a-button>
|
||||
<a-button type="primary" @click="handleSubmit" :loading="saving" size="small">保存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {reactive, ref, watch, computed, nextTick} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {
|
||||
IconCheckCircle,
|
||||
IconClose,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconStorage
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
ListDbConnections,
|
||||
SaveDbConnection
|
||||
} from '../../../wailsjs/wailsjs/go/main/App'
|
||||
import { getConnectionFailedTip, getLoadFailedTip } from '@/utils/database-error'
|
||||
import { useVisibleDatabases } from '@/composables/useVisibleDatabases'
|
||||
|
||||
// 使用 defineModel 简化 v-model:visible 双向绑定(Vue 3.5+)
|
||||
const visible = defineModel('visible', { type: Boolean, default: false })
|
||||
|
||||
// 使用 TypeScript 泛型语法(Vue 3.5+)
|
||||
const props = defineProps<{
|
||||
connectionId?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const formRef = ref<any>(null)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const errorMessage = ref('')
|
||||
// 是否修改密码(编辑模式下)
|
||||
const isPasswordChanged = ref(false)
|
||||
|
||||
// 数据库过滤相关
|
||||
const { parse: parseVisibleDatabases, filter: filterVisibleDatabases } = useVisibleDatabases()
|
||||
const loadingDatabases = ref(false)
|
||||
const allDatabases = ref<string[]>([])
|
||||
const selectedDatabases = ref<string[]>([])
|
||||
|
||||
// 是否可以加载数据库列表
|
||||
const canLoadDatabases = computed(() =>
|
||||
!!(form.host && form.port && form.username && (form.password || props.connectionId))
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
username: '',
|
||||
password: '',
|
||||
database: '',
|
||||
options: '',
|
||||
visibleDatabases: ''
|
||||
})
|
||||
|
||||
// 选项表单(用于表单输入)
|
||||
const optionsForm = reactive({
|
||||
authSource: ''
|
||||
})
|
||||
|
||||
// 将 options JSON 字符串解析为 optionsForm
|
||||
const parseOptionsToForm = (optionsStr: string) => {
|
||||
if (!optionsStr || optionsStr.trim() === '') {
|
||||
optionsForm.authSource = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const opts = JSON.parse(optionsStr)
|
||||
optionsForm.authSource = opts.authSource || ''
|
||||
// 认证机制使用自动检测,不需要从选项读取
|
||||
} catch (error) {
|
||||
console.warn('解析 Options JSON 失败:', error)
|
||||
// 解析失败时,清空表单选项
|
||||
optionsForm.authSource = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 将 optionsForm 和 form.options 合并为 JSON 字符串
|
||||
const mergeOptionsToJson = (): string => {
|
||||
let customOptions: any = {}
|
||||
|
||||
// 先解析已有的 JSON(可能包含其他自定义选项)
|
||||
if (form.options && form.options.trim() !== '') {
|
||||
try {
|
||||
customOptions = JSON.parse(form.options)
|
||||
} catch (error) {
|
||||
console.warn('解析自定义 Options JSON 失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数据库类型合并表单选项(仅 MongoDB)
|
||||
if (form.type === 'mongo') {
|
||||
// 只有认证数据库不为空时才添加
|
||||
if (optionsForm.authSource && optionsForm.authSource.trim() !== '') {
|
||||
customOptions.authSource = optionsForm.authSource.trim()
|
||||
}
|
||||
// 认证机制使用自动检测,不需要添加到选项
|
||||
}
|
||||
|
||||
// 如果没有任何选项,返回空字符串
|
||||
if (Object.keys(customOptions).length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return JSON.stringify(customOptions)
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{required: true, message: '请输入连接名称'},
|
||||
{maxLength: 100, message: '连接名称长度不能超过100个字符'}
|
||||
],
|
||||
type: [{required: true, message: '请选择数据库类型'}],
|
||||
host: [
|
||||
{required: true, message: '请输入主机地址'},
|
||||
{maxLength: 255, message: '主机地址长度不能超过255个字符'}
|
||||
],
|
||||
port: [
|
||||
{required: true, message: '请输入端口'},
|
||||
{
|
||||
validator: (value, callback) => {
|
||||
if (!value || value < 1 || value > 65535) {
|
||||
callback('端口号必须在1-65535之间')
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
database: [
|
||||
{
|
||||
validator: (value, callback) => {
|
||||
// MySQL 类型时数据库名为可选(允许为空)
|
||||
// MongoDB 和 Redis 也为可选
|
||||
callback()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 获取密码输入框的占位符
|
||||
const getPasswordPlaceholder = () => {
|
||||
if (props.connectionId) return '请输入新密码'
|
||||
const placeholders = { redis: '可选,留空则无密码连接', mongo: '可选,留空则无认证连接' }
|
||||
return placeholders[form.type] || '请输入密码'
|
||||
}
|
||||
|
||||
// 监听类型变化,设置默认端口、主机和用户名
|
||||
const handleTypeChange = (type) => {
|
||||
// 如果主机为空,设置默认值
|
||||
if (!form.host || form.host.trim() === '') {
|
||||
form.host = '127.0.0.1'
|
||||
}
|
||||
|
||||
// 根据类型设置默认端口和用户名
|
||||
switch (type) {
|
||||
case 'mysql':
|
||||
form.port = 3306
|
||||
if (!form.username) {
|
||||
form.username = 'root'
|
||||
}
|
||||
// 清空 MongoDB 专用选项
|
||||
optionsForm.authSource = ''
|
||||
form.options = ''
|
||||
break
|
||||
case 'redis':
|
||||
form.port = 6379
|
||||
form.username = '' // Redis 不需要用户名
|
||||
if (!form.database) {
|
||||
form.database = '0' // Redis 默认 DB 0
|
||||
}
|
||||
// 清空其他数据库的选项
|
||||
optionsForm.authSource = ''
|
||||
form.options = ''
|
||||
break
|
||||
case 'mongo':
|
||||
case 'mongodb':
|
||||
form.port = 27017
|
||||
if (!form.username) {
|
||||
form.username = 'admin' // MongoDB 常用默认用户名
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 类型变化时,同步更新 options JSON
|
||||
form.options = mergeOptionsToJson()
|
||||
}
|
||||
|
||||
// 加载连接详情
|
||||
const loadConnection = async () => {
|
||||
if (!props.connectionId) {
|
||||
resetForm()
|
||||
// 新建模式:不自动加载,等用户手动点击
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (!(window as any).go?.main?.App?.ListDbConnections) {
|
||||
return
|
||||
}
|
||||
|
||||
const connections = await (window as any).go.main.App.ListDbConnections()
|
||||
const conn = connections.find(c => c.id === props.connectionId)
|
||||
if (conn) {
|
||||
form.name = conn.name
|
||||
form.type = conn.type
|
||||
form.host = conn.host || '127.0.0.1'
|
||||
form.port = conn.port || (conn.type === 'mysql' ? 3306 : conn.type === 'redis' ? 6379 : 27017)
|
||||
form.username = conn.username || ''
|
||||
form.database = conn.database || ''
|
||||
// 先解析 options 到表单
|
||||
parseOptionsToForm(conn.options || '')
|
||||
// 然后设置 form.options(这样不会触发 watch)
|
||||
form.options = conn.options || ''
|
||||
// 设置可见数据库
|
||||
form.visibleDatabases = conn.visible_databases || ''
|
||||
// 编辑模式下,默认不修改密码
|
||||
form.password = ''
|
||||
isPasswordChanged.value = false
|
||||
|
||||
// 恢复数据库选择
|
||||
selectedDatabases.value = parseVisibleDatabases(conn.visible_databases || null)
|
||||
|
||||
// 编辑模式:自动加载数据库列表
|
||||
nextTick(() => {
|
||||
loadAllDatabases()
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载连接详情失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取要使用的密码(编辑模式下未修改密码时为空)
|
||||
const getPasswordToUse = () =>
|
||||
(props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 表单验证(返回 true 表示验证通过)
|
||||
const validateForm = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return false
|
||||
}
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
return true
|
||||
} catch (error) {
|
||||
const errorMsg = error?.fields?.[Object.keys(error.fields)[0]]?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.type = 'mysql'
|
||||
form.host = '127.0.0.1'
|
||||
form.port = 3306
|
||||
form.username = 'root' // MySQL 默认用户名
|
||||
form.password = ''
|
||||
form.database = ''
|
||||
form.options = ''
|
||||
form.visibleDatabases = ''
|
||||
optionsForm.authSource = ''
|
||||
isPasswordChanged.value = false
|
||||
loadingDatabases.value = false
|
||||
allDatabases.value = []
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
|
||||
// 测试连接(不保存数据)
|
||||
const handleTest = async () => {
|
||||
if (!(await validateForm())) return
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
const msg = 'Go 后端未就绪,请确保应用已启动'
|
||||
errorMessage.value = msg
|
||||
Message.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
await (window as any).go.main.App.TestDbConnectionWithParams({
|
||||
id: props.connectionId || 0,
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: getPasswordToUse(),
|
||||
database: form.database || '',
|
||||
options: mergeOptionsToJson()
|
||||
})
|
||||
Message.success('连接测试成功')
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
const friendlyMsg = getConnectionFailedTip(error, form.type)
|
||||
errorMessage.value = friendlyMsg
|
||||
Message.error(friendlyMsg)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!(await validateForm())) return
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
const msg = 'Go 后端未就绪,请确保应用已启动'
|
||||
errorMessage.value = msg
|
||||
Message.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await (window as any).go.main.App.SaveDbConnection({
|
||||
id: props.connectionId || 0,
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: getPasswordToUse(),
|
||||
database: form.database || '',
|
||||
options: mergeOptionsToJson(),
|
||||
visible_databases: form.visibleDatabases || ''
|
||||
})
|
||||
Message.success(props.connectionId ? '更新成功' : '保存成功')
|
||||
errorMessage.value = ''
|
||||
emit('success')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
const errorMsg = error?.message || error?.toString() || '未知错误'
|
||||
errorMessage.value = '保存失败: ' + errorMsg
|
||||
Message.error('保存失败: ' + errorMsg)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
errorMessage.value = ''
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 是否正在加载连接(用于避免加载时触发 watch)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 监听 optionsForm 变化,自动同步到 form.options(仅 MongoDB)
|
||||
watch(
|
||||
() => [optionsForm.authSource, form.type],
|
||||
() => {
|
||||
// 如果正在加载,不触发更新
|
||||
if (isLoading.value) {
|
||||
return
|
||||
}
|
||||
// 仅 MongoDB 需要同步选项
|
||||
if (visible.value && form.type === 'mongo') {
|
||||
form.options = mergeOptionsToJson()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 加载全部数据库列表
|
||||
const loadAllDatabases = async () => {
|
||||
if (!canLoadDatabases.value) {
|
||||
Message.warning('请先填写连接信息')
|
||||
return
|
||||
}
|
||||
|
||||
loadingDatabases.value = true
|
||||
try {
|
||||
const databases = await (window as any).go.main.App.LoadAllDatabases({
|
||||
id: props.connectionId || 0,
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: getPasswordToUse(),
|
||||
database: form.database || '',
|
||||
options: mergeOptionsToJson()
|
||||
})
|
||||
|
||||
allDatabases.value = databases || []
|
||||
|
||||
// 从已保存的 visibleDatabases 中恢复选择(使用 composable)
|
||||
selectedDatabases.value = filterVisibleDatabases(databases, form.visibleDatabases || null)
|
||||
|
||||
Message.success(`成功加载 ${databases.length} 个数据库`)
|
||||
} catch (error) {
|
||||
Message.error(getLoadFailedTip(error, 'databases'))
|
||||
} finally {
|
||||
loadingDatabases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个数据库选择
|
||||
const toggleDatabase = (dbName: string, checked: boolean) => {
|
||||
const list = selectedDatabases.value
|
||||
if (checked && !list.includes(dbName)) {
|
||||
list.push(dbName)
|
||||
} else if (!checked) {
|
||||
selectedDatabases.value = list.filter(db => db !== dbName)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/全不选
|
||||
const handleSelectAll = (selectAll: boolean) => {
|
||||
if (selectAll) {
|
||||
selectedDatabases.value = [...allDatabases.value]
|
||||
} else {
|
||||
selectedDatabases.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 反选
|
||||
const handleInvertSelection = () => {
|
||||
const selectedSet = new Set(selectedDatabases.value)
|
||||
selectedDatabases.value = allDatabases.value.filter(db => !selectedSet.has(db))
|
||||
}
|
||||
|
||||
// 监听数据库选择变化,同步到表单
|
||||
watch(selectedDatabases, (newVal) => {
|
||||
if (newVal.length === 0) {
|
||||
form.visibleDatabases = ''
|
||||
} else {
|
||||
form.visibleDatabases = JSON.stringify(newVal)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (val) => {
|
||||
if (val) {
|
||||
errorMessage.value = ''
|
||||
loadConnection()
|
||||
} else {
|
||||
errorMessage.value = ''
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.password-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.options-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-item-extra {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.database-list-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-stats {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-empty svg {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
/* 复选框列表 */
|
||||
.database-checkbox-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background: var(--color-fill-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.database-checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.database-checkbox-item :deep(.arco-checkbox) {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 底部提示 */
|
||||
.list-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.list-tip svg {
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="context-menu-overlay"
|
||||
@click="handleOverlayClick"
|
||||
@contextmenu.prevent="handleOverlayClick"
|
||||
>
|
||||
<div
|
||||
class="context-menu"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<template v-for="(item, index) in processedItems" :key="item.key || index">
|
||||
<div
|
||||
v-if="item.divider"
|
||||
class="context-menu-divider"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
:class="{ disabled: item.disabled }"
|
||||
@click="handleMenuItemClick(item)"
|
||||
>
|
||||
<span v-if="item.icon" class="context-menu-item-icon">
|
||||
<component :is="item.icon"/>
|
||||
</span>
|
||||
<span class="context-menu-item-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
/**
|
||||
* 菜单项配置
|
||||
*/
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: Component
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
handler?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 defineModel 简化 v-model:visible 双向绑定(Vue 3.5+)
|
||||
*/
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
/**
|
||||
* Props
|
||||
*/
|
||||
const props = defineProps<{
|
||||
position: { x: number; y: number }
|
||||
items: MenuItem[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Emits
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
'menu-item-click': [item: MenuItem]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 点击遮罩层关闭菜单
|
||||
*/
|
||||
const handleOverlayClick = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项点击
|
||||
*/
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (item.disabled) return
|
||||
|
||||
emit('menu-item-click', item)
|
||||
|
||||
if (item.handler) {
|
||||
item.handler()
|
||||
}
|
||||
|
||||
// 点击后关闭菜单
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项(处理分隔线)
|
||||
* divider: true 表示在该菜单项之后添加分隔线
|
||||
*/
|
||||
const processedItems = computed(() => {
|
||||
const result: MenuItem[] = []
|
||||
|
||||
props.items.forEach((item, index) => {
|
||||
// 添加菜单项本身(不包含 divider 标记)
|
||||
const menuItem = { ...item }
|
||||
const hasDivider = menuItem.divider
|
||||
delete menuItem.divider // 移除 divider 标记,避免在渲染时被当作分隔线
|
||||
|
||||
result.push(menuItem)
|
||||
|
||||
// 如果该项标记了 divider,在其后添加分隔线
|
||||
if (hasDivider) {
|
||||
result.push({
|
||||
key: `divider-${index}`,
|
||||
label: '',
|
||||
divider: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
min-width: 160px;
|
||||
padding: 4px 0;
|
||||
background: var(--color-bg-popup, #fff);
|
||||
border: 1px solid var(--color-border-2, #e5e6eb);
|
||||
border-radius: var(--border-radius-medium, 4px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-1, #1d2129);
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) {
|
||||
background: var(--color-fill-2, #f2f3f5);
|
||||
}
|
||||
|
||||
.context-menu-item.disabled {
|
||||
color: var(--color-text-4, #c9cdd4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.context-menu-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.context-menu-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 12px;
|
||||
background: var(--color-border-2, #e5e6eb);
|
||||
}
|
||||
</style>
|
||||
@@ -1,529 +0,0 @@
|
||||
<template>
|
||||
<div class="mysql-create">
|
||||
<a-tabs
|
||||
v-model:active-key="activeTab"
|
||||
type="line"
|
||||
class="create-tabs"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<a-tab-pane key="basic" title="基本信息">
|
||||
<div class="tab-content basic-info-content">
|
||||
<a-form :model="formData" layout="vertical" :label-col-props="{ span: 6 }">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="数据库" field="database">
|
||||
<a-input v-model="formData.database" disabled />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="表名" field="tableName" :rules="[{ required: true, message: '请输入表名' }]">
|
||||
<a-input v-model="formData.tableName" placeholder="请输入表名" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="字符集" field="charset">
|
||||
<a-select v-model="formData.charset" placeholder="选择字符集">
|
||||
<a-option value="utf8mb4">utf8mb4</a-option>
|
||||
<a-option value="utf8">utf8</a-option>
|
||||
<a-option value="latin1">latin1</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="排序规则" field="collation">
|
||||
<a-select v-model="formData.collation" placeholder="选择排序规则">
|
||||
<a-option value="utf8mb4_general_ci">utf8mb4_general_ci</a-option>
|
||||
<a-option value="utf8mb4_unicode_ci">utf8mb4_unicode_ci</a-option>
|
||||
<a-option value="utf8_general_ci">utf8_general_ci</a-option>
|
||||
<a-option value="utf8_unicode_ci">utf8_unicode_ci</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 字段列表 -->
|
||||
<a-tab-pane key="fields" title="字段列表">
|
||||
<div class="tab-content fields-content">
|
||||
<MySQLFieldList
|
||||
mode="create"
|
||||
:fields="formData.fields"
|
||||
@add-field="handleAddField"
|
||||
@remove-field="handleRemoveField"
|
||||
@move-field="handleMoveField"
|
||||
@update-field="handleUpdateField"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 索引列表 -->
|
||||
<a-tab-pane key="indexes" title="索引列表">
|
||||
<div class="tab-content indexes-content">
|
||||
<div class="section-header">
|
||||
<a-button type="primary" size="small" @click="showIndexDialog">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
添加索引
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="formData.indexes.length === 0" class="empty-tip">
|
||||
<a-empty description="暂无索引(可选)" :image="false" />
|
||||
</div>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="indexColumns"
|
||||
:data="formData.indexes"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="true"
|
||||
>
|
||||
<template #unique="{ record }">
|
||||
<a-tag :color="record.unique ? 'blue' : 'default'">
|
||||
{{ record.unique ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #fields="{ record }">
|
||||
{{ record.fields.map((f: any) => f.name).join(', ') }}
|
||||
</template>
|
||||
<template #operations="{ record, rowIndex }">
|
||||
<a-button type="text" size="small" status="danger" @click="removeIndex(rowIndex)">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
</a-button>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- SQL预览 -->
|
||||
<a-tab-pane key="sql" title="SQL预览">
|
||||
<div class="tab-content sql-preview-content">
|
||||
<div class="sql-preview-header">
|
||||
<a-button type="text" size="small" @click="copySQL">
|
||||
<template #icon>
|
||||
<icon-copy />
|
||||
</template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="sql-preview-wrapper">
|
||||
<pre class="sql-code">{{ sqlPreview }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- 索引定义对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="indexDialogVisible"
|
||||
title="添加索引"
|
||||
:width="600"
|
||||
@ok="handleIndexDialogOk"
|
||||
@cancel="handleIndexDialogCancel"
|
||||
>
|
||||
<a-form :model="indexForm" layout="vertical" ref="indexFormRef">
|
||||
<a-form-item label="索引名" field="name" :rules="[{ required: true, message: '请输入索引名' }]">
|
||||
<a-input v-model="indexForm.name" placeholder="请输入索引名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="唯一索引" field="unique">
|
||||
<a-checkbox v-model="indexForm.unique">唯一索引</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item label="索引字段" field="fields" :rules="[{ required: true, message: '请至少选择一个字段' }]">
|
||||
<a-select
|
||||
v-model="indexForm.fields"
|
||||
mode="multiple"
|
||||
placeholder="选择索引字段"
|
||||
:max-tag-count="3"
|
||||
>
|
||||
<a-option
|
||||
v-for="field in formData.fields"
|
||||
:key="field.name"
|
||||
:value="field.name"
|
||||
>
|
||||
{{ field.name }} ({{ field.type }}{{ field.length ? `(${field.length})` : '' }})
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconCopy
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import MySQLFieldList from './MySQLFieldList.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
connectionId: number
|
||||
database: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'create', data: any): void
|
||||
}>()
|
||||
|
||||
// 当前激活的 tab
|
||||
const activeTab = ref<string>('basic')
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
database: props.database,
|
||||
tableName: '',
|
||||
charset: 'utf8mb4',
|
||||
collation: 'utf8mb4_general_ci',
|
||||
fields: [] as any[],
|
||||
indexes: [] as any[]
|
||||
})
|
||||
|
||||
// SQL 预览
|
||||
const sqlPreview = computed(() => {
|
||||
if (formData.fields.length === 0) {
|
||||
return '-- 请先添加字段'
|
||||
}
|
||||
return generateSQL()
|
||||
})
|
||||
|
||||
// 索引表格列
|
||||
const indexColumns = [
|
||||
{ title: '索引名', dataIndex: 'name', width: 150 },
|
||||
{ title: '唯一', dataIndex: 'unique', slotName: 'unique', width: 80 },
|
||||
{ title: '字段', slotName: 'fields', width: 200 },
|
||||
{ title: '操作', slotName: 'operations', width: 100, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 索引对话框
|
||||
const indexDialogVisible = ref(false)
|
||||
const indexFormRef = ref()
|
||||
const indexForm = reactive({
|
||||
name: '',
|
||||
unique: false,
|
||||
fields: [] as string[]
|
||||
})
|
||||
|
||||
// 字段列表事件处理
|
||||
const handleAddField = (field: any) => {
|
||||
formData.fields.push(field)
|
||||
// 自动切换到字段列表 tab
|
||||
if (activeTab.value !== 'fields') {
|
||||
activeTab.value = 'fields'
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateField = (index: number, field: string, value: any) => {
|
||||
if (formData.fields[index]) {
|
||||
formData.fields[index] = { ...formData.fields[index], [field]: value }
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveField = (index: number) => {
|
||||
formData.fields.splice(index, 1)
|
||||
// 同时移除相关索引
|
||||
formData.indexes = formData.indexes.filter((idx: any) => {
|
||||
return idx.fields.every((f: any) => {
|
||||
const fieldName = typeof f === 'string' ? f : f.name
|
||||
return fieldName !== formData.fields[index]?.name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoveField = (index: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && index > 0) {
|
||||
const temp = formData.fields[index]
|
||||
formData.fields[index] = formData.fields[index - 1]
|
||||
formData.fields[index - 1] = temp
|
||||
} else if (direction === 'down' && index < formData.fields.length - 1) {
|
||||
const temp = formData.fields[index]
|
||||
formData.fields[index] = formData.fields[index + 1]
|
||||
formData.fields[index + 1] = temp
|
||||
}
|
||||
}
|
||||
|
||||
const showIndexDialog = () => {
|
||||
if (formData.fields.length === 0) {
|
||||
Message.warning('请先添加字段')
|
||||
return
|
||||
}
|
||||
// 重置表单
|
||||
Object.assign(indexForm, {
|
||||
name: '',
|
||||
unique: false,
|
||||
fields: []
|
||||
})
|
||||
indexDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleIndexDialogOk = async () => {
|
||||
try {
|
||||
// 验证表单
|
||||
await indexFormRef.value?.validate()
|
||||
|
||||
// 检查索引名是否重复
|
||||
if (formData.indexes.some((idx: any) => idx.name === indexForm.name)) {
|
||||
Message.error('索引名已存在')
|
||||
return false // 阻止对话框关闭
|
||||
}
|
||||
|
||||
// 添加索引(深拷贝避免引用问题)
|
||||
const newIndex = {
|
||||
name: indexForm.name,
|
||||
unique: indexForm.unique,
|
||||
fields: indexForm.fields.map((name: string) => ({ name, order: 'ASC' }))
|
||||
}
|
||||
|
||||
formData.indexes.push(newIndex)
|
||||
|
||||
Message.success('索引添加成功')
|
||||
indexDialogVisible.value = false
|
||||
|
||||
// 自动切换到索引列表 tab
|
||||
if (activeTab.value !== 'indexes') {
|
||||
activeTab.value = 'indexes'
|
||||
}
|
||||
} catch (error) {
|
||||
// 表单验证失败时会抛出错误
|
||||
console.error('索引表单验证失败:', error)
|
||||
return false // 阻止对话框关闭
|
||||
}
|
||||
}
|
||||
|
||||
const handleIndexDialogCancel = () => {
|
||||
indexDialogVisible.value = false
|
||||
}
|
||||
|
||||
const removeIndex = (index: number) => {
|
||||
formData.indexes.splice(index, 1)
|
||||
}
|
||||
|
||||
// 复制 SQL
|
||||
const copySQL = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(sqlPreview.value)
|
||||
Message.success('SQL已复制到剪贴板')
|
||||
} catch (error) {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
const validate = (): boolean => {
|
||||
if (!formData.tableName) {
|
||||
Message.error('请输入表名')
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.fields.length === 0) {
|
||||
Message.error('请至少添加一个字段')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否有主键
|
||||
const hasPrimaryKey = formData.fields.some((f: any) => f.primaryKey)
|
||||
if (!hasPrimaryKey) {
|
||||
Message.warning('建议设置主键')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 转义 SQL 字符串(转义单引号)
|
||||
const escapeSQLString = (str: string): string => {
|
||||
return str.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
// 生成 SQL
|
||||
const generateSQL = (): string => {
|
||||
const fieldsSQL = formData.fields.map((field: any) => {
|
||||
let sql = `\`${field.name}\` ${field.type}`
|
||||
if (field.length) {
|
||||
sql += `(${field.length})`
|
||||
}
|
||||
if (!field.nullable) {
|
||||
sql += ' NOT NULL'
|
||||
}
|
||||
// 处理默认值
|
||||
if (field.defaultValue !== null && field.defaultValue !== undefined) {
|
||||
if (field.defaultValue === '') {
|
||||
// 空字符串默认值
|
||||
sql += ` DEFAULT ''`
|
||||
} else {
|
||||
// 转义单引号
|
||||
const escapedDefault = escapeSQLString(String(field.defaultValue))
|
||||
sql += ` DEFAULT '${escapedDefault}'`
|
||||
}
|
||||
}
|
||||
if (field.autoIncrement) {
|
||||
sql += ' AUTO_INCREMENT'
|
||||
}
|
||||
if (field.comment) {
|
||||
// 转义注释中的单引号
|
||||
const escapedComment = escapeSQLString(field.comment)
|
||||
sql += ` COMMENT '${escapedComment}'`
|
||||
}
|
||||
return sql
|
||||
}).join(',\n ')
|
||||
|
||||
// 主键
|
||||
const primaryKeys = formData.fields.filter((f: any) => f.primaryKey).map((f: any) => `\`${f.name}\``)
|
||||
let primaryKeySQL = ''
|
||||
if (primaryKeys.length > 0) {
|
||||
primaryKeySQL = `,\n PRIMARY KEY (${primaryKeys.join(', ')})`
|
||||
}
|
||||
|
||||
// 索引
|
||||
const indexesSQL = formData.indexes.map((idx: any) => {
|
||||
const fields = idx.fields.map((f: any) => `\`${typeof f === 'string' ? f : f.name}\``).join(', ')
|
||||
const unique = idx.unique ? 'UNIQUE ' : ''
|
||||
return ` ${unique}KEY \`${idx.name}\` (${fields})`
|
||||
}).join(',\n')
|
||||
|
||||
const sql = `CREATE TABLE \`${formData.database}\`.\`${formData.tableName}\` (
|
||||
${fieldsSQL}${primaryKeySQL}${indexesSQL ? ',\n' + indexesSQL : ''}
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=${formData.charset} COLLATE=${formData.collation};`
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
validate,
|
||||
generateSQL,
|
||||
getFormData: () => formData
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mysql-create {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tabs 容器 */
|
||||
.create-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs-content-list) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs-content-item) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tab 内容通用样式 */
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-md, 12px);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 基本信息内容 */
|
||||
.basic-info-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.basic-info-content :deep(.arco-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 字段列表和索引列表内容 */
|
||||
.fields-content,
|
||||
.indexes-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fields-content :deep(.arco-table),
|
||||
.indexes-content :deep(.arco-table) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* SQL预览内容 */
|
||||
.sql-preview-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sql-preview-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sql-preview-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--color-fill-2, #f2f3f5);
|
||||
border: 1px solid var(--color-border-2, #e5e6eb);
|
||||
border-radius: var(--border-radius-small, 2px);
|
||||
padding: var(--spacing-md, 12px);
|
||||
}
|
||||
|
||||
.sql-code {
|
||||
margin: 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-1, #1d2129);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||