Compare commits
7 Commits
v0.3.3
...
feature/fs
| Author | SHA1 | Date | |
|---|---|---|---|
| 44847e0d40 | |||
| 3d5a1e5892 | |||
| 4f1d5f885f | |||
| 742581c5d6 | |||
| 4ffac72999 | |||
| 72fef3e56f | |||
| 691e38604f |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
||||||
|
|
||||||
## [0.3.3] - 2026-03-31
|
## [0.3.3] - 2026-04-13
|
||||||
|
|
||||||
### 架构新增 🏗️
|
### 架构新增 🏗️
|
||||||
|
|
||||||
|
|||||||
93
CHANGELOG.md
@@ -1,46 +1,99 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
## [0.3.3] - 2026-03-31
|
## [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
|
||||||
|
|
||||||
### 新增 ✨
|
### 新增 ✨
|
||||||
- **Markdown 编辑器**: 实时预览、编辑、字符/行数统计、Ctrl+S 保存、自动保存
|
- **CodeMirror 搜索功能**: Ctrl+F / Ctrl+H 全局查找替换,`@codemirror/search` 集成
|
||||||
- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面
|
- **编辑器滚动位置恢复**: LRU 缓存(最多5份/3分钟TTL),切换文件不丢位置
|
||||||
- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式
|
- **文件列表列排序**: 图标/名称/时间/大小四列可排序,升序降序切换
|
||||||
- **窗口置顶**: 支持窗口始终置顶
|
- **文件搜索过滤**: 工具栏实时搜索框,按文件名过滤列表
|
||||||
- **收藏夹置顶**: 收藏项置顶排序
|
- **Toolbar UI 重排**: 快捷访问内嵌面包屑左侧、历史记录改为图标+tooltip、Ctrl+H 快捷键
|
||||||
- **文件预览**: Excel/Word 文件预览支持
|
- **更新面板 Markdown 渲染**: changelog 用 `marked.parse()` 结构化渲染,替代纯文本
|
||||||
- 数据库 UI 交互体验改进
|
- **重命名零闪烁**: `updateFilePath()` 仅迁移路径引用+草稿key,不重新加载内容
|
||||||
|
|
||||||
### 优化 🚀
|
### 优化 🚀
|
||||||
- MySQL 动态连接池重构(健康检查、性能权重、自适应扩缩容)
|
- **路径安全重构**: `validateFilePath()` 提取统一函数,消除两处重复校验代码
|
||||||
- SQL 查询优化器(查询缓存、慢查询日志)
|
- **requireUpdateAPI 模式**: 7 处重复 nil 检查收敛为 guard 方法
|
||||||
- Redis Pipeline 支持(批量命令、事务 MULTI/EXEC)
|
- **端口统一**: 文件服务器端口 18765→8073,全局一致消除魔法数字分散
|
||||||
- HTML 预览改用 iframe src 替代 srcdoc
|
- **文件服务器 URL 动态获取**: 前端从后端 API 获取,不再硬编码
|
||||||
- Office/CSV 预览增强(本地文件服务器获取文件)
|
- **Tab 配置迁移扩展**: MigrateTabConfig 改为 map 驱动,覆盖 openclaw-manager→version 迁移
|
||||||
- Markdown 本地文件链接支持 + Shell 语法高亮
|
- **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
|
||||||
|
|
||||||
|
### 新增 ✨
|
||||||
|
- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存
|
||||||
|
- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面 (`views/markdown-editor/`)
|
||||||
|
- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式
|
||||||
|
- **窗口置顶**: 支持窗口始终置顶
|
||||||
|
- **收藏夹置顶**: 收藏项支持置顶排序
|
||||||
|
- **文件预览**: Excel/Word 文件预览支持
|
||||||
|
- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器
|
||||||
|
- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块 (`database-error.ts`)
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容
|
||||||
|
- SQL 查询优化器 — 查询缓存、慢查询日志
|
||||||
|
- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持
|
||||||
|
- Office/CSV 预览增强 — 本地文件服务器获取文件
|
||||||
|
- Markdown 增强 — 本地文件链接支持、Shell 语法高亮
|
||||||
|
- HTML 预览 — 改用 iframe src 替代 srcdoc
|
||||||
|
- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复
|
||||||
|
- 文件列表 UI 重构 — 统一渲染逻辑,提升滚动性能
|
||||||
|
- CSV 编辑模式优化 + PDF 导出重构
|
||||||
|
- 拷贝功能优化
|
||||||
|
|
||||||
### 修复 🐛
|
### 修复 🐛
|
||||||
- Office 文件预览:修复类型检测与二进制误判
|
- Office 文件预览:修复类型检测与二进制误判
|
||||||
|
- 本地文件服务器 CORS 跨域问题
|
||||||
|
- 大文件点击卡死问题
|
||||||
|
- 收藏夹 bug 修复
|
||||||
- FileEditorPanel 语法错误
|
- FileEditorPanel 语法错误
|
||||||
- 修复本地文件服务器 CORS 跨域问题
|
|
||||||
|
|
||||||
### 安全修复 🔒
|
### 安全修复 🔒
|
||||||
- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)
|
- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)
|
||||||
- PDF 导出路径穿越防护
|
- PDF 导出路径穿越防护
|
||||||
- PDF 导出标题 HTML 注入防护
|
- PDF 导出标题 HTML 注入防护
|
||||||
|
|
||||||
### 代码质量 🔧
|
### 重构 🔧
|
||||||
- 正则表达式预编译(query_optimizer)
|
- CodeMirror 架构优化 — 统一导出避免多实例问题
|
||||||
- 缓存读锁优化 + SHA-256 key hash
|
- 消除代码重复 — storage/connection_service 重构、useVisibleDatabases 抽取
|
||||||
- 死代码清理(未使用 import/类型/字段)
|
- 大规模死代码清理,显著减小包体积
|
||||||
- 配置加载超时保护(最多重试 30 次)
|
- 配置加载超时保护(最多重试 30 次)
|
||||||
|
- 正则表达式预编译、缓存读锁优化
|
||||||
- 禁止 Ctrl+滚轮缩放
|
- 禁止 Ctrl+滚轮缩放
|
||||||
- 清理冗余工具函数(fileHelpers、pathHelpers、useLocalStorage)
|
- Dockerfile 语法高亮支持
|
||||||
|
- 滚动条样式修复
|
||||||
|
|
||||||
### 文件系统 📁
|
### 文件系统 📁
|
||||||
- 右键菜单新增新建文件/文件夹
|
- 右键菜单新增新建文件/文件夹
|
||||||
- FileEditorPanel 集成 PDF 导出按钮
|
- FileEditorPanel 集成 PDF 导出按钮
|
||||||
- Markdown 文件自动预览与编辑/预览模式切换
|
- Markdown 文件自动预览与编辑/预览模式切换
|
||||||
|
- 面包屑导航组件
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
24
README.md
@@ -1,10 +1,22 @@
|
|||||||
# U-Desk v0.3.3
|
# U-Desk v0.3.4
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
- 数据库客户端
|
- **文件管理** — 本地文件浏览、编辑(CodeMirror 语法高亮+搜索)、预览(图片/视频/PDF/HTML/Markdown/Excel/Word/CSV)
|
||||||
- Markdown编辑器
|
- **数据库客户端** — 多数据库连接管理、SQL 执行、查询历史、表结构管理
|
||||||
- PDF导出
|
- **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导出优化中
|
|
||||||
|
|||||||
238
app.go
@@ -1,9 +1,11 @@
|
|||||||
|
// [fs-only] 数据库客户端模块已移除(feature/fs-only 分支)
|
||||||
|
// 保留模块:文件系统 | Markdown编辑器 | 版本历史(抽屉) | 系统信息 | 更新检查 | PDF导出
|
||||||
|
// 顶部Tab仅:file-system(数据库 db-cli 已删除)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
stdruntime "runtime"
|
stdruntime "runtime"
|
||||||
@@ -24,13 +26,9 @@ import (
|
|||||||
// App 应用结构体
|
// App 应用结构体
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
connectionAPI *api.ConnectionAPI
|
|
||||||
sqlAPI *api.SqlAPI
|
|
||||||
tabAPI *api.TabAPI
|
|
||||||
updateAPI *api.UpdateAPI
|
updateAPI *api.UpdateAPI
|
||||||
configAPI *api.ConfigAPI
|
configAPI *api.ConfigAPI
|
||||||
pdfAPI *api.PdfAPI
|
pdfAPI *api.PdfAPI
|
||||||
fileServer *http.Server
|
|
||||||
filesystem *filesystem.FileSystemService
|
filesystem *filesystem.FileSystemService
|
||||||
isAlwaysOnTop bool
|
isAlwaysOnTop bool
|
||||||
}
|
}
|
||||||
@@ -91,7 +89,7 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
|
|
||||||
// 5. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
// 5. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
||||||
go func() {
|
go func() {
|
||||||
if updateAPI, err := api.NewUpdateAPI("https://img.1216.top/u-desk/last-version.json"); err == nil {
|
if updateAPI, err := api.NewUpdateAPI("https://c.1216.top/last-version.json"); err == nil {
|
||||||
a.updateAPI = updateAPI
|
a.updateAPI = updateAPI
|
||||||
a.updateAPI.SetContext(ctx)
|
a.updateAPI.SetContext(ctx)
|
||||||
a.startAutoUpdateCheck()
|
a.startAutoUpdateCheck()
|
||||||
@@ -138,31 +136,6 @@ func (a *App) getVisibleTabs() []string {
|
|||||||
|
|
||||||
// initModulesByConfig 根据配置初始化模块
|
// initModulesByConfig 根据配置初始化模块
|
||||||
func (a *App) initModulesByConfig(visibleTabs []string) error {
|
func (a *App) initModulesByConfig(visibleTabs []string) error {
|
||||||
// 检查是否启用数据库模块
|
|
||||||
if common.Contains(visibleTabs, common.TabDatabase) {
|
|
||||||
fmt.Println("[启动] 初始化数据库模块...")
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// 初始化 ConnectionAPI
|
|
||||||
if a.connectionAPI, err = api.NewConnectionAPI(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 SqlAPI
|
|
||||||
if a.sqlAPI, err = api.NewSqlAPI(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 TabAPI
|
|
||||||
if a.tabAPI, err = api.NewTabAPI(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("[启动] 数据库模块初始化完成")
|
|
||||||
} else {
|
|
||||||
fmt.Println("[启动] 跳过数据库模块(未启用)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否启用文件系统模块
|
// 检查是否启用文件系统模块
|
||||||
if common.Contains(visibleTabs, common.TabFileSystem) {
|
if common.Contains(visibleTabs, common.TabFileSystem) {
|
||||||
fmt.Println("[启动] 初始化文件系统模块...")
|
fmt.Println("[启动] 初始化文件系统模块...")
|
||||||
@@ -194,7 +167,7 @@ func (a *App) startFileServer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[文件服务器] 启动在 http://localhost:18765")
|
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown 应用关闭时调用
|
// Shutdown 应用关闭时调用
|
||||||
@@ -415,7 +388,7 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
|||||||
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
|
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
|
||||||
folderGUIDs := map[string]string{
|
folderGUIDs := map[string]string{
|
||||||
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||||
"documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}",
|
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
|
||||||
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||||
}
|
}
|
||||||
for name, guid := range folderGUIDs {
|
for name, guid := range folderGUIDs {
|
||||||
@@ -441,94 +414,6 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 数据库连接管理接口 ==========
|
|
||||||
|
|
||||||
// SaveDbConnection 保存数据库连接配置
|
|
||||||
func (a *App) SaveDbConnection(req api.SaveConnectionRequest) error {
|
|
||||||
return a.connectionAPI.SaveDbConnection(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListDbConnections 获取连接列表
|
|
||||||
func (a *App) ListDbConnections() ([]map[string]interface{}, error) {
|
|
||||||
return a.connectionAPI.ListDbConnections()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteDbConnection 删除连接配置
|
|
||||||
func (a *App) DeleteDbConnection(id uint) error {
|
|
||||||
return a.connectionAPI.DeleteDbConnection(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDbConnection 测试连接(通过已保存的连接ID)
|
|
||||||
func (a *App) TestDbConnection(id uint) error {
|
|
||||||
return a.connectionAPI.TestDbConnection(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
|
|
||||||
func (a *App) TestDbConnectionWithParams(req api.TestConnectionRequest) error {
|
|
||||||
return a.connectionAPI.TestDbConnectionWithParams(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAllDatabases 加载全部数据库列表
|
|
||||||
func (a *App) LoadAllDatabases(req api.LoadAllDatabasesRequest) ([]string, error) {
|
|
||||||
return a.connectionAPI.LoadAllDatabases(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteSQL 执行 SQL 语句
|
|
||||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
|
||||||
func (a *App) ExecuteSQL(connectionId uint, sqlStr string, database string) (map[string]interface{}, error) {
|
|
||||||
return a.sqlAPI.ExecuteSQL(connectionId, sqlStr, database)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDatabases 获取数据库列表
|
|
||||||
func (a *App) GetDatabases(connectionId uint) ([]string, error) {
|
|
||||||
return a.sqlAPI.GetDatabases(connectionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTables 获取表列表
|
|
||||||
func (a *App) GetTables(connectionId uint, database string) ([]string, error) {
|
|
||||||
return a.sqlAPI.GetTables(connectionId, database)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTableStructure 获取表结构
|
|
||||||
func (a *App) GetTableStructure(connectionId uint, database, tableName string) (map[string]interface{}, error) {
|
|
||||||
return a.sqlAPI.GetTableStructure(connectionId, database, tableName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIndexes 获取索引列表
|
|
||||||
func (a *App) GetIndexes(connectionId uint, database, tableName string) ([]map[string]interface{}, error) {
|
|
||||||
return a.sqlAPI.GetIndexes(connectionId, database, tableName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewTableStructure 预览表结构变更
|
|
||||||
func (a *App) PreviewTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
|
||||||
return a.sqlAPI.PreviewTableStructure(connectionId, database, tableName, structure)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateTableStructure 更新表结构
|
|
||||||
func (a *App) UpdateTableStructure(connectionId uint, database, tableName string, structure map[string]interface{}) ([]string, error) {
|
|
||||||
return a.sqlAPI.UpdateTableStructure(connectionId, database, tableName, structure)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveResult 手动保存执行结果
|
|
||||||
func (a *App) SaveResult(connectionId uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (map[string]interface{}, error) {
|
|
||||||
return a.sqlAPI.SaveResult(connectionId, database, sql, resultType, data, columns, rowsAffected, executionTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResultHistory 获取结果历史
|
|
||||||
func (a *App) GetResultHistory(connectionId *uint, keyword string, limit, offset int) (map[string]interface{}, error) {
|
|
||||||
return a.sqlAPI.GetResultHistory(connectionId, keyword, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResultHistoryByID 根据ID获取结果历史
|
|
||||||
func (a *App) GetResultHistoryByID(id uint) (map[string]interface{}, error) {
|
|
||||||
return a.sqlAPI.GetResultHistoryByID(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteResultHistory 删除结果历史
|
|
||||||
func (a *App) DeleteResultHistory(id uint) error {
|
|
||||||
return a.sqlAPI.DeleteResultHistory(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload 重新加载窗口(用于菜单项)
|
// Reload 重新加载窗口(用于菜单项)
|
||||||
func (a *App) Reload() {
|
func (a *App) Reload() {
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
@@ -589,82 +474,86 @@ func (a *App) WindowToggleAlwaysOnTop() bool {
|
|||||||
return a.isAlwaysOnTop
|
return a.isAlwaysOnTop
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== SQL 标签页管理接口 ==========
|
|
||||||
|
|
||||||
// SaveSqlTabs 保存 SQL 标签页列表
|
|
||||||
func (a *App) SaveSqlTabs(tabs []map[string]interface{}) error {
|
|
||||||
return a.tabAPI.SaveSqlTabs(tabs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSqlTabs 获取 SQL 标签页列表
|
|
||||||
func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
|
|
||||||
return a.tabAPI.ListSqlTabs()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 版本更新管理接口 ==========
|
// ========== 版本更新管理接口 ==========
|
||||||
|
|
||||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
|
||||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
|
||||||
if a.updateAPI == nil {
|
if a.updateAPI == nil {
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
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 获取当前版本号
|
// GetCurrentVersion 获取当前版本号
|
||||||
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.GetCurrentVersion()
|
return api.GetCurrentVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUpdateConfig 获取更新配置
|
// GetUpdateConfig 获取更新配置
|
||||||
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.GetUpdateConfig()
|
return api.GetUpdateConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetUpdateConfig 设置更新配置
|
// SetUpdateConfig 设置更新配置
|
||||||
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadUpdate 下载更新包
|
// DownloadUpdate 下载更新包
|
||||||
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.DownloadUpdate(downloadURL)
|
return api.DownloadUpdate(downloadURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallUpdate 安装更新包
|
// InstallUpdate 安装更新包
|
||||||
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
|
return api.InstallUpdate(installerPath, autoRestart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||||
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyUpdateFile 验证更新文件哈希值
|
// VerifyUpdateFile 验证更新文件哈希值
|
||||||
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
|
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startAutoUpdateCheck 启动自动更新检查
|
// startAutoUpdateCheck 启动自动更新检查
|
||||||
@@ -753,7 +642,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
// GetFileServerURL 获取本地文件服务器的URL
|
// GetFileServerURL 获取本地文件服务器的URL
|
||||||
func (a *App) GetFileServerURL() string {
|
func (a *App) GetFileServerURL() string {
|
||||||
return "http://localhost:18765"
|
return "http://localhost:8073"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
||||||
@@ -848,8 +737,6 @@ func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
|
|||||||
|
|
||||||
for _, tab := range newlyEnabled {
|
for _, tab := range newlyEnabled {
|
||||||
switch tab {
|
switch tab {
|
||||||
case common.TabDatabase:
|
|
||||||
a.initDatabaseModule()
|
|
||||||
case common.TabFileSystem:
|
case common.TabFileSystem:
|
||||||
a.initFilesystemModule()
|
a.initFilesystemModule()
|
||||||
case common.TabDevice:
|
case common.TabDevice:
|
||||||
@@ -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 延迟初始化文件系统模块
|
// initFilesystemModule 延迟初始化文件系统模块
|
||||||
func (a *App) initFilesystemModule() {
|
func (a *App) initFilesystemModule() {
|
||||||
if a.filesystem != nil {
|
if a.filesystem != nil {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 35 KiB |
1
build/publish/last-version.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version": "0.4.0", "download_url": "https://c.1216.top/download/u-desk-0.4.0.exe", "changelog": "### 重构 🔧\n- 移除数据库客户端模块:删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- 清理依赖:移除 mysql/redis/mongo 驱动依赖\n- 构建体积优化:原始 exe 26MB,UPX 压缩后 7.5MB(压缩率 28.8%)\n\n### 变更说明\n- 顶部 Tab 仅保留「文件管理」,移除数据库入口\n- Markdown 编辑器、版本历史、系统信息、更新检查等模块不受影响", "force_update": false, "release_date": "2026-04-25", "file_size": 7766016}
|
||||||
1
build/publish/versions.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"updated_at": "2026-04-25T23:58:00+08:00", "versions": [{"version": "0.4.0", "release_date": "2026-04-25", "changelog": "### 重构 🔧\n- **移除数据库客户端模块**: 删除全部 MySQL/Redis/MongoDB 相关代码(-17,885 行),应用专注文件管理\n- **清理依赖**: 移除 go-sql-driver/mysql、go-redis/v9、mongo-driver/v2、gorm.io/driver/mysql 等驱动依赖\n- **构建体积优化**: 原始 exe 从 36MB 降至 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/cdproto v0.0.0-20250724212937-08a3db8b4327
|
||||||
github.com/chromedp/chromedp v0.14.2
|
github.com/chromedp/chromedp v0.14.2
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/labstack/echo/v4 v4.15.0
|
||||||
github.com/redis/go-redis/v9 v9.17.3
|
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/wailsapp/wails/v2 v2.12.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.2
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
|
||||||
golang.org/x/sys v0.40.0
|
golang.org/x/sys v0.40.0
|
||||||
gorm.io/driver/mysql v1.6.0
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
|
||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||||
@@ -37,8 +32,6 @@ require (
|
|||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/compress v1.18.3 // indirect
|
|
||||||
github.com/labstack/echo/v4 v4.15.0 // indirect
|
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
@@ -62,16 +55,12 @@ require (
|
|||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
modernc.org/libc v1.67.7 // indirect
|
modernc.org/libc v1.67.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
53
go.sum
@@ -1,15 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||||
@@ -18,8 +10,6 @@ github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipw
|
|||||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
@@ -31,8 +21,6 @@ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:
|
|||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
@@ -57,8 +45,6 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
|
||||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
|
||||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
@@ -96,8 +82,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
|
||||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
@@ -129,72 +113,43 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
|||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
|
||||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
|
||||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
|
||||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
|
||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
|||||||
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateTabConfig 迁移旧配置
|
// MigrateTabConfig 迁移旧配置(device 移除 + openclaw-manager 重命名)
|
||||||
func (api *ConfigAPI) MigrateTabConfig() error {
|
func (api *ConfigAPI) MigrateTabConfig() error {
|
||||||
config, _ := api.configService.GetTabConfig()
|
config, _ := api.configService.GetTabConfig()
|
||||||
if config == nil {
|
if config == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否包含 device
|
needMigrate := false
|
||||||
hasDevice := false
|
|
||||||
|
// 检查是否包含需要迁移的旧 key
|
||||||
for _, tab := range config.AvailableTabs {
|
for _, tab := range config.AvailableTabs {
|
||||||
if tab.Key == "device" {
|
if tab.Key == "device" || tab.Key == "openclaw-manager" {
|
||||||
hasDevice = true
|
needMigrate = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasDevice {
|
if !needMigrate {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤掉 device
|
// 映射:旧 key → 新 key(不需要的移除)
|
||||||
|
keyMap := map[string]string{
|
||||||
|
"openclaw-manager": "version",
|
||||||
|
// "device": "" // 直接过滤
|
||||||
|
}
|
||||||
|
|
||||||
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
||||||
newVisible := make([]string, 0, len(config.VisibleTabs))
|
newVisible := make([]string, 0, len(config.VisibleTabs))
|
||||||
|
seenKeys := map[string]bool{}
|
||||||
|
|
||||||
for _, tab := range config.AvailableTabs {
|
for _, tab := range config.AvailableTabs {
|
||||||
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)
|
newTabs = append(newTabs, tab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, key := range config.VisibleTabs {
|
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)
|
newVisible = append(newVisible, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultTab := config.DefaultTab
|
defaultTab := config.DefaultTab
|
||||||
|
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
|
||||||
|
defaultTab = newKey
|
||||||
|
}
|
||||||
if defaultTab == "device" {
|
if defaultTab == "device" {
|
||||||
defaultTab = "file-system"
|
defaultTab = "file-system"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Default visible tabs configuration
|
||||||
const (
|
const (
|
||||||
// TabDatabase 数据库管理 Tab
|
|
||||||
TabDatabase = "db-cli"
|
|
||||||
// TabFileSystem 文件系统 Tab
|
// TabFileSystem 文件系统 Tab
|
||||||
TabFileSystem = "file-system"
|
TabFileSystem = "file-system"
|
||||||
// TabDevice 设备测试 Tab
|
// TabDevice 设备测试 Tab
|
||||||
@@ -11,4 +9,4 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// DefaultVisibleTabs 默认可见的 Tabs
|
// DefaultVisibleTabs 默认可见的 Tabs
|
||||||
var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}
|
var DefaultVisibleTabs = []string{TabFileSystem, TabDevice}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -48,6 +49,48 @@ var (
|
|||||||
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||||
var attrRegexCache sync.Map // map[string]*regexp.Regexp
|
var attrRegexCache sync.Map // map[string]*regexp.Regexp
|
||||||
|
|
||||||
|
// 路径校验 sentinel error(用 errors.Is 匹配,不依赖字符串)
|
||||||
|
var (
|
||||||
|
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
|
||||||
|
ErrPathTraversal = fmt.Errorf("path traversal detected")
|
||||||
|
ErrPathUnsafe = fmt.Errorf("unsafe path")
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateFilePath 校验文件路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||||
|
// 返回清理后的绝对路径,或 sentinel error
|
||||||
|
func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
||||||
|
decodedPath, err := url.QueryUnescape(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrPathInvalidEncoding
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(decodedPath, "..") {
|
||||||
|
return "", ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去除代理引入的 /localfs/ 前缀(可能有多层)
|
||||||
|
clean := decodedPath
|
||||||
|
for strings.HasPrefix(clean, "/localfs/") || strings.HasPrefix(clean, "localfs/") {
|
||||||
|
clean = strings.TrimPrefix(clean, "/localfs/")
|
||||||
|
clean = strings.TrimPrefix(clean, "localfs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台适配:Windows 用反斜杠,Linux/macOS 保持正斜杠
|
||||||
|
filePath := filepath.FromSlash(clean)
|
||||||
|
filePath = filepath.Clean(filePath)
|
||||||
|
|
||||||
|
// 确保绝对路径(Linux 以 / 开头,Windows 以盘符开头)
|
||||||
|
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
|
||||||
|
filePath = "/" + filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSafePath(filePath) {
|
||||||
|
return "", ErrPathUnsafe
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
|
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
|
||||||
type LocalFileServer struct {
|
type LocalFileServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
@@ -75,7 +118,7 @@ func StartLocalFileServer() (string, error) {
|
|||||||
|
|
||||||
// 创建服务器(固定端口)
|
// 创建服务器(固定端口)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: "localhost:18765",
|
Addr: "localhost:8073",
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +133,7 @@ func StartLocalFileServer() (string, error) {
|
|||||||
|
|
||||||
localFileServer = &LocalFileServer{
|
localFileServer = &LocalFileServer{
|
||||||
server: server,
|
server: server,
|
||||||
addr: "localhost:18765",
|
addr: "localhost:8073",
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
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)
|
log.Printf("[LocalFileHandler] 收到请求: %s", r.URL.Path)
|
||||||
|
|
||||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
// 从 URL 路径获取文件路径(移除所有 /localfs/ 前缀,兼容代理双重前缀)
|
||||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
pathPart := r.URL.Path
|
||||||
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
|
for strings.HasPrefix(pathPart, "/localfs/") {
|
||||||
|
pathPart = strings.TrimPrefix(pathPart, "/localfs/")
|
||||||
|
}
|
||||||
|
if pathPart == "" || pathPart == r.URL.Path {
|
||||||
|
log.Printf("[LocalFileHandler] 路径前缀无效: original=%s", r.URL.Path)
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅对非绝对路径添加前导 /(Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /)
|
||||||
|
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
|
||||||
|
pathPart = "/" + pathPart
|
||||||
|
}
|
||||||
|
|
||||||
if pathPart == "" || pathPart == r.URL.Path {
|
if pathPart == "" || pathPart == r.URL.Path {
|
||||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||||
@@ -133,33 +187,23 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔒 修复:先进行URL解码,防止路径遍历攻击
|
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||||
decodedPath, err := url.QueryUnescape(pathPart)
|
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||||
return
|
case errors.Is(err, ErrPathTraversal):
|
||||||
}
|
|
||||||
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
|
|
||||||
|
|
||||||
// 🔒 修复:在路径转换前检查是否包含危险字符
|
|
||||||
if strings.Contains(decodedPath, "..") {
|
|
||||||
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
|
|
||||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||||
return
|
case errors.Is(err, ErrPathUnsafe):
|
||||||
}
|
|
||||||
|
|
||||||
// 路径转换(统一使用反斜杠)
|
|
||||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
|
||||||
filePath = filepath.Clean(filePath)
|
|
||||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
|
||||||
|
|
||||||
// 安全检查
|
|
||||||
if !isSafePath(filePath) {
|
|
||||||
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
|
|
||||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||||
|
|
||||||
// 🔒 文件类型白名单检查
|
// 🔒 文件类型白名单检查
|
||||||
ext := strings.ToLower(filepath.Ext(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")
|
rawPath := r.URL.Query().Get("path")
|
||||||
var err error
|
|
||||||
if filePath, err = url.QueryUnescape(filePath); err != nil {
|
|
||||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
theme := r.URL.Query().Get("theme")
|
theme := r.URL.Query().Get("theme")
|
||||||
if theme == "" {
|
if theme == "" {
|
||||||
theme = "light"
|
theme = "light"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||||
|
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
|
||||||
// 安全检查
|
if err != nil {
|
||||||
if !isSafePath(filePath) {
|
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
|
||||||
log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath)
|
switch {
|
||||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
case errors.Is(err, ErrPathInvalidEncoding):
|
||||||
return
|
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||||
}
|
case errors.Is(err, ErrPathTraversal):
|
||||||
|
|
||||||
// 检查路径遍历攻击
|
|
||||||
if strings.Contains(filePath, "..") {
|
|
||||||
log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath)
|
|
||||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||||
|
case errors.Is(err, ErrPathUnsafe):
|
||||||
|
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||||
|
|
||||||
// 读取文件
|
// 读取文件
|
||||||
content, err := os.ReadFile(filePath)
|
content, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
package filesystem
|
package filesystem
|
||||||
|
|
||||||
import (
|
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{
|
var defaultTabConfig = TabConfig{
|
||||||
AvailableTabs: []TabDefinition{
|
AvailableTabs: []TabDefinition{
|
||||||
{Key: "file-system", Title: "文件管理", Enabled: true},
|
{Key: "file-system", Title: "文件管理", Enabled: true},
|
||||||
{Key: "db-cli", Title: "数据库", Enabled: true},
|
|
||||||
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
|
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
|
||||||
{Key: "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",
|
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()
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,7 @@ func LoadUpdateConfig() (*UpdateConfig, error) {
|
|||||||
LastCheckTime: time.Time{}, // 启动时会立即检查
|
LastCheckTime: time.Time{}, // 启动时会立即检查
|
||||||
AutoCheckEnabled: true,
|
AutoCheckEnabled: true,
|
||||||
CheckIntervalMinutes: 5, // 5分钟检查一次
|
CheckIntervalMinutes: 5, // 5分钟检查一次
|
||||||
CheckURL: "https://img.1216.top/u-desk/last-version.json",
|
CheckURL: "https://c.1216.top/last-version.json",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ func LoadUpdateConfig() (*UpdateConfig, error) {
|
|||||||
|
|
||||||
// 使用默认检查地址
|
// 使用默认检查地址
|
||||||
if config.CheckURL == "" {
|
if config.CheckURL == "" {
|
||||||
config.CheckURL = "https://img.1216.top/u-desk/last-version.json"
|
config.CheckURL = "https://c.1216.top/last-version.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保版本号不为空(使用缓存的版本号)
|
// 确保版本号不为空(使用缓存的版本号)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
// ==================== 常量定义 ====================
|
// ==================== 常量定义 ====================
|
||||||
|
|
||||||
// AppVersion 应用版本号(发布时直接修改此处)
|
// AppVersion 应用版本号(发布时直接修改此处)
|
||||||
const AppVersion = "0.3.3"
|
const AppVersion = "0.4.0"
|
||||||
|
|
||||||
// 版本号缓存
|
// 版本号缓存
|
||||||
var (
|
var (
|
||||||
@@ -69,27 +70,16 @@ func ParseVersion(versionStr string) (*Version, error) {
|
|||||||
func (v *Version) Compare(other *Version) int {
|
func (v *Version) Compare(other *Version) int {
|
||||||
switch {
|
switch {
|
||||||
case v.Major != other.Major:
|
case v.Major != other.Major:
|
||||||
return compareInt(v.Major, other.Major)
|
return cmp.Compare(v.Major, other.Major)
|
||||||
case v.Minor != other.Minor:
|
case v.Minor != other.Minor:
|
||||||
return compareInt(v.Minor, other.Minor)
|
return cmp.Compare(v.Minor, other.Minor)
|
||||||
case v.Patch != other.Patch:
|
case v.Patch != other.Patch:
|
||||||
return compareInt(v.Patch, other.Patch)
|
return cmp.Compare(v.Patch, other.Patch)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compareInt 比较两个整数
|
|
||||||
func compareInt(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if a > b {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNewerThan 判断是否比目标版本新
|
// IsNewerThan 判断是否比目标版本新
|
||||||
func (v *Version) IsNewerThan(other *Version) bool {
|
func (v *Version) IsNewerThan(other *Version) bool {
|
||||||
return v.Compare(other) > 0
|
return v.Compare(other) > 0
|
||||||
|
|||||||
@@ -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 在启动时执行,但只在表结构不存在时创建
|
// AutoMigrate 在启动时执行,但只在表结构不存在时创建
|
||||||
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
|
// SQLite 的 AutoMigrate 很快,不会造成明显延迟
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&models.DbConnection{},
|
|
||||||
&models.SqlTab{},
|
|
||||||
&models.SqlResultHistory{},
|
|
||||||
&models.AppConfig{},
|
&models.AppConfig{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "u-desk",
|
"name": "u-desk",
|
||||||
"outputfilename": "u-desk",
|
"outputfilename": "u-desk",
|
||||||
"version": "0.3.3",
|
"version": "0.4.0",
|
||||||
"frontend:install": "npm install",
|
"frontend:install": "npm install",
|
||||||
"frontend:build": "npm run build",
|
"frontend:build": "npm run build",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
12
web/package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.5.3",
|
"@codemirror/state": "^6.5.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.8",
|
"@codemirror/view": "^6.39.8",
|
||||||
@@ -414,6 +415,17 @@
|
|||||||
"crelt": "^1.0.5"
|
"crelt": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/state": {
|
"node_modules/@codemirror/state": {
|
||||||
"version": "6.5.3",
|
"version": "6.5.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.5.3",
|
"@codemirror/state": "^6.5.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.8",
|
"@codemirror/view": "^6.39.8",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0e1fafcbb6b28922a38f6c5316932015
|
c0e9e27e045c6118704c87fcf34a03de
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<a-layout-content class="content">
|
<a-layout-content class="content">
|
||||||
<!-- 动态渲染 Tab 内容 -->
|
<!-- 动态渲染 Tab 内容 -->
|
||||||
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
|
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
|
||||||
<KeepAlive include="FileSystem,DbCli">
|
<KeepAlive include="FileSystem">
|
||||||
<component :is="getComponent(activeTab)"/>
|
<component :is="getComponent(activeTab)"/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
@@ -67,8 +67,20 @@
|
|||||||
v-model="showSettings"
|
v-model="showSettings"
|
||||||
:config="configStore.appConfig"
|
:config="configStore.appConfig"
|
||||||
@save="handleSaveConfig"
|
@save="handleSaveConfig"
|
||||||
|
@open-version-history="showVersionHistory = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 版本历史抽屉 -->
|
||||||
|
<a-drawer
|
||||||
|
v-model:visible="showVersionHistory"
|
||||||
|
:width="720"
|
||||||
|
:footer="false"
|
||||||
|
:unmount-on-close="false"
|
||||||
|
title="版本历史"
|
||||||
|
>
|
||||||
|
<VersionHistory />
|
||||||
|
</a-drawer>
|
||||||
|
|
||||||
<!-- 升级提示弹窗 -->
|
<!-- 升级提示弹窗 -->
|
||||||
<UpdateNotification
|
<UpdateNotification
|
||||||
v-model="updateStore.showUpdate"
|
v-model="updateStore.showUpdate"
|
||||||
@@ -82,21 +94,23 @@
|
|||||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||||
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
||||||
import MarkdownEditor from './views/markdown-editor/index.vue'
|
import MarkdownEditor from './views/markdown-editor/index.vue'
|
||||||
import DbCli from './views/db-cli/index.vue'
|
import VersionHistory from './views/version/index.vue'
|
||||||
import ThemeToggle from './components/ThemeToggle.vue'
|
import ThemeToggle from './components/ThemeToggle.vue'
|
||||||
import FileSystem from './components/FileSystem/index.vue'
|
import FileSystem from './components/FileSystem/index.vue'
|
||||||
import SettingsPanel from './components/SettingsPanel.vue'
|
import SettingsPanel from './components/SettingsPanel.vue'
|
||||||
import UpdateNotification from './components/UpdateNotification.vue'
|
import UpdateNotification from './components/UpdateNotification.vue'
|
||||||
import {useUpdateStore} from './stores/update'
|
import {useUpdateStore} from './stores/update'
|
||||||
import {useConfigStore} from './stores/config'
|
import {useConfigStore, type AppConfig} from './stores/config'
|
||||||
|
|
||||||
// 存储键
|
// 存储键
|
||||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||||
|
|
||||||
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
||||||
|
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移
|
||||||
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const showVersionHistory = ref(false)
|
||||||
const isMaximized = ref(false)
|
const isMaximized = ref(false)
|
||||||
const isPinned = ref(false)
|
const isPinned = ref(false)
|
||||||
|
|
||||||
@@ -111,7 +125,7 @@ const appConfig = computed(() => configStore.appConfig)
|
|||||||
const visibleTabs = computed(() => configStore.visibleTabs)
|
const visibleTabs = computed(() => configStore.visibleTabs)
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
const handleSaveConfig = async (config) => {
|
const handleSaveConfig = async (config: AppConfig) => {
|
||||||
try {
|
try {
|
||||||
await configStore.saveConfig(config)
|
await configStore.saveConfig(config)
|
||||||
showSettings.value = false
|
showSettings.value = false
|
||||||
@@ -134,10 +148,9 @@ const loadConfig = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取组件
|
// 获取组件
|
||||||
const getComponent = (key) => {
|
const getComponent = (key: string) => {
|
||||||
const components = {
|
const components = {
|
||||||
'file-system': FileSystem,
|
'file-system': FileSystem,
|
||||||
'db-cli': DbCli,
|
|
||||||
'markdown-editor': MarkdownEditor
|
'markdown-editor': MarkdownEditor
|
||||||
}
|
}
|
||||||
return components[key] || null
|
return components[key] || null
|
||||||
@@ -219,10 +232,9 @@ watch(activeTab, (newTab) => {
|
|||||||
// 保存到 localStorage
|
// 保存到 localStorage
|
||||||
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, newTab)
|
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, newTab)
|
||||||
|
|
||||||
// 检查 Tab 是否在可见列表中
|
// 检查一级 Tab 是否在可见列表中
|
||||||
const isVisible = appConfig.value.visibleTabs.includes(newTab)
|
const isVisible = appConfig.value.visibleTabs.includes(newTab)
|
||||||
if (!isVisible && appConfig.value.visibleTabs.length > 0 && newTab !== appConfig.value.defaultTab) {
|
if (!isVisible && appConfig.value.visibleTabs.length > 0 && newTab !== appConfig.value.defaultTab) {
|
||||||
// 切换到默认 Tab(避免重复触发)
|
|
||||||
activeTab.value = appConfig.value.defaultTab
|
activeTab.value = appConfig.value.defaultTab
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -363,4 +375,9 @@ watch(activeTab, (newTab) => {
|
|||||||
.arco-tooltip {
|
.arco-tooltip {
|
||||||
--wails-draggable: no-drag;
|
--wails-draggable: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
|
||||||
|
html, body {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
</style>
|
</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 './types'
|
||||||
export * from './connection'
|
|
||||||
export * from './database'
|
|
||||||
export * from './structure'
|
|
||||||
export * from './query'
|
|
||||||
export * from './tab'
|
|
||||||
export * from './system'
|
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 type { File } from './types'
|
||||||
import { debugError } from '@/utils/debugLog'
|
import { connectionManager } from './connection-manager'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
* 转换后端文件数据格式(蛇形 → 驼峰)
|
||||||
* 后端返回 is_dir,前端使用 isDir
|
|
||||||
*/
|
*/
|
||||||
function transformFile(file: any): File {
|
function transformFile(file: any): File {
|
||||||
return {
|
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
|
||||||
...file,
|
|
||||||
isDir: file.is_dir,
|
|
||||||
modified_time: file.mod_time
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量转换文件列表
|
|
||||||
*/
|
|
||||||
function transformFileList(files: any[]): File[] {
|
function transformFileList(files: any[]): File[] {
|
||||||
return files.map(transformFile)
|
return files.map(transformFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const t = () => connectionManager.getTransport()
|
||||||
* 获取系统信息
|
|
||||||
*/
|
export async function getSystemInfo() { return t().getFileInfo('/') }
|
||||||
export async function getSystemInfo(): Promise<SystemInfo> {
|
|
||||||
if (!window.go?.main?.App?.GetSystemInfo) {
|
export async function getCPUInfo() {
|
||||||
throw new Error('GetSystemInfo API 不可用')
|
if (connectionManager.isRemote()) return {}
|
||||||
}
|
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||||
return await window.go.main.App.GetSystemInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getMemoryInfo() {
|
||||||
* 获取 CPU 信息
|
if (connectionManager.isRemote()) return {}
|
||||||
*/
|
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
|
||||||
export async function getCPUInfo(): Promise<CPU> {
|
|
||||||
if (!window.go?.main?.App?.GetCPUInfo) {
|
|
||||||
throw new Error('GetCPUInfo API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetCPUInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getDiskInfo() {
|
||||||
* 获取内存信息
|
if (connectionManager.isRemote()) return {}
|
||||||
*/
|
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||||
export async function getMemoryInfo(): Promise<Memory> {
|
|
||||||
if (!window.go?.main?.App?.GetMemoryInfo) {
|
|
||||||
throw new Error('GetMemoryInfo API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetMemoryInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取磁盘信息
|
|
||||||
*/
|
|
||||||
export async function getDiskInfo(): Promise<Disk> {
|
|
||||||
if (!window.go?.main?.App?.GetDiskInfo) {
|
|
||||||
throw new Error('GetDiskInfo API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetDiskInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出目录文件
|
|
||||||
*/
|
|
||||||
export async function listDir(path: string): Promise<File[]> {
|
export async function listDir(path: string): Promise<File[]> {
|
||||||
if (!window.go?.main?.App?.ListDir) {
|
return transformFileList(await t().listDir(path))
|
||||||
throw new Error('ListDir API 不可用')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await window.go.main.App.ListDir(path)
|
|
||||||
return transformFileList(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取文件
|
|
||||||
*/
|
|
||||||
export async function readFile(path: string): Promise<string> {
|
export async function readFile(path: string): Promise<string> {
|
||||||
if (!window.go?.main?.App?.ReadFile) {
|
return t().readFile(path)
|
||||||
throw new Error('ReadFile API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.ReadFile(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入文件
|
|
||||||
*/
|
|
||||||
export async function writeFile(path: string, content: string): Promise<void> {
|
export async function writeFile(path: string, content: string): Promise<void> {
|
||||||
if (!window.go?.main?.App?.WriteFile) {
|
await t().writeFile(path, String(content))
|
||||||
throw new Error('WriteFile API 不可用')
|
|
||||||
}
|
|
||||||
// 确保传递的是字符串类型
|
|
||||||
await window.go.main.App.WriteFile({
|
|
||||||
path: String(path),
|
|
||||||
content: String(content)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存 Base64 编码的二进制文件(图片等)
|
|
||||||
*/
|
|
||||||
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
||||||
if (!window.go?.main?.App?.SaveBase64File) {
|
if (!base64Content) throw new Error('无效的 base64 内容')
|
||||||
throw new Error('SaveBase64File API 不可用')
|
await t().saveBase64File(path, base64Content)
|
||||||
}
|
|
||||||
if (!base64Content) {
|
|
||||||
throw new Error('无效的 base64 内容')
|
|
||||||
}
|
|
||||||
await window.go.main.App.SaveBase64File({
|
|
||||||
path: String(path),
|
|
||||||
content: base64Content
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除文件或目录
|
|
||||||
*/
|
|
||||||
export async function deletePath(path: string): Promise<any> {
|
export async function deletePath(path: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.DeletePath) {
|
return t().deletePath(path)
|
||||||
throw new Error('DeletePath API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.DeletePath(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建目录(parentPath + dirname 拼接为完整路径)
|
|
||||||
*/
|
|
||||||
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.CreateDir) {
|
return t().createDir(parentPath, dirname)
|
||||||
throw new Error('CreateDir API 不可用')
|
|
||||||
}
|
|
||||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
|
||||||
return await window.go.main.App.CreateDir(fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建文件(dirPath + filename 拼接为完整路径)
|
|
||||||
*/
|
|
||||||
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.CreateFile) {
|
return t().createFile(dirPath, filename)
|
||||||
throw new Error('CreateFile API 不可用')
|
|
||||||
}
|
|
||||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
|
||||||
return await window.go.main.App.CreateFile(fullPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重命名文件或目录
|
|
||||||
*/
|
|
||||||
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.RenamePath) {
|
return t().renamePath(oldPath, String(newPath))
|
||||||
throw new Error('RenamePath API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.RenamePath({
|
|
||||||
oldPath: String(oldPath),
|
|
||||||
newPath: String(newPath)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取环境变量
|
|
||||||
*/
|
|
||||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||||
if (!window.go?.main?.App?.GetEnvVars) {
|
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||||
throw new Error('GetEnvVars API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetEnvVars()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出 zip 文件内容
|
|
||||||
*/
|
|
||||||
export async function listZipContents(zipPath: string): Promise<File[]> {
|
export async function listZipContents(zipPath: string): Promise<File[]> {
|
||||||
if (!window.go?.main?.App?.ListZipContents) {
|
return transformFileList(await t().listZipContents(zipPath))
|
||||||
throw new Error('ListZipContents API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ListZipContents(zipPath)
|
|
||||||
return transformFileList(result)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] listZipContents 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 zip 文件中提取单个文件内容
|
|
||||||
*/
|
|
||||||
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||||
if (!window.go?.main?.App?.ExtractFileFromZip) {
|
return t().extractFileFromZip(zipPath, filePath)
|
||||||
throw new Error('ExtractFileFromZip API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ExtractFileFromZip(zipPath, filePath)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] extractFileFromZip 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 zip 文件中提取单个文件到临时目录
|
|
||||||
* 返回临时文件的完整路径,适用于图片等二进制文件
|
|
||||||
*/
|
|
||||||
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||||
if (!window.go?.main?.App?.ExtractFileFromZipToTemp) {
|
return t().extractFileFromZipToTemp(zipPath, filePath)
|
||||||
throw new Error('ExtractFileFromZipToTemp API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] extractFileFromZipToTemp 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 zip 文件中特定文件的信息
|
|
||||||
*/
|
|
||||||
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
||||||
if (!window.go?.main?.App?.GetZipFileInfo) {
|
return transformFile(await t().getZipFileInfo(zipPath, filePath))
|
||||||
throw new Error('GetZipFileInfo API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.GetZipFileInfo(zipPath, filePath)
|
|
||||||
return transformFile(result)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] getZipFileInfo 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用系统默认程序打开文件或目录
|
|
||||||
*/
|
|
||||||
export async function openPath(path: string): Promise<void> {
|
export async function openPath(path: string): Promise<void> {
|
||||||
if (!window.go?.main?.App?.OpenPath) {
|
await t().openPath(path)
|
||||||
throw new Error('OpenPath API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await window.go.main.App.OpenPath(path)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] openPath 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取本地文件服务器URL
|
|
||||||
*/
|
|
||||||
export async function getFileServerURL(): Promise<string> {
|
export async function getFileServerURL(): Promise<string> {
|
||||||
if (!window.go?.main?.App?.GetFileServerURL) {
|
return t().getFileServerURL()
|
||||||
throw new Error('GetFileServerURL API 不可用')
|
|
||||||
}
|
|
||||||
return await window.go.main.App.GetFileServerURL()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function resolveShortcut(lnkPath: string): Promise<any> {
|
||||||
* 解析快捷方式文件,返回目标路径信息
|
return t().resolveShortcut(lnkPath)
|
||||||
*/
|
|
||||||
export async function resolveShortcut(lnkPath: string): Promise<{
|
|
||||||
success: boolean
|
|
||||||
message?: string
|
|
||||||
targetPath?: string
|
|
||||||
targetExists?: boolean
|
|
||||||
targetAccessible?: boolean
|
|
||||||
targetInfo?: any
|
|
||||||
}> {
|
|
||||||
if (!window.go?.main?.App?.ResolveShortcut) {
|
|
||||||
throw new Error('ResolveShortcut API 不可用')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await window.go.main.App.ResolveShortcut(lnkPath)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[API] resolveShortcut 错误:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function detectFileTypeByContent(path: string) {
|
||||||
* 通过文件内容检测文件类型(用于小文件,500KB以内)
|
return t().detectFileTypeByContent(path)
|
||||||
*/
|
|
||||||
export async function detectFileTypeByContent(path: string): Promise<{
|
|
||||||
extension: string
|
|
||||||
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 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 类型定义
|
* API 类型定义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 连接
|
|
||||||
export interface Connection {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
dbType: string
|
|
||||||
host: string
|
|
||||||
port: number
|
|
||||||
username: string
|
|
||||||
database?: string
|
|
||||||
createdAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数据库和表
|
|
||||||
export interface Database {
|
|
||||||
name: string
|
|
||||||
tableCount?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Table {
|
|
||||||
name: string
|
|
||||||
type?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表结构
|
|
||||||
export interface Column {
|
|
||||||
Field: string
|
|
||||||
Type: string
|
|
||||||
Null: string
|
|
||||||
Key: string
|
|
||||||
Default: string | null
|
|
||||||
Comment: string
|
|
||||||
Extra?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Index {
|
|
||||||
Key_name: string
|
|
||||||
Column_name: string
|
|
||||||
Non_unique: number
|
|
||||||
Seq_in_index: number
|
|
||||||
Index_type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Structure {
|
|
||||||
database: string
|
|
||||||
table: string
|
|
||||||
type: 'mysql' | 'mongo' | 'redis'
|
|
||||||
columns?: Column[]
|
|
||||||
indexes?: Index[]
|
|
||||||
structure?: any
|
|
||||||
info?: any
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQL 查询
|
|
||||||
export interface QueryResult {
|
|
||||||
columns: string[]
|
|
||||||
data: any[]
|
|
||||||
rowsAffected: number
|
|
||||||
executionTime: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标签页
|
|
||||||
export interface Tab {
|
|
||||||
id?: number
|
|
||||||
title: string
|
|
||||||
content: string
|
|
||||||
connectionId?: number | null
|
|
||||||
order?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 系统信息
|
// 系统信息
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
os: string
|
os: string
|
||||||
|
|||||||
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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, onBeforeUnmount, computed, nextTick } from 'vue'
|
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import {
|
import {
|
||||||
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
|
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
|
||||||
EditorState, Compartment,
|
EditorState, Compartment,
|
||||||
defaultKeymap, history,
|
defaultKeymap, history,
|
||||||
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
|
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
|
||||||
oneDark
|
oneDark,
|
||||||
|
openSearchPanel, search
|
||||||
} from '@/utils/codemirrorExports'
|
} from '@/utils/codemirrorExports'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
|
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
|
||||||
|
|
||||||
// ==================== 主题定义 ====================
|
|
||||||
|
|
||||||
// 亮色主题的基础样式
|
|
||||||
const lightTheme = EditorView.theme({
|
|
||||||
'&': { backgroundColor: '#ffffff' },
|
|
||||||
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
|
||||||
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
|
||||||
'.cm-line': { caretColor: '#000' },
|
|
||||||
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
|
||||||
'.cm-cursor': { borderLeftColor: '#000' }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Props & Emits ====================
|
// ==================== Props & Emits ====================
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, required: true },
|
modelValue: { type: String, required: true },
|
||||||
fileExtension: { type: String, default: '' }
|
fileExtension: { type: String, default: '' },
|
||||||
|
filePath: { type: String, default: '' },
|
||||||
|
fileMtime: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
@@ -41,6 +32,36 @@ const themeStore = useThemeStore()
|
|||||||
const editorContainer = ref(null)
|
const editorContainer = ref(null)
|
||||||
let view = null
|
let view = null
|
||||||
|
|
||||||
|
// 滚动位置缓存:LRU 最多 5 份,每份 3 分钟过期
|
||||||
|
const MAX_SCROLL_CACHE = 5
|
||||||
|
const SCROLL_CACHE_TTL = 3 * 60 * 1000 // 3 分钟
|
||||||
|
const fileScrollPositions = new Map() // filePath → { scrollTop, anchor, timestamp }
|
||||||
|
let currentFilePath = ''
|
||||||
|
let saveScrollTimer = null
|
||||||
|
|
||||||
|
// 清理过期缓存 + LRU 淘汰,保持最多 MAX_SCROLL_CACHE 条
|
||||||
|
const cleanScrollCache = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
// 清理过期的
|
||||||
|
for (const [key, val] of fileScrollPositions) {
|
||||||
|
if (now - val.timestamp > SCROLL_CACHE_TTL) {
|
||||||
|
fileScrollPositions.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LRU:超出上限时删除最旧的
|
||||||
|
if (fileScrollPositions.size > MAX_SCROLL_CACHE) {
|
||||||
|
let oldestKey = null
|
||||||
|
let oldestTime = Infinity
|
||||||
|
for (const [key, val] of fileScrollPositions) {
|
||||||
|
if (val.timestamp < oldestTime) {
|
||||||
|
oldestTime = val.timestamp
|
||||||
|
oldestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey) fileScrollPositions.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 Compartment 实现动态切换,避免重建编辑器
|
// 使用 Compartment 实现动态切换,避免重建编辑器
|
||||||
const themeCompartment = new Compartment()
|
const themeCompartment = new Compartment()
|
||||||
const languageCompartment = new Compartment()
|
const languageCompartment = new Compartment()
|
||||||
@@ -87,6 +108,9 @@ const createExtensions = () => {
|
|||||||
keymap.of(defaultKeymap),
|
keymap.of(defaultKeymap),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
|
|
||||||
|
// 查找替换(Ctrl+F / Ctrl+H)
|
||||||
|
search(),
|
||||||
|
|
||||||
// 内容更新监听(带防抖)
|
// 内容更新监听(带防抖)
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
@@ -98,7 +122,7 @@ const createExtensions = () => {
|
|||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
'&': { height: '100%', fontSize: '13px' },
|
'&': { height: '100%', fontSize: '13px' },
|
||||||
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
||||||
'.cm-content': { padding: '8px', minHeight: '100%' },
|
'.cm-content': { padding: '8px' },
|
||||||
'.cm-line': { padding: '0 0' },
|
'.cm-line': { padding: '0 0' },
|
||||||
'&.cm-focused': { outline: 'none' }
|
'&.cm-focused': { outline: 'none' }
|
||||||
}),
|
}),
|
||||||
@@ -143,6 +167,12 @@ const createEditor = (docContent = '') => {
|
|||||||
|
|
||||||
view = new EditorView({ state, parent: editorContainer.value })
|
view = new EditorView({ state, parent: editorContainer.value })
|
||||||
|
|
||||||
|
// 滚动时防抖保存位置
|
||||||
|
view.scrollDOM.addEventListener('scroll', () => {
|
||||||
|
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||||
|
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
// 初始化语言
|
// 初始化语言
|
||||||
initLanguage()
|
initLanguage()
|
||||||
}
|
}
|
||||||
@@ -163,8 +193,10 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (emitTimeout) {
|
if (emitTimeout) clearTimeout(emitTimeout)
|
||||||
clearTimeout(emitTimeout)
|
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||||
|
if (view?.scrollDOM) {
|
||||||
|
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
|
||||||
}
|
}
|
||||||
view?.destroy()
|
view?.destroy()
|
||||||
view = null
|
view = null
|
||||||
@@ -172,12 +204,64 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
// ==================== 监听器 ====================
|
// ==================== 监听器 ====================
|
||||||
|
|
||||||
// 监听外部内容变化
|
// 保存当前文件滚动位置(防抖)
|
||||||
watch(() => props.modelValue, (newValue) => {
|
const saveScrollPosition = () => {
|
||||||
if (view && newValue !== view.state.doc.toString()) {
|
if (!view || !currentFilePath) return
|
||||||
view.dispatch({
|
const scroller = view.scrollDOM
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }
|
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) {
|
.codemirror-editor :deep(.cm-content) {
|
||||||
height: 100%;
|
/* 不设 height,让 CodeMirror 虚拟滚动自行计算文档高度 */
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<template>
|
||||||
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
||||||
|
<!-- 有选中文件时显示表头和内容 -->
|
||||||
|
<template v-if="config.currentFileName">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">
|
<span class="panel-title">
|
||||||
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
||||||
@@ -72,7 +74,8 @@
|
|||||||
|
|
||||||
<!-- 视频预览 -->
|
<!-- 视频预览 -->
|
||||||
<div v-else-if="config.isVideoView" class="media-preview">
|
<div v-else-if="config.isVideoView" class="media-preview">
|
||||||
<video :src="config.previewUrl" controls class="preview-video"></video>
|
<video :src="config.previewUrl" controls class="preview-video" @error="handleMediaError('视频')"></video>
|
||||||
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="arcoblue">🎬 视频</a-tag>
|
<a-tag color="arcoblue">🎬 视频</a-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +83,8 @@
|
|||||||
|
|
||||||
<!-- 音频预览 -->
|
<!-- 音频预览 -->
|
||||||
<div v-else-if="config.isAudioView" class="media-preview">
|
<div v-else-if="config.isAudioView" class="media-preview">
|
||||||
<audio :src="config.previewUrl" controls class="preview-audio"></audio>
|
<audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')"></audio>
|
||||||
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="green">🎵 音频</a-tag>
|
<a-tag color="green">🎵 音频</a-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +92,8 @@
|
|||||||
|
|
||||||
<!-- PDF 预览 -->
|
<!-- PDF 预览 -->
|
||||||
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
|
<div v-else-if="config.isPdfFile" class="media-preview media-preview-pdf">
|
||||||
<iframe :src="config.previewUrl" class="preview-pdf"></iframe>
|
<iframe :src="config.previewUrl" class="preview-pdf" @load="handlePdfLoad"></iframe>
|
||||||
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="orangered">📕 PDF</a-tag>
|
<a-tag color="orangered">📕 PDF</a-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,6 +160,8 @@
|
|||||||
<AsyncCodeEditor
|
<AsyncCodeEditor
|
||||||
:model-value="config.fileContent"
|
:model-value="config.fileContent"
|
||||||
:file-extension="config.currentFileExtension"
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
@update:model-value="handleContentUpdate"
|
@update:model-value="handleContentUpdate"
|
||||||
class="code-editor"
|
class="code-editor"
|
||||||
/>
|
/>
|
||||||
@@ -218,6 +225,8 @@
|
|||||||
<AsyncCodeEditor
|
<AsyncCodeEditor
|
||||||
:model-value="config.fileContent"
|
:model-value="config.fileContent"
|
||||||
:file-extension="config.currentFileExtension"
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
@update:model-value="handleContentUpdate"
|
@update:model-value="handleContentUpdate"
|
||||||
class="code-editor"
|
class="code-editor"
|
||||||
/>
|
/>
|
||||||
@@ -284,6 +293,8 @@
|
|||||||
<AsyncCodeEditor
|
<AsyncCodeEditor
|
||||||
:model-value="config.fileContent"
|
:model-value="config.fileContent"
|
||||||
:file-extension="config.currentFileExtension"
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
@update:model-value="handleContentUpdate"
|
@update:model-value="handleContentUpdate"
|
||||||
class="code-editor"
|
class="code-editor"
|
||||||
/>
|
/>
|
||||||
@@ -337,6 +348,8 @@
|
|||||||
<AsyncCodeEditor
|
<AsyncCodeEditor
|
||||||
:model-value="config.fileContent"
|
:model-value="config.fileContent"
|
||||||
:file-extension="config.currentFileExtension"
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
@update:model-value="handleContentUpdate"
|
@update:model-value="handleContentUpdate"
|
||||||
class="code-editor"
|
class="code-editor"
|
||||||
/>
|
/>
|
||||||
@@ -346,6 +359,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -359,6 +373,7 @@ import type { FileEditorPanelConfig } from '@/types/file-system'
|
|||||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
|
|
||||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||||
const AsyncCodeEditor = defineAsyncComponent({
|
const AsyncCodeEditor = defineAsyncComponent({
|
||||||
@@ -424,13 +439,27 @@ interface Emits {
|
|||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// HTML 预览 URL(使用后端接口)
|
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||||
const htmlPreviewUrl = computed(() => {
|
function resolveHtmlPreviewBase(): string {
|
||||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
|
if (!connectionManager.isRemote()) return 'http://localhost:8073'
|
||||||
return ''
|
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 ''
|
||||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
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')
|
emit('imageError')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaErrorMsg = ref('')
|
||||||
|
const handleMediaError = (type: string) => {
|
||||||
|
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
|
||||||
|
}
|
||||||
|
const handlePdfLoad = (event: Event) => {
|
||||||
|
const iframe = event.target as HTMLIFrameElement
|
||||||
|
try {
|
||||||
|
// iframe 加载后检查内容是否为空(401/404 等错误页面通常内容很少)
|
||||||
|
if (!iframe.contentDocument || iframe.contentDocument.body.innerHTML.length < 100) {
|
||||||
|
mediaErrorMsg.value = 'PDF 文件加载失败,请检查网络连接或文件权限'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 跨域时无法访问 contentDocument,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带认证的 fetch(远程模式自动附加 Bearer token)
|
||||||
|
const authFetch = async (url: string): Promise<Response> => {
|
||||||
|
const token = connectionManager.activeProfile?.token
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
return fetch(url, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
// 打印窗口导出 PDF 公共函数
|
// 打印窗口导出 PDF 公共函数
|
||||||
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
|
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
|
||||||
const printWindow = window.open('', '_blank')
|
const printWindow = window.open('', '_blank')
|
||||||
@@ -642,7 +695,7 @@ const loadExcelPreview = async (filePath: string) => {
|
|||||||
|
|
||||||
// 直接从本地文件服务器获取(不走 base64)
|
// 直接从本地文件服务器获取(不走 base64)
|
||||||
const fileUrl = props.config.previewUrl
|
const fileUrl = props.config.previewUrl
|
||||||
const response = await fetch(fileUrl)
|
const response = await authFetch(fileUrl)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
||||||
|
|
||||||
@@ -671,7 +724,7 @@ const loadWordPreview = async (filePath: string) => {
|
|||||||
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
|
|
||||||
const fileUrl = props.config.previewUrl
|
const fileUrl = props.config.previewUrl
|
||||||
const response = await fetch(fileUrl)
|
const response = await authFetch(fileUrl)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
||||||
|
|
||||||
@@ -701,7 +754,7 @@ const loadCsvPreview = async (filePath: string) => {
|
|||||||
|
|
||||||
const blob = props.config.fileContent && !props.config.isBinaryFile
|
const blob = props.config.fileContent && !props.config.isBinaryFile
|
||||||
? new Blob([props.config.fileContent], { type: 'text/csv' })
|
? new Blob([props.config.fileContent], { type: 'text/csv' })
|
||||||
: await (await fetch(props.config.previewUrl)).blob()
|
: await (await authFetch(props.config.previewUrl)).blob()
|
||||||
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
|
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
|
||||||
|
|
||||||
const result = await previewCsv(file, csvPreviewRef.value)
|
const result = await previewCsv(file, csvPreviewRef.value)
|
||||||
@@ -775,11 +828,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
|||||||
// 处理 HTML iframe 发送的消息(链接点击)
|
// 处理 HTML iframe 发送的消息(链接点击)
|
||||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
|
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
'null', // about:blank 或 data: URL
|
'null',
|
||||||
'http://localhost:18765', // 本地文件服务器
|
resolveHtmlPreviewBase(), // 动态:本地 localhost:8073 或远程代理地址
|
||||||
]
|
]
|
||||||
if (!allowedOrigins.includes(event.origin)) {
|
if (!allowedOrigins.includes(event.origin)) {
|
||||||
return
|
return
|
||||||
@@ -827,11 +880,9 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
padding: 3px 12px;
|
||||||
background: var(--color-fill-1);
|
background: var(--color-bg-2);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@@ -936,6 +987,13 @@ onUnmounted(() => {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-error {
|
||||||
|
color: var(--color-danger-6);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.media-meta {
|
.media-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">📋 文件列表</span>
|
<span class="panel-title">📋 文件列表</span>
|
||||||
<div class="panel-header-right">
|
<div class="panel-header-right">
|
||||||
<span class="panel-count">{{ config.fileList.length }} 项</span>
|
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
||||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '150px' }">
|
|
||||||
<a-button size="mini" type="text" class="settings-btn">
|
<a-button size="mini" type="text" class="settings-btn">
|
||||||
<icon-more />
|
<icon-more />
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -31,6 +30,18 @@
|
|||||||
:disabled="col.key === 'name' && visibleCount <= 1"
|
:disabled="col.key === 'name' && visibleCount <= 1"
|
||||||
@change="(val: boolean) => toggleColumn(col.key, val)"
|
@change="(val: boolean) => toggleColumn(col.key, val)"
|
||||||
>{{ col.label }}</a-checkbox>
|
>{{ col.label }}</a-checkbox>
|
||||||
|
<!-- 可排序列:点击图标排序 -->
|
||||||
|
<span
|
||||||
|
v-if="colSortMap[col.key]"
|
||||||
|
class="col-sort-icon"
|
||||||
|
:class="{ 'col-sort-active': sortBy === colSortMap[col.key] }"
|
||||||
|
:title="sortBy === colSortMap[col.key] ? (sortOrder === 'asc' ? '升序 → 点击降序' : '降序 → 点击升序') : `按${col.label}排序`"
|
||||||
|
@click.stop="emit('sort', colSortMap[col.key])"
|
||||||
|
>
|
||||||
|
<IconSort v-if="sortBy !== colSortMap[col.key]" />
|
||||||
|
<IconSortAscending v-else-if="sortOrder === 'asc'" />
|
||||||
|
<IconSortDescending v-else />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
@@ -38,21 +49,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="file-list-wrapper"
|
class="file-list-wrapper thin-dark-scrollbar"
|
||||||
@contextmenu.prevent="handleWrapperContextMenu"
|
@contextmenu.prevent="handleWrapperContextMenu"
|
||||||
>
|
>
|
||||||
<!-- 文件列表(a-table) -->
|
<!-- 文件列表(滚动区域) -->
|
||||||
<a-table
|
<a-table
|
||||||
v-if="config.fileList.length > 0 || config.fileLoading"
|
v-if="config.fileList.length > 0 || config.fileLoading"
|
||||||
:columns="tableColumns"
|
:columns="tableColumns"
|
||||||
:data="config.fileList"
|
:data="pagedFileList"
|
||||||
:loading="config.fileLoading"
|
:loading="config.fileLoading"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:show-header="showHeader"
|
:show-header="showHeader"
|
||||||
size="mini"
|
size="mini"
|
||||||
:row-class-name="getRowClassName"
|
:row-class-name="getRowClassName"
|
||||||
:scroll="{ y: 'auto' }"
|
|
||||||
class="file-table"
|
class="file-table"
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@row-contextmenu="handleRowContextMenu"
|
@row-contextmenu="handleRowContextMenu"
|
||||||
@@ -64,13 +74,27 @@
|
|||||||
<span>此文件夹为空</span>
|
<span>此文件夹为空</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页栏(固定在面板底部,不随内容滚动) -->
|
||||||
|
<div v-if="config.fileList.length > 0" class="pagination-bar">
|
||||||
|
<span class="pagination-total">共 {{ config.fileList.length }} 项</span>
|
||||||
|
<span class="pagination-nav">
|
||||||
|
<span class="page-btn" :class="{ disabled: currentPage <= 1 }" @click="onPageChange(currentPage - 1)">
|
||||||
|
<icon-left />
|
||||||
|
</span>
|
||||||
|
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||||
|
<span class="page-btn" :class="{ disabled: currentPage >= totalPages }" @click="onPageChange(currentPage + 1)">
|
||||||
|
<icon-right />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h, computed, nextTick, ref } from 'vue'
|
import { h, computed, nextTick, ref, watch } from 'vue'
|
||||||
import { Input, Button } from '@arco-design/web-vue'
|
import { Input, Button } from '@arco-design/web-vue'
|
||||||
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } from '@arco-design/web-vue/es/icon'
|
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore, IconLeft, IconRight } from '@arco-design/web-vue/es/icon'
|
||||||
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
|
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
|
||||||
import { STORAGE_KEYS } from '@/utils/constants'
|
import { STORAGE_KEYS } from '@/utils/constants'
|
||||||
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
||||||
@@ -101,6 +125,14 @@ interface Emits {
|
|||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 列 key → 排序字段映射
|
||||||
|
const colSortMap: Record<string, string> = {
|
||||||
|
icon: 'type',
|
||||||
|
name: 'name',
|
||||||
|
time: 'modified_time',
|
||||||
|
size: 'size'
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 列配置(支持显隐 + 排序) ==========
|
// ========== 列配置(支持显隐 + 排序) ==========
|
||||||
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
|
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
|
||||||
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
|
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
|
||||||
@@ -139,7 +171,8 @@ function loadColSettings(): ColumnConfig[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
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 频繁写入)
|
// 手动持久化(避免 deep watch 频繁写入)
|
||||||
function saveColSettings() {
|
function saveColSettings() {
|
||||||
@@ -311,6 +344,26 @@ const tableColumns = computed(() => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ========== 分页 ==========
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 100
|
||||||
|
|
||||||
|
const pagedFileList = computed(() => {
|
||||||
|
const list = props.config.fileList
|
||||||
|
const start = (currentPage.value - 1) * pageSize
|
||||||
|
return list.slice(start, start + pageSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.config.fileList.length / pageSize)))
|
||||||
|
|
||||||
|
const onPageChange = (page: number) => {
|
||||||
|
if (page < 1 || page > totalPages.value) return
|
||||||
|
currentPage.value = page
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当文件列表变化时重置到第1页
|
||||||
|
watch(() => props.config.fileList.length, () => { currentPage.value = 1 })
|
||||||
|
|
||||||
// ========== 行事件处理 ==========
|
// ========== 行事件处理 ==========
|
||||||
const handleRowClick = (record: FileItem, ev: Event) => {
|
const handleRowClick = (record: FileItem, ev: Event) => {
|
||||||
const target = ev.target as HTMLElement
|
const target = ev.target as HTMLElement
|
||||||
@@ -351,12 +404,13 @@ defineExpose({ focusEditingItem })
|
|||||||
.file-list-panel {
|
.file-list-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */
|
||||||
|
min-height: 0; /* 允许收缩到小于内容高度 */
|
||||||
background: var(--color-bg-1);
|
background: var(--color-bg-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 6px 12px;
|
padding: 3px 12px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--color-bg-2);
|
background: var(--color-bg-2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -382,15 +436,39 @@ defineExpose({ focusEditingItem })
|
|||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动容器 */
|
/* 列项排序图标 */
|
||||||
|
.col-sort-icon {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.col-sort-icon:hover {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
.col-sort-active {
|
||||||
|
color: rgb(var(--primary-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动容器(table + 分页 的统一滚动层) */
|
||||||
.file-list-wrapper {
|
.file-list-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====== Table 全局覆盖 ====== */
|
/* ====== Table ====== */
|
||||||
|
.file-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.file-table :deep(.arco-table) {
|
.file-table :deep(.arco-table) {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
@@ -524,4 +602,35 @@ defineExpose({ focusEditingItem })
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.empty-state span:nth-child(2) { font-size: 14px; }
|
.empty-state span:nth-child(2) { font-size: 14px; }
|
||||||
|
|
||||||
|
/* 分页栏(固定底部) */
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pagination-total {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
.pagination-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.page-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
padding: 0 3px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.page-btn:hover:not(.disabled) { color: rgb(var(--primary-6)); }
|
||||||
|
.page-btn.disabled { color: var(--color-text-4); cursor: default; }
|
||||||
|
.page-info { color: var(--color-text-2); min-width: 28px; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- 路径段 -->
|
<!-- 路径段 -->
|
||||||
<div
|
<div
|
||||||
class="breadcrumb-segment"
|
class="breadcrumb-segment"
|
||||||
:class="{ 'is-hoverable': index < segments.length - 1 }"
|
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
|
||||||
@mouseenter="onHover(segment, index)"
|
@mouseenter="onHover(segment, index)"
|
||||||
@mouseleave="onLeave"
|
@mouseleave="onLeave"
|
||||||
@click="onClick(segment)"
|
@click="onClick(segment)"
|
||||||
@@ -152,7 +152,8 @@ const resetAndClose = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onHover = (segment: PathSegment, index: number) => {
|
const onHover = (segment: PathSegment, index: number) => {
|
||||||
if (index === segments.value.length - 1) return
|
// 根目录(如 C:)只有一段,也允许悬停弹出子目录
|
||||||
|
if (index === segments.value.length - 1 && segments.value.length > 1) return
|
||||||
|
|
||||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||||
@@ -209,7 +210,7 @@ watch(() => props.path, () => {
|
|||||||
.breadcrumb-items {
|
.breadcrumb-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ watch(() => props.path, () => {
|
|||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0 2px;
|
margin: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹出菜单 */
|
/* 弹出菜单 */
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<div v-show="config.visible" class="sidebar">
|
<div v-show="config.visible" class="sidebar">
|
||||||
<div class="sidebar-header">
|
<!-- 收藏夹区块 -->
|
||||||
<span class="sidebar-title">⭐ 收藏夹</span>
|
<div class="sidebar-section">
|
||||||
<span class="sidebar-count">{{ config.favoriteFiles.length }}</span>
|
<div class="section-header" @click="favCollapsed = !favCollapsed">
|
||||||
|
<span class="section-title">⭐ 收藏夹</span>
|
||||||
|
<span class="section-count">共{{ config.favoriteFiles.length }}项</span>
|
||||||
|
<icon-down v-if="!favCollapsed" class="section-toggle" />
|
||||||
|
<icon-right v-else class="section-toggle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-content">
|
<div class="section-content" :class="{ collapsed: favCollapsed }">
|
||||||
<div
|
<div
|
||||||
v-for="(fav, index) in config.favoriteFiles"
|
v-for="(fav, index) in config.favoriteFiles"
|
||||||
:key="fav.path"
|
:key="fav.path"
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
|
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
|
||||||
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
|
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
|
||||||
}"
|
}"
|
||||||
:draggable="config.draggingState.isDragging && config.draggingState.draggedIndex === index"
|
:draggable="config.draggingState.pressedIndex === index || config.draggingState.isDragging"
|
||||||
@click="handleOpenFavorite(fav)"
|
@click="handleOpenFavorite(fav)"
|
||||||
@mousedown="handleLongPressStart($event, index)"
|
@mousedown="handleLongPressStart($event, index)"
|
||||||
@mouseup="handleLongPressCancel"
|
@mouseup="handleLongPressCancel"
|
||||||
@@ -61,11 +65,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 帮助文档区块 -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
|
||||||
|
<span class="section-title">📖 帮助</span>
|
||||||
|
<icon-down v-if="!helpCollapsed" class="section-toggle" />
|
||||||
|
<icon-right v-else class="section-toggle" />
|
||||||
|
</div>
|
||||||
|
<div class="section-content help-content" :class="{ collapsed: helpCollapsed }">
|
||||||
|
<div class="help-item" v-for="item in helpItems" :key="item.key">
|
||||||
|
<span class="help-key">{{ item.key }}</span>
|
||||||
|
<span class="help-desc">{{ item.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@@ -75,6 +95,10 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// 折叠状态(组件内部,不污染父组件)
|
||||||
|
const favCollapsed = ref(false)
|
||||||
|
const helpCollapsed = ref(true)
|
||||||
|
|
||||||
// 计算第一个和最后一个置顶项的索引
|
// 计算第一个和最后一个置顶项的索引
|
||||||
const pinnedIndices = computed(() => {
|
const pinnedIndices = computed(() => {
|
||||||
return props.config.favoriteFiles
|
return props.config.favoriteFiles
|
||||||
@@ -85,6 +109,15 @@ const pinnedIndices = computed(() => {
|
|||||||
const firstPinnedIndex = computed(() => pinnedIndices.value[0] ?? -1)
|
const firstPinnedIndex = computed(() => pinnedIndices.value[0] ?? -1)
|
||||||
const lastPinnedIndex = computed(() => pinnedIndices.value[pinnedIndices.value.length - 1] ?? -1)
|
const lastPinnedIndex = computed(() => pinnedIndices.value[pinnedIndices.value.length - 1] ?? -1)
|
||||||
|
|
||||||
|
// 帮助内容
|
||||||
|
const helpItems = [
|
||||||
|
{ key: 'Ctrl+B', desc: '切换侧边栏' },
|
||||||
|
{ key: 'Ctrl+H', desc: '历史记录' },
|
||||||
|
{ key: 'Ctrl+F', desc: '聚焦搜索' },
|
||||||
|
{ key: 'Click ⭐', desc: '收藏文件' },
|
||||||
|
{ key: 'Drag', desc: '排序收藏' },
|
||||||
|
]
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'openFavorite', file: FavoriteFile): void
|
(e: 'openFavorite', file: FavoriteFile): void
|
||||||
@@ -101,7 +134,7 @@ interface Emits {
|
|||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// 图标导入
|
// 图标导入
|
||||||
import { IconStar, IconClose, IconPushpin } from '@arco-design/web-vue/es/icon'
|
import { IconStar, IconClose, IconPushpin, IconDown, IconRight } from '@arco-design/web-vue/es/icon'
|
||||||
import { getFileIcon } from '@/utils/fileUtils'
|
import { getFileIcon } from '@/utils/fileUtils'
|
||||||
|
|
||||||
// 事件处理
|
// 事件处理
|
||||||
@@ -151,37 +184,100 @@ const handleDragEnd = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
/* 区块 */
|
||||||
padding: 12px 16px;
|
.sidebar-section {
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
background: var(--color-bg-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
/* 区块头部 - 可点击折叠 */
|
||||||
font-size: 14px;
|
.section-header {
|
||||||
font-weight: 600;
|
padding: 5px 12px;
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:hover {
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-count {
|
.section-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
background: var(--color-fill-2);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.section-toggle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区块内容 - 可折叠 */
|
||||||
|
.section-content {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收藏夹内容 */
|
||||||
|
.section-content:not(.help-content) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
min-height: 0;
|
||||||
padding: 8px;
|
padding: 4px 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 帮助内容 */
|
||||||
|
.help-content {
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-key {
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-desc {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收藏项 */
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -257,6 +353,7 @@ const handleDragEnd = () => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
.sidebar-empty {
|
.sidebar-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -283,7 +380,7 @@ const handleDragEnd = () => {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滑动动画 */
|
/* 侧边栏整体滑入滑出动画 */
|
||||||
.slide-enter-active,
|
.slide-enter-active,
|
||||||
.slide-leave-active {
|
.slide-leave-active {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
📦 {{ config.zipFileName }}
|
📦 {{ config.zipFileName }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
|
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
|
||||||
<icon-right class="breadcrumb-separator" />
|
<icon-right class="breadcrumb-sep" />
|
||||||
<a-tag
|
<a-tag
|
||||||
v-for="(crumb, index) in config.zipBreadcrumbs"
|
v-for="(crumb, index) in config.zipBreadcrumbs"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -25,13 +25,36 @@
|
|||||||
退出 ZIP
|
退出 ZIP
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 正常模式:面包屑导航 -->
|
<!-- 正常模式:连接指示器 + 面包屑导航(融合布局) -->
|
||||||
<div v-else class="path-breadcrumb-wrapper">
|
<div v-else class="path-breadcrumb-wrapper">
|
||||||
|
<!-- 连接指示器(紧凑标签样式,作为面包屑首段) -->
|
||||||
|
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
|
||||||
|
<span class="breadcrumb-sep">›</span>
|
||||||
|
<!-- 路径面包屑 -->
|
||||||
<PathBreadcrumb
|
<PathBreadcrumb
|
||||||
:path="config.filePath"
|
:path="config.filePath"
|
||||||
@navigate="handleGoToPath"
|
@navigate="handleGoToPath"
|
||||||
@openFile="handleOpenFile"
|
@openFile="handleOpenFile"
|
||||||
/>
|
/>
|
||||||
|
<!-- 右侧操作:快捷路径 + 复制 -->
|
||||||
|
<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-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
|
||||||
<a-button
|
<a-button
|
||||||
size="mini"
|
size="mini"
|
||||||
@@ -47,47 +70,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<!-- 快捷路径下拉 -->
|
<!-- 搜索框 -->
|
||||||
<a-dropdown v-if="!config.isBrowsingZip">
|
<a-input-search
|
||||||
<a-button size="small">
|
:model-value="config.searchKeyword"
|
||||||
<template #icon>
|
placeholder="搜索文件..."
|
||||||
<icon-forward />
|
size="small"
|
||||||
</template>
|
class="toolbar-search"
|
||||||
快捷访问
|
allow-clear
|
||||||
</a-button>
|
@search="handleSearch"
|
||||||
<template #content>
|
@update:model-value="handleSearch"
|
||||||
<a-doption
|
@keyup.escape="handleClearSearch"
|
||||||
v-for="shortcut in config.commonPaths"
|
/>
|
||||||
:key="shortcut.path"
|
|
||||||
@click="handleGoToPath(shortcut.path)"
|
|
||||||
>
|
|
||||||
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
|
||||||
{{ (shortcut.name || '').substring(2) }}
|
|
||||||
</a-doption>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
|
|
||||||
<!-- 历史记录下拉 -->
|
|
||||||
<a-dropdown>
|
|
||||||
<a-button size="small">
|
|
||||||
<template #icon>
|
|
||||||
<icon-history />
|
|
||||||
</template>
|
|
||||||
历史
|
|
||||||
</a-button>
|
|
||||||
<template #content>
|
|
||||||
<a-doption
|
|
||||||
v-for="path in config.pathHistory.slice(0, 10)"
|
|
||||||
:key="path"
|
|
||||||
@click="handleGoToPath(path)"
|
|
||||||
>
|
|
||||||
{{ path }}
|
|
||||||
</a-doption>
|
|
||||||
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<a-button
|
<a-button
|
||||||
@@ -101,6 +97,29 @@
|
|||||||
刷新
|
刷新
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 历史记录下拉(仅图标,Ctrl+H) -->
|
||||||
|
<a-dropdown
|
||||||
|
v-model:popup-visible="historyPopupVisible"
|
||||||
|
>
|
||||||
|
<a-tooltip content="历史记录 (Ctrl+H)" position="left">
|
||||||
|
<a-button size="small">
|
||||||
|
<template #icon><icon-history /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<template #content>
|
||||||
|
<div class="history-dropdown-content">
|
||||||
|
<a-doption
|
||||||
|
v-for="path in config.pathHistory.slice(0, 10)"
|
||||||
|
:key="path"
|
||||||
|
@click="handleGoToPath(path)"
|
||||||
|
>
|
||||||
|
<span class="history-path-text">{{ path }}</span>
|
||||||
|
</a-doption>
|
||||||
|
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
|
||||||
<!-- 切换侧边栏 -->
|
<!-- 切换侧边栏 -->
|
||||||
<a-button
|
<a-button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -113,13 +132,18 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConnectionDialog ref="connectionDialogRef" v-model:visible="showConnectionDialog" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
|
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||||
import type { ToolbarConfig } from '@/types/file-system'
|
import type { ToolbarConfig } from '@/types/file-system'
|
||||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||||
|
import ConnectionIndicator from './ConnectionIndicator.vue'
|
||||||
|
import ConnectionDialog from './ConnectionDialog.vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -132,15 +156,33 @@ const props = defineProps<Props>()
|
|||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:filePath', path: string): void
|
(e: 'update:filePath', path: string): void
|
||||||
(e: 'update:showSidebar', show: boolean): void
|
(e: 'update:showSidebar', show: boolean): void
|
||||||
|
(e: 'update:searchKeyword', keyword: string): void
|
||||||
(e: 'refresh'): void
|
(e: 'refresh'): void
|
||||||
(e: 'exitZip'): void
|
(e: 'exitZip'): void
|
||||||
(e: 'goToPath', path: string): void
|
(e: 'goToPath', path: string): void
|
||||||
(e: 'openFile', path: string): void
|
(e: 'openFile', path: string): void
|
||||||
(e: 'navigateToZipDirectory', path: string): void
|
(e: 'navigateToZipDirectory', path: string): void
|
||||||
|
(e: 'connectionChanged'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 连接对话框
|
||||||
|
const showConnectionDialog = ref(false)
|
||||||
|
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
|
||||||
|
const onConnectionChanged = async (_id: string) => {
|
||||||
|
emit('connectionChanged')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEditProfile = (id: string) => {
|
||||||
|
showConnectionDialog.value = true
|
||||||
|
// 等待 DOM 更新后调用 editProfile 填充表单
|
||||||
|
nextTick(() => connectionDialogRef.value?.editProfile(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
|
||||||
|
const historyPopupVisible = ref(false)
|
||||||
|
|
||||||
// 事件处理
|
// 事件处理
|
||||||
const handleGoToPath = (path: string) => {
|
const handleGoToPath = (path: string) => {
|
||||||
emit('goToPath', path)
|
emit('goToPath', path)
|
||||||
@@ -162,16 +204,28 @@ const handleNavigateToZipRoot = () => {
|
|||||||
emit('navigateToZipDirectory', '')
|
emit('navigateToZipDirectory', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNavigateToZipDirectory = (path: string) => {
|
|
||||||
emit('navigateToZipDirectory', path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleSidebar = () => {
|
const handleToggleSidebar = () => {
|
||||||
emit('update:showSidebar', !props.config.showSidebar)
|
emit('update:showSidebar', !props.config.showSidebar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearch = (keyword: string) => {
|
||||||
|
emit('update:searchKeyword', keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
emit('update:searchKeyword', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换历史记录下拉面板(供父组件 Ctrl+H 调用)
|
||||||
|
const toggleHistoryDropdown = () => {
|
||||||
|
historyPopupVisible.value = !historyPopupVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
const { copied, copy: copyPath } = useClipboardCopy()
|
const { copied, copy: copyPath } = useClipboardCopy()
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({ toggleHistoryDropdown })
|
||||||
|
|
||||||
const handleCopyPath = async () => {
|
const handleCopyPath = async () => {
|
||||||
await copyPath(props.config.filePath)
|
await copyPath(props.config.filePath)
|
||||||
}
|
}
|
||||||
@@ -202,32 +256,59 @@ const handleCopyPath = async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-right :deep(.arco-btn-size-small),
|
||||||
|
.toolbar-right :deep(.arco-input-wrapper) {
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.path-input-wrapper {
|
.path-input-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.path-breadcrumb-wrapper {
|
.path-breadcrumb-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--color-fill-1);
|
background: var(--color-fill-1);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-breadcrumb-wrapper:hover {
|
.path-breadcrumb-wrapper:hover {
|
||||||
border-color: var(--color-border-2);
|
border-color: var(--color-border-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumb-sep {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-right-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-btn {
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-copy-btn {
|
.toolbar-copy-btn {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
@@ -253,12 +334,6 @@ const handleCopyPath = async () => {
|
|||||||
border-color: rgb(var(--primary-6));
|
border-color: rgb(var(--primary-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-separator {
|
|
||||||
color: var(--color-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-tag {
|
.breadcrumb-tag {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -272,12 +347,18 @@ const handleCopyPath = async () => {
|
|||||||
border-color: rgb(var(--primary-6));
|
border-color: rgb(var(--primary-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.zip-path-text {
|
/* 历史记录下拉 */
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
.history-dropdown-content {
|
||||||
font-size: 13px;
|
max-width: 420px;
|
||||||
color: var(--color-text-2);
|
max-height: 300px;
|
||||||
white-space: nowrap;
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-path-text {
|
||||||
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 380px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,90 +5,56 @@
|
|||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { PATH_ICONS } from '@/utils/constants'
|
import { PATH_ICONS } from '@/utils/constants'
|
||||||
|
import { getCommonPaths } from '@/api/system'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import type { ShortcutPath } from '@/types/file-system'
|
import type { ShortcutPath } from '@/types/file-system'
|
||||||
|
|
||||||
export function useCommonPaths() {
|
export function useCommonPaths() {
|
||||||
// 系统路径
|
|
||||||
const commonPaths = ref<ShortcutPath[]>([])
|
const commonPaths = ref<ShortcutPath[]>([])
|
||||||
const systemPaths = ref<Record<string, string>>({})
|
const systemPaths = ref<Record<string, string>>({})
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载常用系统路径
|
|
||||||
*/
|
|
||||||
const loadCommonPaths = async () => {
|
const loadCommonPaths = async () => {
|
||||||
try {
|
try {
|
||||||
// 检查 Wails API 是否可用
|
const paths = await getCommonPaths()
|
||||||
if (!window.go?.main?.App?.GetCommonPaths) {
|
if (!paths) throw new Error('无法获取系统路径')
|
||||||
// 降级方案:使用默认路径
|
|
||||||
commonPaths.value = [
|
|
||||||
{ name: '💿 C盘', path: 'C:\\' },
|
|
||||||
{ name: '💿 D盘', path: 'D:\\' }
|
|
||||||
]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = await window.go.main.App.GetCommonPaths()
|
|
||||||
if (!paths) {
|
|
||||||
throw new Error('无法获取系统路径')
|
|
||||||
}
|
|
||||||
|
|
||||||
systemPaths.value = paths
|
systemPaths.value = paths
|
||||||
const platform = window.navigator.platform
|
|
||||||
const pathList: ShortcutPath[] = []
|
const pathList: ShortcutPath[] = []
|
||||||
|
// 根据返回数据判断平台(Linux agent 返回 root key,Windows 返回 root_ 前缀)
|
||||||
|
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
|
||||||
|
|
||||||
if (platform.includes('Win')) {
|
if (isWin) {
|
||||||
// Windows: 先添加基础路径,再添加所有盘符
|
|
||||||
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
||||||
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
||||||
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
||||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
|
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
|
||||||
|
|
||||||
// 动态添加所有盘符(按字母顺序)
|
|
||||||
const drives: Array<{ letter: string; path: string }> = []
|
const drives: Array<{ letter: string; path: string }> = []
|
||||||
for (const key in paths) {
|
for (const key in paths) {
|
||||||
if (key.startsWith('root_')) {
|
if (key.startsWith('root_')) {
|
||||||
const driveLetter = key.substring(5)
|
drives.push({ letter: key.substring(5), path: paths[key] })
|
||||||
drives.push({
|
|
||||||
letter: driveLetter,
|
|
||||||
path: paths[key]
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
||||||
|
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}盘`, path: d.path }))
|
||||||
// 添加盘符到路径列表
|
|
||||||
drives.forEach(drive => {
|
|
||||||
pathList.push({
|
|
||||||
name: `${PATH_ICONS.DRIVE} ${drive.letter}盘`,
|
|
||||||
path: drive.path
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
// macOS/Linux: 使用系统路径
|
// Linux 远程模式
|
||||||
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
|
||||||
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
|
||||||
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
|
||||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
|
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
|
||||||
pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
||||||
|
if (paths.users) pathList.push({ name: `👥 /home`, path: paths.users })
|
||||||
}
|
}
|
||||||
|
|
||||||
commonPaths.value = pathList.length > 0 ? pathList : [
|
commonPaths.value = pathList.length > 0 ? pathList : (
|
||||||
{ name: '💿 C盘', path: 'C:\\' },
|
connectionManager.isRemote()
|
||||||
{ name: '💿 D盘', path: 'D:\\' }
|
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||||
]
|
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载系统路径失败:', error)
|
console.error('加载系统路径失败:', error)
|
||||||
// 降级方案
|
commonPaths.value = connectionManager.isRemote()
|
||||||
commonPaths.value = [
|
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||||
{ name: '💿 C盘', path: 'C:\\' },
|
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||||
{ name: '💿 D盘', path: 'D:\\' }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { commonPaths, systemPaths, loadCommonPaths }
|
||||||
commonPaths,
|
|
||||||
systemPaths,
|
|
||||||
loadCommonPaths
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,18 +21,12 @@ export function useFavorites() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排序收藏列表:置顶项在前(按 pinnedAt 降序),非置顶项按添加时间降序
|
* 排序收藏列表:置顶项归到前面,组内保持原有顺序(尊重拖拽)
|
||||||
*/
|
*/
|
||||||
const sortFavorites = () => {
|
const sortFavorites = () => {
|
||||||
favorites.value = [...favorites.value].sort((a, b) => {
|
const pinned = favorites.value.filter(f => f.pinnedAt)
|
||||||
// 置顶项优先
|
const unpinned = favorites.value.filter(f => !f.pinnedAt)
|
||||||
if (a.pinnedAt && !b.pinnedAt) return -1
|
favorites.value = [...pinned, ...unpinned]
|
||||||
if (!a.pinnedAt && b.pinnedAt) return 1
|
|
||||||
// 都是置顶项,按置顶时间降序
|
|
||||||
if (a.pinnedAt && b.pinnedAt) return b.pinnedAt - a.pinnedAt
|
|
||||||
// 都不是置顶项,按添加时间降序(最新在前)
|
|
||||||
return b.addedAt - a.addedAt
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +44,7 @@ export function useFavorites() {
|
|||||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 排序
|
// 仅排序(置顶项归组到前面),保持用户拖拽顺序
|
||||||
sortFavorites()
|
sortFavorites()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -171,15 +165,23 @@ export function useFavorites() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽方法
|
// 拖拽方法
|
||||||
|
const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||||
if (event instanceof MouseEvent && event.button !== 0) return
|
if (event instanceof MouseEvent && event.button !== 0) return
|
||||||
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
|
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
|
||||||
|
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
draggingState.value.pressedIndex = index
|
draggingState.value.pressedIndex = index
|
||||||
draggingState.value.draggedIndex = index
|
draggingState.value.draggedIndex = index
|
||||||
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLongPressCancel = () => {
|
const onLongPressCancel = () => {
|
||||||
|
if (longPressTimer) {
|
||||||
|
clearTimeout(longPressTimer)
|
||||||
|
longPressTimer = null
|
||||||
|
}
|
||||||
if (!draggingState.value.isDragging) {
|
if (!draggingState.value.isDragging) {
|
||||||
draggingState.value.pressedIndex = -1
|
draggingState.value.pressedIndex = -1
|
||||||
draggingState.value.draggedIndex = -1
|
draggingState.value.draggedIndex = -1
|
||||||
@@ -191,7 +193,6 @@ export function useFavorites() {
|
|||||||
draggingState.value.draggedIndex = index
|
draggingState.value.draggedIndex = index
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
event.dataTransfer.setData('text/plain', index.toString())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import {
|
|||||||
isTextEditable, isConfigFile
|
isTextEditable, isConfigFile
|
||||||
} from '@/utils/fileTypeHelpers'
|
} from '@/utils/fileTypeHelpers'
|
||||||
import { useFileOperations } from './useFileOperations'
|
import { useFileOperations } from './useFileOperations'
|
||||||
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
export interface UseFileEditOptions {
|
export interface UseFileEditOptions {
|
||||||
currentFilePath?: any
|
currentFilePath?: import('vue').Ref<FileItem | null>
|
||||||
currentDirectory?: any
|
currentDirectory?: import('vue').Ref<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件大小限制(5MB)
|
// 文件大小限制(5MB)
|
||||||
@@ -46,9 +47,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||||
const fileVersion = ref(0)
|
const fileVersion = ref(0)
|
||||||
|
|
||||||
// 最后一次文件加载的时间戳,用于过滤过期更新
|
|
||||||
const lastLoadTime = ref(0)
|
|
||||||
|
|
||||||
// 使用文件操作 composable
|
// 使用文件操作 composable
|
||||||
const { readFile, writeFile } = useFileOperations({
|
const { readFile, writeFile } = useFileOperations({
|
||||||
onSuccess: (operation, data) => {
|
onSuccess: (operation, data) => {
|
||||||
@@ -81,7 +79,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
* 判断是否为二进制文件(基于扩展名)
|
* 判断是否为二进制文件(基于扩展名)
|
||||||
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
||||||
*/
|
*/
|
||||||
const isBinaryFileByExt = (filepath: any): boolean | null => {
|
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
|
||||||
const path = getFilePath(filepath)
|
const path = getFilePath(filepath)
|
||||||
const ext = getExt(path)
|
const ext = getExt(path)
|
||||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||||
@@ -185,9 +183,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
// 增加文件版本号,使之前的过期更新失效
|
// 增加文件版本号,使之前的过期更新失效
|
||||||
fileVersion.value++
|
fileVersion.value++
|
||||||
|
|
||||||
// 记录加载时间戳,用于过滤过期更新
|
|
||||||
lastLoadTime.value = Date.now()
|
|
||||||
|
|
||||||
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
// 注意:不再清空内容,避免 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) => {
|
const updateContent = (content: string, expectedVersion?: number) => {
|
||||||
// 如果提供了期望的版本号,检查是否匹配
|
|
||||||
// 这用于防止快速切换文件时,旧文件的防抖更新覆盖新文件的内容
|
|
||||||
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
||||||
// 版本不匹配,这是一个过期的更新,忽略它
|
|
||||||
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
||||||
expected: expectedVersion,
|
expected: expectedVersion,
|
||||||
current: fileVersion.value,
|
current: fileVersion.value,
|
||||||
@@ -517,25 +535,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 额外检查:如果更新是在文件加载后的短时间内,可能是过期更新
|
|
||||||
// 防抖时间是 150ms,我们使用 300ms 的安全边际
|
|
||||||
const timeSinceLoad = Date.now() - lastLoadTime.value
|
|
||||||
if (timeSinceLoad < 300) {
|
|
||||||
console.debug('[useFileEdit] 忽略过期更新(时间窗口内):', {
|
|
||||||
timeSinceLoad,
|
|
||||||
content: content.substring(0, 50)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保只有在内容真正改变时才更新
|
|
||||||
if (fileContent.value !== content) {
|
if (fileContent.value !== content) {
|
||||||
fileContent.value = content
|
fileContent.value = content
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动保存草稿(防抖)
|
|
||||||
// 实际实现应该使用防抖函数
|
|
||||||
// saveDraft()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -556,12 +558,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
return filePath.startsWith(currentDirectory.value)
|
return filePath.startsWith(currentDirectory.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听文件内容变化,自动保存草稿
|
|
||||||
watch(fileContent, () => {
|
|
||||||
// 实际实现应该使用防抖
|
|
||||||
// saveDraft()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// 监听文件路径变化,清除草稿
|
// 监听文件路径变化,清除草稿
|
||||||
watch(currentFilePath, (newPath, oldPath) => {
|
watch(currentFilePath, (newPath, oldPath) => {
|
||||||
if (newPath !== oldPath) {
|
if (newPath !== oldPath) {
|
||||||
@@ -604,6 +600,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
// 其他
|
// 其他
|
||||||
resetContent,
|
resetContent,
|
||||||
clearContent,
|
clearContent,
|
||||||
|
updateFilePath,
|
||||||
setEditorHeight,
|
setEditorHeight,
|
||||||
|
|
||||||
// 文件类型检查
|
// 文件类型检查
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ref } from 'vue'
|
|||||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||||
import { detectFileTypeByContent } from '@/api/system'
|
import { detectFileTypeByContent } from '@/api/system'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import {
|
import {
|
||||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||||
@@ -26,12 +27,22 @@ export interface UseFilePreviewOptions {
|
|||||||
isBrowsingZip?: boolean
|
isBrowsingZip?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalServerURL(): string {
|
||||||
|
return 'http://localhost:8073'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFileServerBase(): string {
|
||||||
|
// 单一数据源:从 connectionManager 实时读取,不缓存
|
||||||
|
if (!connectionManager.isRemote()) return getLocalServerURL()
|
||||||
|
const base = connectionManager.getFileServerBaseURL()
|
||||||
|
if (!base) return getLocalServerURL()
|
||||||
|
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
|
||||||
|
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||||
|
}
|
||||||
|
|
||||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||||
|
|
||||||
// 文件服务器 URL(硬编码,与旧版本保持一致)
|
|
||||||
const fileServerURL = 'http://localhost:18765'
|
|
||||||
|
|
||||||
// 预览 URL
|
// 预览 URL
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
|
|
||||||
@@ -40,12 +51,19 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
const currentImageDimensions = ref('')
|
const currentImageDimensions = ref('')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取预览 URL(与旧版本保持一致)
|
* 获取预览 URL(本地/远程自适应,每次实时计算)
|
||||||
|
* 本地: http://localhost:8073/localfs/{encoded_path}
|
||||||
|
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
|
||||||
*/
|
*/
|
||||||
const getPreviewUrl = (path: string): string => {
|
const getPreviewUrl = (path: string): string => {
|
||||||
if (!path) return ''
|
if (!path) return ''
|
||||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
const isRemote = connectionManager.isRemote()
|
||||||
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
const base = resolveFileServerBase()
|
||||||
|
let normalized = normalizeFilePath(path, true)
|
||||||
|
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
|
||||||
|
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||||
|
const sep = base.endsWith('/') ? '' : '/'
|
||||||
|
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +94,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
/**
|
/**
|
||||||
* 更新预览 URL
|
* 更新预览 URL
|
||||||
*/
|
*/
|
||||||
const updatePreviewUrl = (path: string) => {
|
const updatePreviewUrl = async (path: string) => {
|
||||||
previewUrl.value = getPreviewUrl(path)
|
previewUrl.value = getPreviewUrl(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,12 +206,6 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
|
|
||||||
// 文件类型判断(同步,基于扩展名)
|
// 文件类型判断(同步,基于扩展名)
|
||||||
getFileType,
|
getFileType,
|
||||||
isImageFile,
|
|
||||||
isVideoFile,
|
|
||||||
isAudioFile,
|
|
||||||
isPdfFile,
|
|
||||||
isHtmlFile,
|
|
||||||
isMarkdownFile,
|
|
||||||
isPreviewable,
|
isPreviewable,
|
||||||
isEditable,
|
isEditable,
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="file-system-container">
|
<div class="file-system-container">
|
||||||
<!-- 顶部工具栏 -->
|
<!-- 顶部工具栏 -->
|
||||||
<Toolbar
|
<Toolbar
|
||||||
|
ref="toolbarRef"
|
||||||
:config="toolbarConfig"
|
:config="toolbarConfig"
|
||||||
@update:file-path="handleFilePathUpdate"
|
@update:file-path="handleFilePathUpdate"
|
||||||
@update:show-sidebar="handleSidebarToggle"
|
@update:show-sidebar="handleSidebarToggle"
|
||||||
@@ -10,7 +11,9 @@
|
|||||||
@go-to-path="handleGoToPath"
|
@go-to-path="handleGoToPath"
|
||||||
@open-file="handleOpenFile"
|
@open-file="handleOpenFile"
|
||||||
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
||||||
|
@update:search-keyword="handleSearchKeywordUpdate"
|
||||||
@show-message="handleShowMessage"
|
@show-message="handleShowMessage"
|
||||||
|
@connection-changed="handleConnectionChanged"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
<!-- 主内容区 -->
|
||||||
@@ -54,9 +57,8 @@
|
|||||||
<!-- 分隔条 -->
|
<!-- 分隔条 -->
|
||||||
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
||||||
|
|
||||||
<!-- 文件编辑器面板 -->
|
<!-- 文件编辑器面板(始终显示,无选中文件时为空白预览区) -->
|
||||||
<FileEditorPanel
|
<FileEditorPanel
|
||||||
v-if="hasSelectedFile"
|
|
||||||
:config="fileEditorPanelConfig"
|
:config="fileEditorPanelConfig"
|
||||||
:width="panelWidth.right"
|
:width="panelWidth.right"
|
||||||
:current-directory="filePath"
|
:current-directory="filePath"
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { getPathSeparator } from '@/utils/fileUtils'
|
import { getPathSeparator } from '@/utils/fileUtils'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
// 导入子组件
|
// 导入子组件
|
||||||
@@ -127,6 +129,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
|||||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||||
import { listDir, saveBase64File } from '@/api/system'
|
import { listDir, saveBase64File } from '@/api/system'
|
||||||
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||||
import { createResizeHandler } from '@/utils/resize'
|
import { createResizeHandler } from '@/utils/resize'
|
||||||
|
|
||||||
@@ -152,6 +155,7 @@ const isEditableWithPreview = (filename: string): boolean => {
|
|||||||
const fileList = ref<FileItem[]>([])
|
const fileList = ref<FileItem[]>([])
|
||||||
const fileLoading = ref(false)
|
const fileLoading = ref(false)
|
||||||
const selectedFileItem = ref<FileItem | null>(null)
|
const selectedFileItem = ref<FileItem | null>(null)
|
||||||
|
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||||
|
|
||||||
// 排序状态(带 localStorage 持久化)
|
// 排序状态(带 localStorage 持久化)
|
||||||
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
||||||
@@ -187,6 +191,18 @@ const editingFileName = ref('')
|
|||||||
// 侧边栏
|
// 侧边栏
|
||||||
const showSidebar = ref(true)
|
const showSidebar = ref(true)
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
// 过滤后的文件列表(基于搜索关键词)
|
||||||
|
const filteredFileList = computed(() => {
|
||||||
|
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||||
|
if (!keyword) return fileList.value
|
||||||
|
return fileList.value.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 面板宽度(带 localStorage 持久化)
|
// 面板宽度(带 localStorage 持久化)
|
||||||
const restorePanelWidth = (): { left: number; right: number } => {
|
const restorePanelWidth = (): { left: number; right: number } => {
|
||||||
try {
|
try {
|
||||||
@@ -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({
|
useFileEdit({
|
||||||
currentFilePath: selectedFileItem,
|
currentFilePath: selectedFileItem,
|
||||||
currentDirectory: filePath
|
currentDirectory: filePath
|
||||||
@@ -295,7 +311,8 @@ const toolbarConfig = computed(() => ({
|
|||||||
fileLoading: fileLoading.value,
|
fileLoading: fileLoading.value,
|
||||||
showSidebar: showSidebar.value,
|
showSidebar: showSidebar.value,
|
||||||
sortBy: sortBy.value,
|
sortBy: sortBy.value,
|
||||||
sortOrder: sortOrder.value
|
sortOrder: sortOrder.value,
|
||||||
|
searchKeyword: searchKeyword.value
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 侧边栏配置
|
// 侧边栏配置
|
||||||
@@ -307,7 +324,7 @@ const sidebarConfig = computed(() => ({
|
|||||||
|
|
||||||
// 文件列表面板配置
|
// 文件列表面板配置
|
||||||
const fileListPanelConfig = computed(() => ({
|
const fileListPanelConfig = computed(() => ({
|
||||||
fileList: fileList.value,
|
fileList: filteredFileList.value,
|
||||||
fileLoading: fileLoading.value,
|
fileLoading: fileLoading.value,
|
||||||
selectedFileItem: selectedFileItem.value,
|
selectedFileItem: selectedFileItem.value,
|
||||||
editingFilePath: editingFilePath.value,
|
editingFilePath: editingFilePath.value,
|
||||||
@@ -320,10 +337,24 @@ const computeRendered = computed(() => {
|
|||||||
if (isHtmlFile(currentFileName)) {
|
if (isHtmlFile(currentFileName)) {
|
||||||
return fileContent.value || ''
|
return fileContent.value || ''
|
||||||
} else if (isMarkdownFile(currentFileName)) {
|
} else if (isMarkdownFile(currentFileName)) {
|
||||||
// 使用配置好的 marked 渲染 Markdown(支持 mermaid)
|
// 使用配置好的 marked 渲染 Markdown(支持 mermaid + 图片相对路径转换)
|
||||||
try {
|
try {
|
||||||
const content = fileContent.value || ''
|
const content = fileContent.value || ''
|
||||||
return marked(content)
|
|
||||||
|
// 设置图片路径转换所需的上下文(renderer.image 钩子中读取)
|
||||||
|
// dir: 当前 md 文件所在目录(从文件完整路径中去掉文件名)
|
||||||
|
const fullPath = selectedFileItem.value?.path || ''
|
||||||
|
const dir = fullPath ? fullPath.replace(/[/\\][^/\\]+$/, '') : (filePath.value || '')
|
||||||
|
setCurrentFileDir(dir)
|
||||||
|
|
||||||
|
// 设置文件服务器 Base URL
|
||||||
|
const isRemote = connectionManager.isRemote()
|
||||||
|
const base = isRemote
|
||||||
|
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
|
||||||
|
: 'http://localhost:8073/localfs'
|
||||||
|
setFileServerBase(base)
|
||||||
|
|
||||||
|
return marked.parse(content) as string
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Markdown 解析失败:', error)
|
console.error('Markdown 解析失败:', error)
|
||||||
return fileContent.value || ''
|
return fileContent.value || ''
|
||||||
@@ -363,7 +394,8 @@ const fileEditorPanelConfig = computed(() => {
|
|||||||
imageLoading: imageLoading.value,
|
imageLoading: imageLoading.value,
|
||||||
currentImageDimensions: currentImageDimensions.value,
|
currentImageDimensions: currentImageDimensions.value,
|
||||||
currentFileExtension,
|
currentFileExtension,
|
||||||
isBinaryFile: isBinaryFileRef.value
|
isBinaryFile: isBinaryFileRef.value,
|
||||||
|
fileMtime: selectedFileItem.value?.modified_time || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -382,6 +414,23 @@ const handleRefresh = async () => {
|
|||||||
await loadDirectory(filePath.value)
|
await loadDirectory(filePath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 连接切换后重置路径(仅监听状态变化以刷新快捷入口,不做自动导航)
|
||||||
|
connectionManager.onStateChange(async (state) => {
|
||||||
|
if (state === 'connected') {
|
||||||
|
await loadCommonPaths()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSearchKeywordUpdate = (keyword: string) => {
|
||||||
|
searchKeyword.value = keyword
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户主动切换连接时重置到根路径
|
||||||
|
const handleConnectionChanged = async () => {
|
||||||
|
await loadCommonPaths()
|
||||||
|
await navigate(connectionManager.isRemote() ? '/' : 'C:/')
|
||||||
|
}
|
||||||
|
|
||||||
const handleGoToPath = async (path: string) => {
|
const handleGoToPath = async (path: string) => {
|
||||||
await navigate(path)
|
await navigate(path)
|
||||||
}
|
}
|
||||||
@@ -479,6 +528,26 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
|
|||||||
|
|
||||||
// 侧边栏事件
|
// 侧边栏事件
|
||||||
const handleOpenFavorite = async (file: FavoriteFile) => {
|
const handleOpenFavorite = async (file: FavoriteFile) => {
|
||||||
|
// 根据路径格式自动切换连接(Linux 路径 → 远程,Windows 路径 → 本地)
|
||||||
|
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
|
||||||
|
const shouldBeRemote = isLinuxPath
|
||||||
|
const isCurrentlyRemote = connectionManager.isRemote()
|
||||||
|
|
||||||
|
if (shouldBeRemote !== isCurrentlyRemote) {
|
||||||
|
// 需要切换连接
|
||||||
|
if (shouldBeRemote) {
|
||||||
|
// 切换到远程:找第一个 remote profile
|
||||||
|
const remoteProfile = connectionManager.profiles.find(p => p.type === 'remote')
|
||||||
|
if (remoteProfile) {
|
||||||
|
connectionManager.connect(remoteProfile.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 切换到本地
|
||||||
|
connectionManager.disconnect()
|
||||||
|
}
|
||||||
|
await loadCommonPaths()
|
||||||
|
}
|
||||||
|
|
||||||
if (file.isDir) {
|
if (file.isDir) {
|
||||||
await navigate(file.path)
|
await navigate(file.path)
|
||||||
} else {
|
} else {
|
||||||
@@ -619,24 +688,12 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果重命名的是当前打开的文件,先关闭编辑器和预览
|
// 标记是否需要重命名后仅更新路径(内容不变,零闪烁)
|
||||||
if (selectedFileItem.value?.path === oldPath) {
|
let needUpdatePath = false
|
||||||
// 如果是文件(不是文件夹),才需要关闭编辑器
|
|
||||||
if (!selectedFileItem.value.isDir) {
|
|
||||||
// 清空编辑器内容
|
|
||||||
await clearContent()
|
|
||||||
|
|
||||||
// 清空预览URL
|
// 如果重命名的是当前打开的文件
|
||||||
if (previewUrl.value) {
|
if (selectedFileItem.value?.path === oldPath && !selectedFileItem.value.isDir) {
|
||||||
previewUrl.value = ''
|
needUpdatePath = true
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消选中状态
|
|
||||||
selectedFileItem.value = null
|
|
||||||
|
|
||||||
// 等待文件句柄释放(文件需要更长时间)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renamedFile = await fileOps.rename(oldPath, trimmedName)
|
const renamedFile = await fileOps.rename(oldPath, trimmedName)
|
||||||
@@ -650,6 +707,13 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
||||||
|
|
||||||
|
// 仅更新路径关联,不重新加载内容(编辑器内容不变,零闪烁)
|
||||||
|
if (needUpdatePath && !renamedFile.isDir) {
|
||||||
|
selectedFileItem.value = renamedFile
|
||||||
|
updateFilePath(newPath)
|
||||||
|
updatePreviewUrl(newPath)
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 提取错误信息
|
// 提取错误信息
|
||||||
let errorMsg = error?.message || error?.toString() || '未知错误'
|
let errorMsg = error?.message || error?.toString() || '未知错误'
|
||||||
@@ -1008,6 +1072,9 @@ const selectFile = async (path: string) => {
|
|||||||
|
|
||||||
// 加载文件内容
|
// 加载文件内容
|
||||||
await loadFileContent(path)
|
await loadFileContent(path)
|
||||||
|
|
||||||
|
// 记住上次打开的文件
|
||||||
|
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadFileContent = async (path: string) => {
|
const loadFileContent = async (path: string) => {
|
||||||
@@ -1162,7 +1229,7 @@ const extractZipImageAndPreview = async (zipPath: string, filePath: string): Pro
|
|||||||
const temp = await fileOps.extractZipFileToTemp(zipPath, filePath)
|
const temp = await fileOps.extractZipFileToTemp(zipPath, filePath)
|
||||||
const url = await fileOps.getFileServerURL()
|
const url = await fileOps.getFileServerURL()
|
||||||
const normalized = temp.replace(/\\/g, '/')
|
const normalized = temp.replace(/\\/g, '/')
|
||||||
updatePreviewUrl(`${url}/localfs/${encodeURIComponent(normalized)}`)
|
updatePreviewUrl(normalized)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('提取图片失败:', error)
|
console.error('提取图片失败:', error)
|
||||||
Message.error(`提取图片失败: ${error}`)
|
Message.error(`提取图片失败: ${error}`)
|
||||||
@@ -1201,18 +1268,32 @@ const handleHorizontalResize = createResizeHandler(
|
|||||||
|
|
||||||
// ========== 生命周期 ==========
|
// ========== 生命周期 ==========
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 加载系统路径
|
// 加载系统路径(阻塞,确保快捷入口就绪)
|
||||||
loadCommonPaths()
|
await loadCommonPaths()
|
||||||
|
|
||||||
// 初始化加载
|
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
||||||
if (!filePath.value) {
|
const startPath = connectionManager.isRemote() ? '/'
|
||||||
// 设置默认路径
|
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
||||||
const defaultPath = commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:\\'
|
if (filePath.value && !connectionManager.isRemote()) {
|
||||||
filePath.value = defaultPath
|
await loadDirectory(filePath.value)
|
||||||
loadDirectory(defaultPath)
|
|
||||||
} else {
|
} else {
|
||||||
loadDirectory(filePath.value)
|
filePath.value = startPath
|
||||||
|
await loadDirectory(startPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复上次打开的文件
|
||||||
|
const lastFile = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
|
||||||
|
if (lastFile) {
|
||||||
|
const normalized = lastFile.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const currentDir = filePath.value.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
const lastFileDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/'
|
||||||
|
if (lastFileDir.toLowerCase() === currentDir.toLowerCase()) {
|
||||||
|
const found = fileList.value.find(f => f.path.replace(/\\/g, '/').toLowerCase() === normalized.toLowerCase())
|
||||||
|
if (found && !found.isDir) {
|
||||||
|
await selectFile(found.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加键盘快捷键
|
// 添加键盘快捷键
|
||||||
@@ -1237,12 +1318,23 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// F5 刷新文件列表
|
// F5 刷新文件列表 + 重载当前预览文件
|
||||||
if (event.key === 'F5') {
|
if (event.key === 'F5') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (filePath.value) {
|
if (filePath.value) {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,7 +1562,7 @@ watch(() => themeStore.isDark, async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resizer {
|
.resizer {
|
||||||
width: 4px;
|
width: 3px;
|
||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
<!-- 版本更新 -->
|
<!-- 版本更新 -->
|
||||||
<a-tab-pane key="update" title="版本更新">
|
<a-tab-pane key="update" title="版本更新">
|
||||||
<UpdatePanel />
|
<UpdatePanel @open-version-history="handleOpenVersionHistory" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
@@ -122,7 +122,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits(['update:modelValue', 'save'])
|
const emit = defineEmits(['update:modelValue', 'save', 'open-version-history'])
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
@@ -291,6 +291,11 @@ const handleReset = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开版本历史
|
||||||
|
const handleOpenVersionHistory = () => {
|
||||||
|
emit('open-version-history')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
||||||
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
||||||
import { useUpdateStore } from '../stores/update'
|
import { useUpdateStore } from '../stores/update'
|
||||||
|
import { marked } from '../utils/markedExtensions'
|
||||||
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -66,8 +68,8 @@ const showUpdateModal = () => {
|
|||||||
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
||||||
content: () => {
|
content: () => {
|
||||||
const elements = [
|
const elements = [
|
||||||
h('div', { style: { marginBottom: '12px' } }, [
|
h('div', { style: { marginBottom: '8px' } }, [
|
||||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '版本:'),
|
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-1)', marginLeft: '8px' } }, currentVersion.value),
|
||||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
||||||
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
||||||
@@ -76,20 +78,23 @@ const showUpdateModal = () => {
|
|||||||
|
|
||||||
// 更新日志
|
// 更新日志
|
||||||
if (changelog.value) {
|
if (changelog.value) {
|
||||||
|
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
|
||||||
elements.push(
|
elements.push(
|
||||||
h('div', { style: { marginBottom: '12px' } }, [
|
h('div', { style: { marginBottom: '8px' } }, [
|
||||||
h('div', { style: { fontSize: '13px', color: 'var(--color-text-2)', marginBottom: '8px' } }, '更新内容:'),
|
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'),
|
||||||
h('div', {
|
h('div', {
|
||||||
style: {
|
style: {
|
||||||
fontSize: '13px',
|
fontSize: '12px',
|
||||||
color: 'var(--color-text-2)',
|
color: 'var(--color-text-2)',
|
||||||
lineHeight: '1.8',
|
lineHeight: '1.6',
|
||||||
padding: '12px',
|
padding: '10px 12px',
|
||||||
background: 'var(--color-fill-1)',
|
background: 'var(--color-fill-1)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
whiteSpace: 'pre-wrap'
|
maxHeight: '240px',
|
||||||
}
|
overflowY: 'auto'
|
||||||
}, changelog.value)
|
},
|
||||||
|
innerHTML: changelogHtml
|
||||||
|
})
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,7 +109,7 @@ const showUpdateModal = () => {
|
|||||||
}
|
}
|
||||||
if (metadata.length > 0) {
|
if (metadata.length > 0) {
|
||||||
elements.push(
|
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(' · '))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
<!-- 当前版本信息 -->
|
<!-- 当前版本信息 -->
|
||||||
<a-card title="版本信息" :bordered="false">
|
<a-card title="版本信息" :bordered="false">
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="text" size="small" @click="$emit('open-version-history')">
|
||||||
|
<template #icon><icon-history /></template>
|
||||||
|
版本历史
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
@@ -24,7 +30,7 @@
|
|||||||
<div class="changelog-title">
|
<div class="changelog-title">
|
||||||
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="changelog">{{ updateInfo.changelog }}</div>
|
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" />
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
@@ -86,8 +92,8 @@
|
|||||||
:status="downloadStatus"
|
:status="downloadStatus"
|
||||||
/>
|
/>
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
|
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
||||||
<span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
|
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,11 +112,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { IconHistory } from '@arco-design/web-vue/es/icon'
|
||||||
import { useUpdateStore } from '../stores/update'
|
import { useUpdateStore } from '../stores/update'
|
||||||
|
import { marked } from '../utils/markedExtensions'
|
||||||
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
defineEmits(['open-version-history'])
|
||||||
|
|
||||||
// 使用更新管理 store
|
// 使用更新管理 store
|
||||||
const updateStore = useUpdateStore()
|
const updateStore = useUpdateStore()
|
||||||
@@ -124,13 +136,10 @@ const lastCheckTime = ref('-')
|
|||||||
const installResult = ref(null)
|
const installResult = ref(null)
|
||||||
const downloadedFile = ref(null)
|
const downloadedFile = ref(null)
|
||||||
|
|
||||||
// 工具函数
|
/** 渲染 changelog(Markdown → HTML) */
|
||||||
const formatFileSize = (bytes) => {
|
function renderChangelog(text: string): string {
|
||||||
return updateStore.formatFileSize(bytes)
|
if (!text) return ''
|
||||||
}
|
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text }
|
||||||
|
|
||||||
const formatSpeed = (bytesPerSecond) => {
|
|
||||||
return updateStore.formatSpeed(bytesPerSecond)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载当前版本
|
// 加载当前版本
|
||||||
@@ -217,7 +226,12 @@ const handleInstall = async () => {
|
|||||||
|
|
||||||
// 监听下载完成事件(本地覆盖:记录下载文件路径)
|
// 监听下载完成事件(本地覆盖:记录下载文件路径)
|
||||||
const onDownloadComplete = (event) => {
|
const onDownloadComplete = (event) => {
|
||||||
const data = typeof event === 'string' ? JSON.parse(event) : event
|
let data: any
|
||||||
|
try {
|
||||||
|
data = typeof event === 'string' ? JSON.parse(event) : event
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (data.success && data.file_path) {
|
if (data.success && data.file_path) {
|
||||||
downloadedFile.value = data.file_path
|
downloadedFile.value = data.file_path
|
||||||
@@ -268,29 +282,70 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.changelog-section {
|
.changelog-section {
|
||||||
margin-top: 16px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.changelog-title {
|
.changelog-title {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-2);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.changelog {
|
.changelog {
|
||||||
background: var(--color-fill-2);
|
background: var(--color-fill-1);
|
||||||
padding: 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: pre-wrap;
|
margin: 0;
|
||||||
margin: 8px 0;
|
max-height: 280px;
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.changelog :deep(h4) {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
margin: 8px 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(h4:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(ul) {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(li) {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(li::before) {
|
||||||
|
content: '·';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(code) {
|
||||||
|
background: var(--color-fill-3);
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(p) {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.download-progress {
|
.download-progress {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
@@ -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[]
|
tabs: TabConfig[]
|
||||||
visibleTabs: string[]
|
visibleTabs: string[]
|
||||||
defaultTab: string
|
defaultTab: string
|
||||||
@@ -44,8 +44,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
|
|
||||||
if (!tabs?.length) {
|
if (!tabs?.length) {
|
||||||
return [
|
return [
|
||||||
{ key: 'file-system', title: '文件管理' },
|
{ key: 'file-system', title: '文件管理' }
|
||||||
{ key: 'db-cli', title: '数据库' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +91,18 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
|
|
||||||
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data
|
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data
|
||||||
|
|
||||||
|
// 一级 Tab 只有文件管理和数据库,其他功能(Markdown、版本历史)不作为独立 Tab
|
||||||
|
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))
|
||||||
|
: allKeys
|
||||||
|
|
||||||
appConfig.value = {
|
appConfig.value = {
|
||||||
tabs: tabs.map(tab => ({ ...tab, visible: visibleTabs.includes(tab.key) })),
|
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
|
||||||
visibleTabs,
|
visibleTabs: mergedVisible,
|
||||||
defaultTab
|
defaultTab: defaultTab || 'file-system'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载配置失败:', error)
|
console.error('加载配置失败:', error)
|
||||||
@@ -111,11 +118,9 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
const useDefaultConfig = () => {
|
const useDefaultConfig = () => {
|
||||||
appConfig.value = {
|
appConfig.value = {
|
||||||
tabs: [
|
tabs: [
|
||||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
|
{ key: 'file-system', title: '文件管理', visible: true, enabled: true }
|
||||||
{ key: 'db-cli', title: '数据库', visible: true, enhanced: true },
|
|
||||||
{ key: 'markdown-editor', title: 'Markdown 编辑器', visible: true, enabled: true }
|
|
||||||
],
|
],
|
||||||
visibleTabs: ['file-system', 'db-cli', 'markdown-editor'],
|
visibleTabs: ['file-system'],
|
||||||
defaultTab: '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
|
sortBy: string
|
||||||
/** 排序方向 */
|
/** 排序方向 */
|
||||||
sortOrder: string
|
sortOrder: string
|
||||||
|
/** 搜索关键词 */
|
||||||
|
searchKeyword: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,6 +201,8 @@ export interface FileEditorPanelConfig {
|
|||||||
currentFileExtension: string
|
currentFileExtension: string
|
||||||
/** 是否为二进制文件 */
|
/** 是否为二进制文件 */
|
||||||
isBinaryFile: boolean
|
isBinaryFile: boolean
|
||||||
|
/** 文件修改时间(用于检测外部变更) */
|
||||||
|
fileMtime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
|||||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||||
export { oneDark } from '@codemirror/theme-one-dark'
|
export { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
|
||||||
|
// 查找替换
|
||||||
|
export { openSearchPanel, closeSearchPanel, search, searchKeymap, SearchQuery } from '@codemirror/search'
|
||||||
|
|
||||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const STORAGE_KEYS = {
|
|||||||
SORT: 'app-filesystem-sort', // 排序状态
|
SORT: 'app-filesystem-sort', // 排序状态
|
||||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||||
|
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设备测试模块
|
// 设备测试模块
|
||||||
@@ -154,6 +155,7 @@ export const FILE_ICONS = {
|
|||||||
RUBY: '💎',
|
RUBY: '💎',
|
||||||
DART: '🎯',
|
DART: '🎯',
|
||||||
DOCKERFILE: '🐳',
|
DOCKERFILE: '🐳',
|
||||||
|
VUE: '💚',
|
||||||
|
|
||||||
// 数据库
|
// 数据库
|
||||||
DATABASE: '🗄️',
|
DATABASE: '🗄️',
|
||||||
@@ -270,6 +272,8 @@ const initIconMap = () => {
|
|||||||
'dart': FILE_ICONS.DART,
|
'dart': FILE_ICONS.DART,
|
||||||
// Dockerfile
|
// Dockerfile
|
||||||
'dockerfile': FILE_ICONS.DOCKERFILE,
|
'dockerfile': FILE_ICONS.DOCKERFILE,
|
||||||
|
// Vue
|
||||||
|
'vue': FILE_ICONS.VUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
||||||
|
|||||||
@@ -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, ''')
|
.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 - 文件路径
|
* @param {string} path - 文件路径
|
||||||
|
|||||||
@@ -100,6 +100,80 @@ renderer.heading = function(token: any) {
|
|||||||
</h${depth}>`
|
</h${depth}>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 图片相对路径转换支持 ==========
|
||||||
|
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
||||||
|
let _currentFileDir: string = ''
|
||||||
|
// 文件服务器 Base URL(由调用方在渲染前设置)
|
||||||
|
let _fileServerBase: string = 'http://localhost:8073/localfs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
||||||
|
* @param dir 文件所在目录的绝对路径,如 "D:/docs" 或 "/"(根目录)
|
||||||
|
*/
|
||||||
|
export function setCurrentFileDir(dir: string): void {
|
||||||
|
_currentFileDir = dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前设置的文件目录 */
|
||||||
|
export function getCurrentFileDir(): string {
|
||||||
|
return _currentFileDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
||||||
|
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||||
|
*/
|
||||||
|
export function setFileServerBase(base: string): void {
|
||||||
|
_fileServerBase = base
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将相对路径图片 src 解析为文件服务器 URL
|
||||||
|
* - 绝对路径(Windows: D:/...、Unix: /usr/...)、网络URL、data URI → 不转换
|
||||||
|
* - 相对路径 → 基于当前文件目录解析为绝对路径,再编码为文件服务器 URL
|
||||||
|
*/
|
||||||
|
function resolveImageUrl(src: string, fileServerBase: string): string {
|
||||||
|
if (!src) return src
|
||||||
|
// 不转换:绝对路径(Windows 盘符)、网络协议、锚点、data URI
|
||||||
|
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
|
||||||
|
|
||||||
|
// 解析相对路径(处理 ../ 和 ./)
|
||||||
|
const dir = _currentFileDir || '/'
|
||||||
|
const sep = dir.includes('\\') ? '\\' : '/'
|
||||||
|
let resolved = normalizeRelativePath(dir, src, sep)
|
||||||
|
|
||||||
|
// 编码路径(保留 / 分隔符)
|
||||||
|
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
|
||||||
|
return `${fileServerBase}/${encoded}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化相对路径,处理 .. 和 . 段
|
||||||
|
*/
|
||||||
|
function normalizeRelativePath(base: string, relative: string, sep: string): string {
|
||||||
|
// 确保基础路径不以分隔符结尾
|
||||||
|
let baseNormalized = base.replace(/[\\/]+$/, '')
|
||||||
|
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
|
||||||
|
|
||||||
|
const baseParts = baseNormalized.split(sep).filter(Boolean)
|
||||||
|
const relParts = relative.split(/[\\/]/).filter(Boolean)
|
||||||
|
|
||||||
|
for (const part of relParts) {
|
||||||
|
if (part === '..') {
|
||||||
|
baseParts.pop() // 向上一级
|
||||||
|
} else if (part !== '.') {
|
||||||
|
baseParts.push(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重建路径:Windows 绝对路径保留盘符前缀
|
||||||
|
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
|
||||||
|
return baseParts.join(sep)
|
||||||
|
}
|
||||||
|
// Unix 风格:以 / 开头
|
||||||
|
return sep + baseParts.join(sep)
|
||||||
|
}
|
||||||
|
|
||||||
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
||||||
const isLocalFileLink = (href: string): boolean => {
|
const isLocalFileLink = (href: string): boolean => {
|
||||||
if (!href) return false
|
if (!href) return false
|
||||||
@@ -108,6 +182,23 @@ const isLocalFileLink = (href: string): boolean => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义图片渲染器 - 转换相对路径为文件服务器 URL
|
||||||
|
renderer.image = function(token: any) {
|
||||||
|
const src = token.href || ''
|
||||||
|
const title = token.title || ''
|
||||||
|
const alt = token.text || ''
|
||||||
|
const titleAttr = title ? ` title="${title}"` : ''
|
||||||
|
|
||||||
|
// 判断是否需要转换(仅处理相对路径,且当前目录已设置)
|
||||||
|
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
|
||||||
|
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
|
||||||
|
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认渲染(绝对路径 / 网络 URL / data URI / 未设置目录时原样输出)
|
||||||
|
return `<img src="${src}" alt="${alt}"${titleAttr}>`
|
||||||
|
}
|
||||||
|
|
||||||
// 自定义链接渲染器 - 支持本地文件链接
|
// 自定义链接渲染器 - 支持本地文件链接
|
||||||
renderer.link = function(token: any) {
|
renderer.link = function(token: any) {
|
||||||
const href = token.href || ''
|
const href = token.href || ''
|
||||||
@@ -126,7 +217,7 @@ renderer.link = function(token: any) {
|
|||||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
marked.use({ renderer, breaks: true, gfm: true })
|
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||||
|
|
||||||
export { marked }
|
export { marked }
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||