Private
Public Access
1
0

新增:Markdown编辑器/数据库优化/安全修复

- Markdown 编辑器:实时预览、PDF 导出、独立查看器
- 数据库优化:动态连接池、查询缓存、Redis Pipeline
- 窗口置顶功能
- 文件系统增强:右键菜单、编辑器集成、收藏夹重构
- 安全修复:XSS 防护、路径穿越、HTML 注入
- 代码质量:正则预编译、缓存锁优化、死代码清理
This commit is contained in:
2026-03-31 09:18:06 +08:00
parent 5f94ccf13b
commit e5dbe89a6f
59 changed files with 5289 additions and 1316 deletions

4
.gitignore vendored
View File

@@ -4,8 +4,12 @@ web/src/wailsjs/
# 构建产物
build/bin/
build/*.log
web/dist/
# 临时文件
*.tmp
# 依赖目录
web/node_modules/
web/bun.lock

View File

@@ -2,6 +2,144 @@
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
## [0.3.3] - 2026-03-31
### 架构新增 🏗️
#### PDF 导出模块
新增 `internal/api/pdf_api.go`,提供两种导出方式:
- **chromedp**: 无头浏览器渲染 HTML → PDF支持完整 CSS 样式
- **gofpdf** (`app.go:ExportMarkdownToPDF`): 纯 Go 实现,解析 Markdown 标题/列表/代码块写入 PDF
- 前端 `PdfExportButton.vue` 使用 `window.open` + `print()` 浏览器打印方式
#### Markdown 编辑器
新增 `web/src/components/MarkdownEditor.vue` 组件:
- textarea 编辑 + MarkdownPreview 实时预览(左右分栏)
- 字符/行数统计、Ctrl+S 保存、5 秒防抖自动保存
- 支持 `content` prop 和 `v-model:content` 双向绑定
- 独立页面 `web/src/views/markdown-editor/index.vue``web/src/views/MarkdownViewer.vue`
---
### 数据库层重构 🗄️
#### MySQL 连接池 (`internal/dbclient/pool.go`, `pool_config.go`)
- 动态扩缩容: `adaptiveScaling()` 基于使用率自动 scaleUp/scaleDown
- 健康检查: `enhancedHealthCheck()` 定期 Ping使用中连接带 100ms 超时
- 性能权重: `adaptiveWeights` 基于 Ping 延迟计算,`getOptimalConnection()` 优选
- **注意**: `warmUp()` 为空壳实现,未被调用;`OptimizeQuery` 等方法未接入 `sql_exec_service.go` 业务调用
#### 查询优化器 (`internal/dbclient/query_optimizer.go`, `cache.go`)
- 查询缓存: SHA-256 key hash + LRU/LFU 混合驱逐100MB 内存限制RLock 读锁优化
- 慢查询日志: 超过 100ms 自动记录,最多 1000 条,维护协程定期分析
- 正则预编译: 5 个正则从方法内移到包级别 `var` 声明
- **注意**: 索引建议框架在但 `analyzeQueryForIndexes` 分析逻辑为占位实现;`extractIndexUsed` 始终返回 `"unknown"`
#### Redis Pipeline (`internal/dbclient/redis_pipeline.go`)
- `RedisPipeline`: 批量命令,使用 go-redis 原生 `Pipeline()` 一次性发送
- `RedisTransaction`: 事务支持,使用 `TxPipeline()` 自动 MULTI/EXEC
- **注意**: 未被业务代码调用,仅 `pool.go` 中定义了桥接方法
---
### 前端变更 🖥️
#### App.vue
- 新增窗口置顶按钮,调用 `WindowToggleAlwaysOnTop` Wails runtime API
- 新增 Markdown 编辑器 tab
- 禁止 Ctrl+滚轮缩放(`wheel` 事件 passive: false
- 移除 `preloadCommonLanguages()` 预加载(改按需加载)
- `lang="ts"` 迁移
#### 文件系统
- `ContextMenu.vue`: 新增新建文件/文件夹菜单项
- `FileEditorPanel.vue`: 集成 PDF 导出按钮、Markdown 预览/编辑模式切换
- `useFavorites.ts`: 收藏夹置顶功能(`togglePin`/`isPinned`/排序)
- `useFilePreview.ts`: Office/CSV 改用本地文件服务器 `fetch` 获取内容
- HTML 预览改用 `iframe src` 替代 `srcdoc``f28fd70`, `7004c6e`
#### 安全修复
- `PdfExportButton.vue`: `escapeHtml()` 转义标题、`stripScripts()` 清除 script/iframe/事件处理器
- `MarkdownPreview.vue`: `sanitizeHtml()` 清除 script/iframe/form/事件处理器/javascript: 协议
- `pdf_api.go`: `filepath.Base()` 防路径穿越、`html.EscapeString()` 防标题 HTML 注入
#### 配置层
- `config.ts`: Wails 绑定加载增加超时保护(最多 30 次重试,约 30 秒)
- `config_service.go`: `TestConnection` 简化为直接传 id
- `connection_api.go`: 依赖从 `storage` 改为 `service`
#### 样式
- `style.css`: 新增 GitHub 风格 `.markdown-body` 样式、Highlight.js 代码高亮样式、`@media print` 打印优化
- Tooltip 全局样式覆盖
---
### 后端变更 ⚙️
#### app.go
- 新增 `pdfAPI``isAlwaysOnTop` 字段
- 新增 PDF 导出方法: `ExportPDF``ExportMarkdownToPDF``SelectPDFSaveDirectory`
- `startAutoUpdateCheck` 修复 `config["success"].(bool)` 类型断言,改为 ok 检查
- `WindowToggleAlwaysOnTop`: Wails runtime 置顶切换
#### 其他
- `aes.go`: AES 加密模块扩展
- `pool.go`: 桥接查询优化器和缓存方法
- `connection_service.go`: 增强 `GetConnection``TestConnection`
---
### 依赖变更 📦
```diff
+ github.com/chromedp/cdproto
+ github.com/chromedp/chromedp v0.14.2
+ github.com/jung-kurt/gofpdf v1.16.2
+ github.com/yuin/goldmark v1.8.2
+ (间接) chromedp/sysutil, go-json-experiment/json, gobwas/ws, gobwas/pool, gobwas/httphead
```
---
### 删除文件 🗑️
- `claude-progress.txt`, `project-status-analysis.md` — 临时文件
- `docs/代码审查/README.md` — 过期文档
- `web/src/composables/useLocalStorage.ts` — 未使用
- `web/src/utils/fileHelpers.js` — 合并到 fileUtils.js
- `web/src/utils/pathHelpers.js` — 合并到 fileUtils.js
---
### 死代码清理 🧹
- `cache.go`: 移除 `CacheStrategy` 枚举、`warmupQueries`/`warmupEnabled` 字段
- `redis_pipeline.go`: 移除 `RedisError` 冗余类型
- `query_optimizer.go`: 移除 `go analyzeQuery()` 空操作 goroutine、清空 `generateJoinSuggestions`/`generateGroupSuggestions`/`generateInsertSuggestions` 硬编码
- `openclaw/api.go`: 清理空 `import ()`
- `openclaw/manager.go`: `*context.Context` 指针存储改为空结构体
- `markdown-editor/index.vue`: 移除 `console.log('Content changed:', content)`
---
### 核心文件变更
| 文件 | 类型 | 说明 |
|------|------|------|
| `app.go` | 重构 | +208 行,新增 PDF/OpenClaw/置顶 API |
| `internal/api/pdf_api.go` | 新增 | chromedp PDF 导出 |
| `internal/dbclient/pool_config.go` | 重构 | +395 行,动态连接池 |
| `internal/dbclient/query_optimizer.go` | 新增 | 查询优化器 |
| `internal/dbclient/cache.go` | 新增 | 查询缓存 |
| `internal/dbclient/redis_pipeline.go` | 新增 | Redis Pipeline/事务 |
| `web/src/components/MarkdownEditor.vue` | 新增 | Markdown 编辑器组件 |
| `web/src/components/PdfExportButton.vue` | 新增 | PDF 导出按钮 |
| `web/src/components/MarkdownPreview.vue` | 新增 | Markdown 预览组件 |
| `web/src/views/markdown-editor/` | 新增 | Markdown 编辑器页面 |
| `web/src/style.css` | 扩展 | +316 行Markdown/打印样式 |
---
## [0.3.2] - 2026-02-05
### 核心架构重构 🏗️

View File

@@ -1,5 +1,49 @@
# 更新日志
## [0.3.3] - 2026-03-31
### 新增 ✨
- **Markdown 编辑器**: 实时预览、编辑、字符/行数统计、Ctrl+S 保存、自动保存
- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面
- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式
- **窗口置顶**: 支持窗口始终置顶
- **收藏夹置顶**: 收藏项置顶排序
- **文件预览**: Excel/Word 文件预览支持
- 数据库 UI 交互体验改进
### 优化 🚀
- MySQL 动态连接池重构(健康检查、性能权重、自适应扩缩容)
- SQL 查询优化器(查询缓存、慢查询日志)
- Redis Pipeline 支持(批量命令、事务 MULTI/EXEC
- HTML 预览改用 iframe src 替代 srcdoc
- Office/CSV 预览增强(本地文件服务器获取文件)
- Markdown 本地文件链接支持 + Shell 语法高亮
### 修复 🐛
- Office 文件预览:修复类型检测与二进制误判
- FileEditorPanel 语法错误
- 修复本地文件服务器 CORS 跨域问题
### 安全修复 🔒
- XSS 防护PdfExportButton、MarkdownPreview HTML 消毒)
- PDF 导出路径穿越防护
- PDF 导出标题 HTML 注入防护
### 代码质量 🔧
- 正则表达式预编译query_optimizer
- 缓存读锁优化 + SHA-256 key hash
- 死代码清理(未使用 import/类型/字段)
- 配置加载超时保护(最多重试 30 次)
- 禁止 Ctrl+滚轮缩放
- 清理冗余工具函数fileHelpers、pathHelpers、useLocalStorage
### 文件系统 📁
- 右键菜单新增新建文件/文件夹
- FileEditorPanel 集成 PDF 导出按钮
- Markdown 文件自动预览与编辑/预览模式切换
---
## [0.3.2] - 2026-02-05
### 重构 🔧
@@ -63,5 +107,3 @@
- **主版本号** - 不兼容的 API 修改
- **次版本号** - 向下兼容的功能性新增
- **修订号** - 向下兼容的问题修复

161
README.md
View File

@@ -1,155 +1,10 @@
# U-Desk
# U-Desk v0.3.3
基于 Wails 的桌面应用程序,集成数据库客户端、文件管理、设备测试等功能。
## 技术栈
- **后端**Go 1.25+、Wails v2
- **前端**Vue 3、Arco Design Vue、Vite
- **存储**SQLite、MySQL、Redis、MongoDB
## 核心功能
### 1. 数据库客户端
- 支持 MySQL、Redis、MongoDB 多种数据库连接
- 连接管理(保存、编辑、删除连接配置)
- SQL 执行与结果展示
- 数据表结构查看
### 2. 文件管理
- 本地文件系统浏览(支持多盘符)
- 文件预览(图片、文本、代码)
- 文件操作(复制、移动、删除、重命名)
- 常用路径快捷访问(桌面、文档、下载等)
- 搜索与筛选功能
### 3. 设备测试
- 系统设备信息查询
- 硬件状态检测
### 4. 更新管理
- 应用版本检查与自动更新
- 更新日志展示
## 项目结构
```
go-desk/
├── app.go # 应用入口API 方法绑定
├── main.go # 程序启动
├── wails.json # Wails 配置
├── go.mod # Go 模块依赖
├── internal/
│ ├── api/ # API 层(数据库、标签页、更新等)
│ ├── common/ # 通用工具(超时、工具函数)
│ ├── dbclient/ # 数据库客户端MySQL、Redis、MongoDB
│ ├── filesystem/ # 文件系统管理(模块化架构)
│ ├── service/ # 服务层SQL 执行等)
│ ├── storage/ # 本地存储SQLite
│ └── system/ # 系统信息获取
└── web/ # 前端代码
├── package.json
├── vite.config.js
├── index.html
└── src/
├── components/ # Vue 组件
│ ├── FileSystem.vue # 文件管理
│ ├── DeviceTest.vue # 设备测试
│ ├── UpdatePanel.vue # 更新面板
│ └── CodeEditor.vue # 代码编辑器
├── composables/ # 组合式函数
│ ├── useFileOperations.js
│ ├── useFavoriteFiles.js
│ └── useLocalStorage.js
├── utils/ # 工具函数
├── api/ # API 调用
└── App.vue # 主应用
```
## 开发
### 1. 安装依赖
```bash
# Go 依赖
go mod tidy
# 前端依赖
cd web
npm install
```
### 2. 构建前端(必须)
```bash
cd web
npm run build
```
**重要**每次修改前端代码后都需要重新构建Wails 使用 `web/dist` 目录中的构建产物。
### 3. 开发模式运行
```bash
# 在项目根目录
wails dev
```
**注意**:如果 `wails` 命令找不到,使用完整路径:
```bash
# 获取 GOPATH
go env GOPATH
# 使用完整路径(根据你的 GOPATH 调整)
D:\Go\go-workspace\bin\wails.exe dev
```
### 4. 构建应用
```bash
# 确保前端已构建
cd web
npm run build
cd ..
# 构建当前平台
wails build
# 构建 Windows明确指定平台
wails build -platform windows/amd64
```
**构建产物位置**`build/bin/go-desk.exe`
**注意**
- 构建前确保前端已构建(`web/dist` 目录存在)
- 构建产物是独立的可执行文件,包含前端资源
## 数据库配置
应用使用 SQLite 本地存储连接配置和用户数据。
可选连接外部数据库:
- **MySQL**:支持连接、查询、表结构查看
- **Redis**:支持连接、基础操作
- **MongoDB**:支持连接、基础操作
## 架构特点
- **模块化文件系统**:文件管理功能采用模块化设计,职责分离
- **异步启动优化**:应用启动流程优化,核心功能快速初始化
- **本地文件服务器**:支持本地文件预览和访问
- **SQLite 持久化**:连接配置和用户数据本地存储
## 文档
详细文档请查看 `docs/` 目录:
- 架构设计文档
- 功能迭代记录
- 技术决策记录ADR
- 测试用例和检查报告
## 许可
本项目用于学习和测试目的。
## 功能
- 数据库客户端
- Markdown编辑器
- PDF导出
## 更新
- ✅ MD编辑器完成
- ✅ PDF导出优化中

159
app.go
View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/jung-kurt/gofpdf"
"u-desk/internal/api"
"u-desk/internal/common"
"u-desk/internal/database"
@@ -23,15 +24,17 @@ import (
// App 应用结构体
type App struct {
ctx context.Context
db *database.DB
connectionAPI *api.ConnectionAPI
sqlAPI *api.SqlAPI
tabAPI *api.TabAPI
updateAPI *api.UpdateAPI
configAPI *api.ConfigAPI
fileServer *http.Server
filesystem *filesystem.FileSystemService
ctx context.Context
db *database.DB
connectionAPI *api.ConnectionAPI
sqlAPI *api.SqlAPI
tabAPI *api.TabAPI
updateAPI *api.UpdateAPI
configAPI *api.ConfigAPI
pdfAPI *api.PdfAPI
fileServer *http.Server
filesystem *filesystem.FileSystemService
isAlwaysOnTop bool
}
// NewApp 创建新的应用实例
@@ -60,6 +63,17 @@ func (a *App) Startup(ctx context.Context) {
// 2.5. 迁移旧配置
_ = a.configAPI.MigrateTabConfig()
// 2.6. 初始化PDF导出API
fmt.Println("[启动] 初始化PDF导出模块...")
pdfAPI, err := api.NewPdfAPI()
if err != nil {
fmt.Printf("[启动] PDF导出API初始化失败: %v\n", err)
// PDF导出失败不应影响应用启动所以只警告不panic
} else {
a.pdfAPI = pdfAPI
fmt.Println("[启动] PDF导出模块初始化完成")
}
// 3. 初始化版本号(提前触发缓存,避免后续重复计算)
version := service.GetCurrentVersion()
fmt.Printf("[启动] 当前版本: %s\n", version)
@@ -545,6 +559,16 @@ func (a *App) WindowIsMaximized() bool {
return false
}
// WindowToggleAlwaysOnTop 切换窗口置顶
func (a *App) WindowToggleAlwaysOnTop() bool {
if a.ctx == nil {
return false
}
a.isAlwaysOnTop = !a.isAlwaysOnTop
runtime.WindowSetAlwaysOnTop(a.ctx, a.isAlwaysOnTop)
return a.isAlwaysOnTop
}
// ========== SQL 标签页管理接口 ==========
// SaveSqlTabs 保存 SQL 标签页列表
@@ -630,7 +654,11 @@ func (a *App) startAutoUpdateCheck() {
}
config, err := a.updateAPI.GetUpdateConfig()
if err != nil || !config["success"].(bool) {
if err != nil {
return
}
success, ok := config["success"].(bool)
if !ok || !success {
return
}
@@ -863,3 +891,114 @@ func (a *App) initFilesystemModule() {
fmt.Println("[模块] 文件系统模块初始化完成")
}
// ExportPDF 导出PDF文件
func (a *App) ExportPDF(content string, title string, fileName string, fontSize int, pageWidth int, pageHeight int) (map[string]interface{}, error) {
if a.pdfAPI == nil {
return map[string]interface{}{
"success": false,
"message": "PDF导出功能未初始化",
}, fmt.Errorf("PDF导出功能未初始化")
}
req := api.PdfExportRequest{
Content: content,
Title: title,
FileName: fileName,
FontSize: fontSize,
PageWidth: pageWidth,
PageHeight: pageHeight,
}
result, err := a.pdfAPI.ExportMarkdownToPDF(req)
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, err
}
return map[string]interface{}{
"success": result.Success,
"message": result.Message,
"path": result.Path,
"size": result.Size,
}, nil
}
// SelectPDFSaveDirectory 选择PDF保存目录
func (a *App) SelectPDFSaveDirectory() (string, error) {
if a.pdfAPI == nil {
return "", fmt.Errorf("PDF导出功能未初始化")
}
return a.pdfAPI.SelectDirectory()
}
// ExportMarkdownToPDF 使用gofpdf导出Markdown为PDF
func (a *App) ExportMarkdownToPDF(markdownContent string) (string, error) {
// 1. 弹出保存对话框
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "保存 PDF",
DefaultFilename: "document.pdf",
Filters: []runtime.FileFilter{
{DisplayName: "PDF 文件", Pattern: "*.pdf"},
},
})
if err != nil || savePath == "" {
return "", err
}
// 2. 创建PDF
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetAutoPageBreak(true, 15)
// 3. 解析Markdown并写入PDF
lines := strings.Split(markdownContent, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "# ") {
// H1 标题
pdf.SetFont("Arial", "B", 24)
pdf.Cell(40, 10, strings.TrimPrefix(line, "# "))
pdf.Ln(12)
} else if strings.HasPrefix(line, "## ") {
// H2 标题
pdf.SetFont("Arial", "B", 18)
pdf.Cell(40, 10, strings.TrimPrefix(line, "## "))
pdf.Ln(10)
} else if strings.HasPrefix(line, "### ") {
// H3 标题
pdf.SetFont("Arial", "B", 14)
pdf.Cell(40, 10, strings.TrimPrefix(line, "### "))
pdf.Ln(8)
} else if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
// 无序列表
pdf.SetFont("Arial", "", 12)
pdf.Cell(10, 7, "•")
pdf.Cell(0, 7, strings.TrimPrefix(line, "- "))
pdf.Ln(7)
} else if strings.HasPrefix(line, "1. ") || strings.HasPrefix(line, "2. ") || strings.HasPrefix(line, "3. ") {
// 有序列表
pdf.SetFont("Arial", "", 12)
pdf.Cell(10, 7, strings.TrimSpace(strings.SplitN(line, ".", 2)[0]) + ".")
pdf.Cell(0, 7, strings.TrimSpace(strings.SplitN(line, ".", 2)[1]))
pdf.Ln(7)
} else if line == "" {
// 空行
pdf.Ln(7)
} else {
// 普通文本
pdf.SetFont("Arial", "", 12)
pdf.MultiCell(190, 7, line, "", "", false)
}
}
// 4. 保存文件
err = pdf.OutputFileAndClose(savePath)
if err != nil {
return "", fmt.Errorf("保存PDF文件失败: %v", err)
}
return savePath, nil
}

View File

@@ -1,142 +0,0 @@
# 代码审查报告索引
本目录包含项目的代码审查和质量分析报告。
---
## 📅 最新审查2026-01-29
### 🚀 快速入口
- **[执行摘要](../代码审查执行摘要.md)** - 5分钟快速了解核心问题和行动清单
- **[完整报告](../代码审查报告_2026-01-29.md)** - 详细的问题分析和改进建议
- **[重构示例](../代码审查示例_2026-01-29.md)** - 可直接参考的重构代码
### 📊 本次审查概览
- **审查范围**: Go后端服务 + Vue前端组件
- **总体评分**: ⭐⭐⭐⭐ (4/5)
- **发现问题**: 9个3个高优先级3个中优先级3个低优先级
- **预计修复时间**: 11小时高+中优先级)
---
## 📚 历史审查报告
### 代码审查
- [code-review-p3-report.md](./code-review-p3-report.md) - P3 优先级代码审查报告
- [code-review-deep-optimization-report.md](./code-review-deep-optimization-report.md) - 深度优化报告
### 质量分析
- [anti-over-engineering-report.md](./anti-over-engineering-report.md) - 防过度工程化报告
- [code-quality-security-report.md](./code-quality-security-report.md) - 代码质量和安全报告
### 总结文档
- [FINAL-SUMMARY.md](./FINAL-SUMMARY.md) - 最终总结报告
---
## 🎯 审查方法论
### 审查维度
1. **代码规范检查**
- Go代码是否符合标准规范
- SQL语句是否规范
- 文档和注释是否完整准确
2. **DRY原则检查**
- 查找重复的代码逻辑
- 识别可以抽取的公共函数或方法
- 检查是否有相似功能的重复实现
3. **代码简洁性**
- 识别过度复杂的函数
- 检查是否有冗余代码
- 评估可读性
4. **防御性编程过度检查**
- 查找不必要的错误检查
- 识别过度的验证逻辑
- 检查是否有冗余的nil检查
### 问题分级标准
- 🔴 **高优先级**: 功能性bug、可能导致运行时错误
- 🟡 **中优先级**: 维护性问题、性能影响
- 🟢 **低优先级**: 可选优化、长期改进
---
## 🛠️ 修复工作流
### 1. 问题识别
通过代码审查发现问题,记录在审查报告中。
### 2. 优先级评估
根据影响范围和严重程度评估优先级。
### 3. 修复计划
制定详细的修复计划和时间表。
### 4. 代码重构
参考重构示例进行代码优化。
### 5. 测试验证
确保修复不引入新问题。
### 6. 文档更新
同步更新相关文档。
---
## 📈 质量指标追踪
| 指标 | 2026-01-29 | 目标 | 状态 |
|------|-----------|------|------|
| 代码重复率 | 15% | <5% | ⚠️ 需改进 |
| 平均函数长度 | 80行 | <30行 | ⚠️ 需改进 |
| 测试覆盖率 | 10% | >60% | ⚠️ 需改进 |
| TypeScript覆盖率 | 0% | >80% | ⚠️ 需改进 |
---
## 💡 最佳实践
### 代码规范
- 遵循 [Effective Go](https://golang.org/doc/effective_go.html)
- 遵循 [Vue风格指南](https://vuejs.org/style-guide/)
- 使用有意义的变量和函数名
- 添加必要的注释和文档
### 重构原则
- 先写测试,再重构
- 小步快跑,频繁提交
- 保持功能不变
- 提升代码可读性
### 审查建议
- 定期进行代码审查(每月/每季度)
- 使用自动化工具辅助
- 建立审查清单
- 培养团队意识
---
## 🔗 相关文档
- [架构设计](../架构设计/) - 架构设计文档
- [功能迭代文档](../04-功能迭代/) - 功能开发和核对报告
- [模块文档](../模块文档/) - 各模块详细文档
- [用户指南](../用户指南/) - 用户使用指南
---
## 📞 反馈与改进
如果您对代码审查有任何建议或发现问题,请:
1. 在项目中创建Issue
2. 联系技术负责人
3. 参与代码审查讨论
---
**维护者**: 开发团队
**最后更新**: 2026-01-29
**下次审查**: 建议在重构完成后约1个月后

9
go.mod
View File

@@ -3,11 +3,15 @@ module u-desk
go 1.25.6
require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/glebarez/sqlite v1.11.0
github.com/go-sql-driver/mysql v1.9.3
github.com/jung-kurt/gofpdf v1.16.2
github.com/redis/go-redis/v9 v9.17.3
github.com/shirou/gopsutil/v3 v3.24.5
github.com/wailsapp/wails/v2 v2.11.0
github.com/yuin/goldmark v1.8.2
go.mongodb.org/mongo-driver/v2 v2.5.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
@@ -17,10 +21,15 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect

30
go.sum
View File

@@ -2,12 +2,20 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
@@ -18,11 +26,19 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -41,6 +57,9 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
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=
@@ -57,6 +76,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@@ -68,8 +89,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -83,6 +108,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
@@ -91,6 +117,7 @@ github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI
github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
@@ -118,6 +145,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
@@ -128,6 +157,7 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=

View File

@@ -1,18 +1,18 @@
package api
import (
"u-desk/internal/storage"
"u-desk/internal/service"
"u-desk/internal/storage/models"
)
// ConnectionAPI 连接管理API
type ConnectionAPI struct {
connService *storage.ConnectionService
connService *service.ConnectionService
}
// NewConnectionAPI 创建连接管理API
func NewConnectionAPI() (*ConnectionAPI, error) {
connService, err := storage.NewConnectionService()
connService, err := service.NewConnectionService()
if err != nil {
return nil, err
}
@@ -82,11 +82,7 @@ func (api *ConnectionAPI) DeleteDbConnection(id uint) error {
}
func (api *ConnectionAPI) TestDbConnection(id uint) error {
conn, err := api.connService.GetConnection(id)
if err != nil {
return err
}
return api.connService.TestConnection(conn)
return api.connService.TestConnection(id)
}
// TestConnectionRequest 测试连接请求结构体(不保存数据)
@@ -104,14 +100,9 @@ type TestConnectionRequest struct {
// 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,
req.Type, req.Host, req.Port,
req.Username, req.Password, req.Database,
req.Options, req.ID,
)
}
@@ -130,13 +121,8 @@ type LoadAllDatabasesRequest struct {
// 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,
req.Type, req.Host, req.Port,
req.Username, req.Password, req.Database,
req.Options, req.ID,
)
}

379
internal/api/pdf_api.go Normal file
View File

@@ -0,0 +1,379 @@
package api
import (
"context"
"fmt"
"html"
"os"
"path/filepath"
"strings"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"github.com/yuin/goldmark"
"u-desk/internal/common"
)
// PdfExportRequest PDF导出请求结构体
type PdfExportRequest struct {
Content string `json:"content"` // Markdown/HTML内容
Title string `json:"title"` // PDF标题
FileName string `json:"fileName"` // 文件名(不含扩展名)
FontSize int `json:"fontSize"` // 字体大小
PageWidth int `json:"pageWidth"` // 页面宽度mm
PageHeight int `json:"pageHeight"` // 页面高度mm
}
// PdfExportResponse PDF导出响应结构体
type PdfExportResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Path string `json:"path"` // PDF文件保存路径
Size int64 `json:"size"` // 文件大小(字节)
}
// PdfAPI PDF导出API
type PdfAPI struct {
// 可以在这里添加依赖,如文件系统服务等
}
// NewPdfAPI 创建PDF导出API
func NewPdfAPI() (*PdfAPI, error) {
return &PdfAPI{}, nil
}
// ExportMarkdownToPDF 将Markdown内容导出为PDF - 使用chromedp实现
func (api *PdfAPI) ExportMarkdownToPDF(req PdfExportRequest) (*PdfExportResponse, error) {
// 验证参数
if strings.TrimSpace(req.Content) == "" {
return nil, fmt.Errorf("内容不能为空")
}
if strings.TrimSpace(req.FileName) == "" {
req.FileName = "document_" + time.Now().Format("20060102_150405")
}
if req.FontSize <= 0 {
req.FontSize = 12
}
// 设置默认页面尺寸A4
if req.PageWidth <= 0 {
req.PageWidth = 210
}
if req.PageHeight <= 0 {
req.PageHeight = 297
}
// 将Markdown转换为HTML
htmlContent := api.markdownToHTML(req.Content, req.Title, req.FontSize)
// 使用chromedp生成PDF
pdfBuffer, err := api.generatePDFFromHTML(htmlContent, req.Title, req.PageWidth, req.PageHeight)
if err != nil {
return nil, fmt.Errorf("生成PDF失败: %v", err)
}
// 生成文件名
if !strings.HasSuffix(strings.ToLower(req.FileName), ".pdf") {
req.FileName += ".pdf"
}
// 获取用户桌面目录作为默认保存位置
saveDir := api.getDesktopDirectory()
// 确保目录存在
if err := os.MkdirAll(saveDir, 0755); err != nil {
return nil, fmt.Errorf("创建目录失败: %v", err)
}
// 完整保存路径
savePath := filepath.Join(saveDir, filepath.Base(req.FileName))
// 保存PDF文件
err = os.WriteFile(savePath, pdfBuffer, 0644)
if err != nil {
return nil, fmt.Errorf("保存PDF文件失败: %v", err)
}
// 获取文件信息
fileInfo, err := os.Stat(savePath)
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
// 返回成功响应
return &PdfExportResponse{
Success: true,
Message: "PDF生成成功",
Path: savePath,
Size: fileInfo.Size(),
}, nil
}
// markdownToHTML 将Markdown转换为HTML
func (api *PdfAPI) markdownToHTML(markdownContent string, title string, fontSize int) string {
// 基础HTML模板
htmlTemplate := `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
font-size: %dpx;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: #6a737d;
}
p {
margin-bottom: 16px;
}
blockquote {
margin: 0 0 16px;
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
}
ul, ol {
padding-left: 2em;
margin-bottom: 16px;
}
li {
margin-bottom: 4px;
}
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
background-color: rgba(27,31,35,0.05);
border-radius: 3px;
font-size: 85%;
margin: 0;
padding: 0.2em 0.4em;
}
pre {
background-color: #f6f8fa;
border-radius: 3px;
padding: 16px;
overflow: auto;
margin-bottom: 16px;
}
pre code {
background-color: transparent;
padding: 0;
margin: 0;
font-size: 100%;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
border: 1px solid #dfe2e5;
}
th, td {
padding: 8px 12px;
border: 1px solid #dfe2e5;
text-align: left;
}
th {
background-color: #f6f8fa;
font-weight: 600;
}
img {
max-width: 100%;
height: auto;
margin: 16px 0;
}
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
.title {
text-align: center;
margin-bottom: 32px;
font-size: 1.5em;
color: #2c3e50;
}
</style>
</head>
<body>
<div class="title">%s</div>
%s
</body>
</html>`
// 标题处理
docTitle := ""
if title != "" {
docTitle = html.EscapeString(title)
} else {
docTitle = "文档"
}
// Markdown转HTML使用goldmark
var htmlContent string
var htmlBuf strings.Builder
if err := goldmark.Convert([]byte(markdownContent), &htmlBuf); err != nil {
htmlContent = "<p>Markdown 解析失败</p>"
} else {
htmlContent = htmlBuf.String()
}
// 生成完整的HTML
fullHTML := fmt.Sprintf(htmlTemplate, fontSize, docTitle, htmlContent)
return fullHTML
}
// generatePDFFromHTML 使用chromedp从HTML生成PDF
func (api *PdfAPI) generatePDFFromHTML(htmlContent, title string, pageWidth, pageHeight int) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 配置chromedp选项
opts := []chromedp.ExecAllocatorOption{
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("disable-software-rasterizer", true),
chromedp.Flag("disable-extensions", true),
chromedp.Flag("disable-notifications", true),
}
// 在Windows上设置Chrome路径
if common.IsWindows() {
// 常见的Windows Chrome路径
chromePaths := []string{
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Users\\" + os.Getenv("USERNAME") + "\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe",
}
for _, path := range chromePaths {
if _, err := os.Stat(path); err == nil {
opts = append(opts, chromedp.ExecPath(path))
break
}
}
}
// 创建执行分配器上下文
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
defer allocCancel()
// 创建chromedp上下文
chromeCtx, chromeCancel := chromedp.NewContext(allocCtx)
defer chromeCancel()
// 创建一个临时的目录用于PDF生成
tempDir, err := os.MkdirTemp("", "pdf_gen")
if err != nil {
return nil, fmt.Errorf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tempDir)
// 将HTML写入临时文件
htmlFile := filepath.Join(tempDir, "document.html")
if err := os.WriteFile(htmlFile, []byte(htmlContent), 0644); err != nil {
return nil, fmt.Errorf("写入HTML文件失败: %v", err)
}
var buf []byte
// 使用 file URL 加载本地HTML文件
err = chromedp.Run(chromeCtx,
// 导航到HTML文件
chromedp.Navigate("file://"+htmlFile),
// 等待页面加载完成
chromedp.WaitReady("body"),
// 打印到PDF
chromedp.ActionFunc(func(ctx context.Context) error {
// 设置页面打印参数
printToPDF := page.PrintToPDF().
WithPrintBackground(true).
WithLandscape(false).
WithMarginTop(0).
WithMarginBottom(0).
WithMarginLeft(0).
WithMarginRight(0).
WithPaperWidth(float64(pageWidth) / 25.4). // mm to inches
WithPaperHeight(float64(pageHeight) / 25.4) // mm to inches
// 执行打印并获取PDF数据
var err error
buf, _, err = printToPDF.Do(ctx)
return err
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp执行失败: %v", err)
}
return buf, nil
}
// getDesktopDirectory 获取用户桌面目录
func (api *PdfAPI) getDesktopDirectory() string {
// Windows系统
if common.IsWindows() {
home := os.Getenv("USERPROFILE")
if home != "" {
return filepath.Join(home, "Desktop")
}
}
// Linux/Mac系统
home := os.Getenv("HOME")
if home != "" {
return filepath.Join(home, "Desktop")
}
// 备用:当前目录
return "."
}
// SelectDirectory 选择保存目录简化版实际应该使用Wails runtime
func (api *PdfAPI) SelectDirectory() (string, error) {
// 简化版:直接返回桌面目录
desktop := api.getDesktopDirectory()
if desktop == "." {
return "", fmt.Errorf("无法确定默认目录")
}
return desktop, nil
}

View File

@@ -2,6 +2,7 @@ package common
import (
"fmt"
"runtime"
)
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
@@ -54,3 +55,18 @@ func Difference[T comparable](a, b []T) []T {
}
return diff
}
// IsWindows 判断是否为Windows系统
func IsWindows() bool {
return runtime.GOOS == "windows"
}
// IsMac 判断是否为Mac系统
func IsMac() bool {
return runtime.GOOS == "darwin"
}
// IsLinux 判断是否为Linux系统
func IsLinux() bool {
return runtime.GOOS == "linux"
}

View File

@@ -7,20 +7,106 @@ import (
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"sync"
)
// 旧版硬编码密钥(用于兼容迁移已有加密数据)
var legacyKey = []byte("go-desk-db-cli-key-32bytes123456")
var (
// 默认密钥(实际应用中应该从配置文件或环境变量读取)
// AES-256 需要 32 字节密钥
// "go-desk-db-cli-key-32bytes123456" = 32 bytes
defaultKey = []byte("go-desk-db-cli-key-32bytes123456") // 32 bytes for AES-256
encryptionKey []byte
keyOnce sync.Once
keyInitErr error
)
func init() {
// 验证密钥长度
if len(defaultKey) != 32 {
panic(fmt.Sprintf("AES-256 密钥长度必须为 32 字节,当前为 %d 字节", len(defaultKey)))
// 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 加密密码
@@ -29,7 +115,12 @@ func EncryptPassword(password string) (string, error) {
return "", nil
}
block, err := aes.NewCipher(defaultKey)
key, err := getKey()
if err != nil {
return "", fmt.Errorf("获取加密密钥失败: %v", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("创建加密器失败: %v", err)
}
@@ -53,47 +144,32 @@ func EncryptPassword(password string) (string, error) {
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// DecryptPassword 解密密码
// DecryptPassword 解密密码(自动回退旧密钥兼容旧数据)
func DecryptPassword(encryptedPassword string) (string, error) {
if encryptedPassword == "" {
return "", nil
}
// 如果加密字符串为空或格式不正确,返回空字符串
if len(encryptedPassword) < 10 {
return "", nil
}
// Base64 解码
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
key, err := getKey()
if err != nil {
return "", fmt.Errorf("解码失败: %v", err)
return "", fmt.Errorf("获取解密密钥失败: %v", err)
}
block, err := aes.NewCipher(defaultKey)
if err != nil {
return "", fmt.Errorf("创建解密器失败: %v", err)
// 先用新密钥尝试解密
result, err := DecryptPasswordV2(encryptedPassword, key)
if err == nil {
return result, nil
}
// 使用 GCM 模式
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("创建 GCM 失败: %v", err)
// 新密钥失败,尝试旧密钥(兼容已迁移的旧数据)
result, err = DecryptPasswordV2(encryptedPassword, legacyKey)
if err == nil {
return result, nil
}
// 提取 nonce
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
// 两种密钥都失败
return "", fmt.Errorf("解密失败: %v", err)
}

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

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

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"sync"
"time"
"u-desk/internal/common"
"u-desk/internal/crypto"
@@ -18,7 +19,10 @@ type ConnectionPool struct {
mongoClients map[uint]*MongoClient
// 新增MySQL 真连接池
mysqlPool *MySQLConnectionPool
mysqlPool *MySQLConnectionPool
// 查询优化器
queryOptimizer *QueryOptimizer
mu sync.RWMutex
}
@@ -38,18 +42,37 @@ func GetPool() *ConnectionPool {
// 启动维护协程
mysqlPool.StartMaintenance()
// 创建查询优化器
queryOptimizer := NewQueryOptimizer(nil)
globalPool = &ConnectionPool{
mysqlClients: make(map[uint]*MySQLClient),
redisClients: make(map[uint]*RedisClient),
mongoClients: make(map[uint]*MongoClient),
mysqlPool: mysqlPool,
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) (*MySQLClient, error) {
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) *PooledClient {
p.mu.Lock()
defer p.mu.Unlock()
@@ -57,16 +80,25 @@ func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) (*MySQLClient
if p.mysqlPool != nil {
entry, err := p.mysqlPool.Acquire(conn)
if err == nil {
// 成功从池中获取连接
return entry.Client, nil
return &PooledClient{Client: entry.Client, entry: entry, pool: p.mysqlPool, fromPool: true}
}
// 连接池错误,返回
return nil, err
p.logPoolError("Acquire failed", err)
}
// 降级到原有逻辑(如果连接池未初始化)
return p.getMySQLClientLegacy(conn)
// 降级到原有逻辑
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 客户端获取逻辑(向后兼容)
@@ -115,6 +147,92 @@ func (p *ConnectionPool) GetMySQLPoolStats() *PoolStats {
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()

View File

@@ -34,22 +34,40 @@ type PoolConfig struct {
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: 20, // 最大20个连接
MaxIdleConns: 10, // 最大10个空闲
ConnMaxLifetime: 30 * time.Minute, // 连接最长30分钟
ConnMaxIdleTime: 10 * time.Minute, // 空闲10分钟关闭
MinIdleConns: 2, // 保持2个最小空闲
ConnTimeout: 5 * time.Second, // 连接超时5秒
HealthCheckInterval: 30 * time.Second, // 30秒健康检查一次
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: 500 * time.Millisecond, // 超过500ms算慢连接
MaxPoolCapacity: 50, // 连接池最大容量
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, // 用于调整的最大空闲时间
}
}
@@ -94,6 +112,13 @@ type MySQLConnectionPool struct {
stats PoolStats
stopCh chan struct{}
wg sync.WaitGroup
// 动态调整相关
lastScaleUpTime time.Time // 上次扩容时间
lastScaleDownTime time.Time // 上次缩容时间
currentTargetSize int // 当前目标连接数
usageHistory []float64 // 使用率历史记录(用于智能调整)
adaptiveWeights map[uint]float64 // 连接权重(基于性能表现)
}
// NewMySQLConnectionPool 创建新的 MySQL 连接池
@@ -103,10 +128,13 @@ func NewMySQLConnectionPool(config *PoolConfig) *MySQLConnectionPool {
}
pool := &MySQLConnectionPool{
config: config,
entries: make([]*MySQLPoolEntry, 0, config.MaxPoolCapacity),
connMap: make(map[uint]*MySQLClient),
stopCh: make(chan struct{}),
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
@@ -119,7 +147,15 @@ func (p *MySQLConnectionPool) Acquire(conn *models.DbConnection) (*MySQLPoolEntr
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 {
@@ -138,13 +174,13 @@ func (p *MySQLConnectionPool) Acquire(conn *models.DbConnection) (*MySQLPoolEntr
// 没有可用连接,创建新连接
if len(p.entries) >= p.config.MaxOpenConns {
// 已达到最大连接数,等待
return nil, p.waitForAvailableConnection(conn)
return p.waitForAvailableConnection(conn)
}
// 创建新连接
// 创建新连接(使用传入的连接配置)
newEntry, err := p.createNewEntry(conn)
if err != nil {
return nil, err
return nil, fmt.Errorf("创建连接失败: %v", err)
}
p.entries = append(p.entries, newEntry)
@@ -160,15 +196,14 @@ func (p *MySQLConnectionPool) Release(entry *MySQLPoolEntry) error {
return nil
}
entry.mu.Lock()
defer entry.mu.Unlock()
entry.InUse = false
entry.LastUsed = time.Now()
p.mu.Lock()
defer p.mu.Unlock()
entry.mu.Lock()
entry.InUse = false
entry.LastUsed = time.Now()
entry.mu.Unlock()
p.updateStats()
return nil
@@ -240,35 +275,9 @@ func (p *MySQLConnectionPool) cleanupIdleConnections() {
p.updateStats()
}
// healthCheck 健康检查
// healthCheck 健康检查(增强版本)
func (p *MySQLConnectionPool) healthCheck() {
p.mu.RLock()
entriesCopy := make([]*MySQLPoolEntry, len(p.entries))
copy(entriesCopy, p.entries)
p.mu.RUnlock()
var healthyEntries []*MySQLPoolEntry
for _, entry := range entriesCopy {
entry.mu.Lock()
if !entry.InUse {
// Ping 测试
if err := entry.Client.sqlDB.Ping(); err != nil {
// 连接失效,标记为需要关闭
entry.mu.Unlock()
entry.Client.Close()
continue
}
}
entry.mu.Unlock()
healthyEntries = append(healthyEntries, entry)
}
// 更新连接池
p.mu.Lock()
defer p.mu.Unlock()
p.entries = healthyEntries
p.updateStats()
p.enhancedHealthCheck()
}
// StartMaintenance 启动维护协程(清理和健康检查)
@@ -277,16 +286,28 @@ func (p *MySQLConnectionPool) StartMaintenance() {
go func() {
defer p.wg.Done()
ticker := time.NewTicker(p.config.HealthCheckInterval)
defer ticker.Stop()
// 健康检查Ticker
healthTicker := time.NewTicker(p.config.HealthCheckInterval)
defer healthTicker.Stop()
// 动态调整Ticker较短间隔
scaleTicker := time.NewTicker(1 * time.Minute)
defer scaleTicker.Stop()
for {
select {
case <-ticker.C:
case <-healthTicker.C:
// 清理空闲连接
p.cleanupIdleConnections()
// 健康检查
p.healthCheck()
case <-scaleTicker.C:
// 动态连接池调整
if p.config.EnableDynamicScaling {
p.adaptiveScaling()
}
case <-p.stopCh:
return
}
@@ -323,10 +344,8 @@ func (p *MySQLConnectionPool) createNewEntry(conn *models.DbConnection) (*MySQLP
return entry, nil
}
// waitForAvailableConnection 等待可用连接
func (p *MySQLConnectionPool) waitForAvailableConnection(conn *models.DbConnection) error {
// 实现简单的等待逻辑(使用 channel
// 创建一个超时上下文
// waitForAvailableConnection 等待可用连接并获取它
func (p *MySQLConnectionPool) waitForAvailableConnection(conn *models.DbConnection) (*MySQLPoolEntry, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -336,34 +355,29 @@ func (p *MySQLConnectionPool) waitForAvailableConnection(conn *models.DbConnecti
for {
select {
case <-ctx.Done():
return ErrPoolExhausted
return nil, ErrPoolExhausted
case <-ticker.C:
// 检查是否有可用连接
p.mu.RLock()
hasAvailable := false
p.mu.Lock()
for _, entry := range p.entries {
entry.mu.Lock()
if !entry.InUse {
hasAvailable = true
entry.InUse = true
entry.LastUsed = time.Now()
entry.mu.Unlock()
break
p.mu.Unlock()
return entry, nil
}
entry.mu.Unlock()
}
p.mu.RUnlock()
if hasAvailable {
return nil
}
p.mu.Unlock()
}
}
}
// updateWaitStats 更新等待统计
// updateWaitStats 更新等待统计(调用方必须持有 p.mu
func (p *MySQLConnectionPool) updateWaitStats(startTime time.Time) {
waitDuration := time.Since(startTime)
p.stats.WaitCount++
p.stats.WaitDuration += waitDuration
p.stats.WaitDuration += time.Since(startTime)
}
// updateStats 更新连接池统计
@@ -387,6 +401,244 @@ func (p *MySQLConnectionPool) updateStats() {
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) {
// 解密密码
@@ -424,3 +676,4 @@ func (e *PoolError) Error() string {
}
return e.Message
}

View File

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

View File

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

View File

@@ -43,8 +43,10 @@ var defaultTabConfig = TabConfig{
AvailableTabs: []TabDefinition{
{Key: "file-system", Title: "文件管理", Enabled: true},
{Key: "db-cli", Title: "数据库", Enabled: true},
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
{Key: "openclaw-manager", Title: "OpenClaw", Enabled: true},
},
VisibleTabs: []string{"file-system", "db-cli"},
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "openclaw-manager"},
DefaultTab: "file-system",
}

View File

@@ -1,12 +1,16 @@
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 连接管理服务
@@ -90,8 +94,20 @@ func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error)
return s.repo.FindByID(id)
}
// DeleteConnection 删除连接配置
// 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)
}
@@ -185,3 +201,68 @@ func (s *ConnectionService) TestConnectionWithParams(connType, host string, port
return fmt.Errorf("不支持的数据库类型: %s", connType)
}
}
// LoadAllDatabases 加载全部数据库列表
func (s *ConnectionService) LoadAllDatabases(dbType, host string, port int, username, password, database, options string, existingId uint) ([]string, error) {
// 如果是编辑模式且密码为空,尝试获取已保存的密码
actualPassword := password
if existingId > 0 && password == "" {
conn, err := s.repo.FindByID(existingId)
if err != nil {
return nil, fmt.Errorf("获取原连接配置失败: %v", err)
}
actualPassword, err = crypto.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("密码解密失败: %v", err)
}
}
// 解析 MongoDB 选项
authSource := ""
authMechanism := ""
if options != "" {
var opts map[string]interface{}
if err := json.Unmarshal([]byte(options), &opts); err == nil {
authSource, _ = opts["authSource"].(string)
authMechanism, _ = opts["authMechanism"].(string)
}
}
switch dbType {
case "mysql":
return loadDatabasesForMySQL(host, port, username, actualPassword, database)
case "mongo":
return loadDatabasesForMongo(host, port, username, actualPassword, database, authSource, authMechanism)
case "redis":
return []string{}, nil
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", dbType)
}
}
func loadDatabasesForMySQL(host string, port int, username, password, defaultDatabase string) ([]string, error) {
config := &dbclient.MySQLConfig{
Host: host, Port: port, Username: username,
Password: password, Database: defaultDatabase,
}
client, err := dbclient.NewMySQLClient(config)
if err != nil {
return nil, err
}
defer client.Close()
return client.ListDatabases(context.Background())
}
func loadDatabasesForMongo(host string, port int, username, password, defaultDatabase, authSource, authMechanism string) ([]string, error) {
config := &dbclient.MongoConfig{
Host: host, Port: port, Username: username,
Password: password, Database: defaultDatabase,
AuthSource: authSource, AuthMechanism: authMechanism,
}
client, err := dbclient.NewMongoClient(config)
if err != nil {
return nil, err
}
defer client.Close()
return client.ListDatabases(context.Background())
}

View File

@@ -66,10 +66,11 @@ func (s *SqlExecService) ExecuteSQL(connectionID uint, sqlStr string, database s
// executeMySQL 执行MySQL SQL
func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
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)
@@ -89,7 +90,7 @@ func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnec
strings.HasPrefix(sqlUpper, "DESCRIBE") || strings.HasPrefix(sqlUpper, "DESC") ||
strings.HasPrefix(sqlUpper, "EXPLAIN") {
// 查询语句
queryResult, err := client.ExecuteQuery(ctx, sqlStr, dbName)
queryResult, err := pc.Client.ExecuteQuery(ctx, sqlStr, dbName)
if err != nil {
return nil, err
}
@@ -99,7 +100,7 @@ func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnec
result.RowsAffected = len(queryResult.Data)
} else {
// 更新语句
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, dbName)
rowsAffected, err := pc.Client.ExecuteUpdate(ctx, sqlStr, dbName)
if err != nil {
return nil, err
}
@@ -220,11 +221,12 @@ func (s *SqlExecService) GetDatabases(connectionID uint) ([]string, error) {
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
return client.ListDatabases(ctx)
defer pc.Release()
return pc.Client.ListDatabases(ctx)
case "redis":
databases := make([]string, 16)
for i := 0; i < 16; i++ {
@@ -254,11 +256,12 @@ func (s *SqlExecService) GetTables(connectionID uint, database string) ([]string
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
return client.ListTables(ctx, database)
defer pc.Release()
return pc.Client.ListTables(ctx, database)
case "redis":
client, err := s.pool.GetRedisClient(conn)
if err != nil {
@@ -305,7 +308,7 @@ func parseRedisCommand(cmd string) []string {
} else {
if char == quoteChar {
inQuotes = false
quoteChar = 0
quoteChar = byte(0)
} else {
current.WriteByte(char)
}
@@ -330,11 +333,12 @@ func (s *SqlExecService) GetTableStructure(connectionID uint, database, tableNam
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
structure, err := client.GetTableStructure(ctx, database, tableName)
defer pc.Release()
structure, err := pc.Client.GetTableStructure(ctx, database, tableName)
if err != nil {
return nil, err
}
@@ -393,11 +397,12 @@ func (s *SqlExecService) GetIndexes(connectionID uint, database, tableName strin
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
return client.GetIndexes(ctx, database, tableName)
defer pc.Release()
return pc.Client.GetIndexes(ctx, database, tableName)
case "mongo", "redis":
return []map[string]interface{}{}, nil
@@ -419,11 +424,12 @@ func (s *SqlExecService) PreviewTableStructure(connectionID uint, database, tabl
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
return client.PreviewTableStructure(ctx, database, tableName, structure)
defer pc.Release()
return pc.Client.PreviewTableStructure(ctx, database, tableName, structure)
case "mongo":
client, err := s.pool.GetMongoClient(conn)
@@ -449,11 +455,12 @@ func (s *SqlExecService) UpdateTableStructure(connectionID uint, database, table
switch conn.Type {
case "mysql":
client, err := s.pool.GetMySQLClient(conn)
if err != nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
pc := s.pool.GetMySQLClient(conn)
if pc.Client == nil {
return nil, fmt.Errorf("获取 MySQL 客户端失败")
}
return client.UpdateTableStructure(ctx, database, tableName, structure)
defer pc.Release()
return pc.Client.UpdateTableStructure(ctx, database, tableName, structure)
case "mongo":
client, err := s.pool.GetMongoClient(conn)

View File

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

View File

@@ -20,6 +20,13 @@
</template>
</a-button>
</a-tooltip>
<a-tooltip :content="isPinned ? '取消置顶' : '窗口置顶'">
<a-button type="text" :class="{ 'pin-active': isPinned }" @click="handleTogglePin">
<template #icon>
<IconPushpin :class="{ pinned: isPinned }"/>
</template>
</a-button>
</a-tooltip>
<ThemeToggle/>
<!-- 窗口控制按钮 -->
@@ -71,9 +78,10 @@
</a-layout>
</template>
<script setup>
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {IconSettings} 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 DbCli from './views/db-cli/index.vue'
import ThemeToggle from './components/ThemeToggle.vue'
import FileSystem from './components/FileSystem/index.vue'
@@ -81,7 +89,6 @@ import SettingsPanel from './components/SettingsPanel.vue'
import UpdateNotification from './components/UpdateNotification.vue'
import {useUpdateStore} from './stores/update'
import {useConfigStore} from './stores/config'
import {preloadCommonLanguages} from './utils/codeMirrorLoader'
// 存储键
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
@@ -91,6 +98,7 @@ const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
const showSettings = ref(false)
const isMaximized = ref(false)
const isPinned = ref(false)
// 使用 stores
const updateStore = useUpdateStore()
@@ -129,21 +137,27 @@ const loadConfig = async () => {
const getComponent = (key) => {
const components = {
'file-system': FileSystem,
'db-cli': DbCli
'db-cli': DbCli,
'markdown-editor': MarkdownEditor
}
return components[key] || null
}
// 组件挂载时加载配置
// 禁止 Ctrl+滚轮缩放
const preventZoom = (e: WheelEvent) => {
if (e.ctrlKey) e.preventDefault()
}
onMounted(() => {
loadConfig()
// 预加载常用编辑器语言包
preloadCommonLanguages()
// 设置更新事件监听
updateStore.setupEventListeners()
// 禁止 Ctrl+滚轮缩放
document.addEventListener('wheel', preventZoom, { passive: false })
// 延迟检查更新(启动后 3 秒,静默模式)
setTimeout(() => {
updateStore.checkForUpdates(true)
@@ -152,6 +166,7 @@ onMounted(() => {
// 组件卸载时清理事件监听
onUnmounted(() => {
document.removeEventListener('wheel', preventZoom)
updateStore.removeEventListeners()
})
@@ -166,6 +181,16 @@ const handleMinimize = async () => {
}
}
const handleTogglePin = async () => {
try {
if (window.go?.main?.App?.WindowToggleAlwaysOnTop) {
isPinned.value = await window.go.main.App.WindowToggleAlwaysOnTop()
}
} catch (error) {
console.error('切换置顶失败:', error)
}
}
const handleMaximize = async () => {
try {
if (window.go?.main?.App?.WindowMaximize) {
@@ -282,6 +307,25 @@ watch(activeTab, (newTab) => {
color: #ffffff;
}
.pin-active {
color: rgb(var(--primary-6)) !important;
}
.pin-active :deep(svg) {
transform: none !important;
opacity: 1 !important;
}
.header-actions :deep(.arco-icon-pushpin) {
transform: rotate(45deg);
opacity: 0.6;
}
.header-actions :deep(.arco-icon-pushpin.pinned) {
transform: none;
opacity: 1;
}
.window-control-btn svg {
display: block;
pointer-events: none;

View File

@@ -76,7 +76,7 @@
style="cursor: pointer; margin-bottom: 4px"
>
<template #icon>
<span>{{ fav.is_dir ? '📁' : '📄' }}</span>
<span>{{ fav.isDir ? '📁' : '📄' }}</span>
</template>
{{ fav.name }}
</a-tag>
@@ -504,7 +504,7 @@ const openFavoriteFile = (path) => {
addToHistory(path)
const fav = favoriteFiles.value.find(f => f.path === path)
if (fav && fav.is_dir) {
if (fav && fav.isDir) {
listDirectory()
} else {
readFile()

View File

@@ -1,8 +1,9 @@
<template>
<div
v-if="config.visible"
ref="menuRef"
class="context-menu"
:style="{ left: config.x + 'px', top: config.y + 'px' }"
:style="menuStyle"
@click.stop
>
<!-- 空白区域菜单 -->
@@ -21,6 +22,16 @@
<!-- 文件菜单 -->
<template v-else-if="config.context === 'file' && config.selectedFile">
<div class="context-menu-item" @click="handleCreateFile">
<span class="context-menu-icon">📄</span>
<span>新建文件</span>
<span class="context-menu-shortcut">Ctrl+N</span>
</div>
<div class="context-menu-item" @click="handleCreateDir">
<span class="context-menu-icon">📁</span>
<span>新建文件夹</span>
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
</div>
<div class="context-menu-divider"></div>
<div
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
@@ -46,9 +57,12 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import type { ContextMenuConfig, FileItem } from '@/types/file-system'
import { isOfficeFile } from '@/utils/fileTypeHelpers'
const menuRef = ref<HTMLElement>()
// Props
interface Props {
config: ContextMenuConfig
@@ -64,6 +78,26 @@ interface Emits {
const emit = defineEmits<Emits>()
const menuStyle = computed(() => {
return { left: props.config.x + 'px', top: props.config.y + 'px' }
})
// 位置修正:渲染后检查菜单是否超出视口,自动调整位置
watch(() => props.config.visible, (visible) => {
if (!visible) return
nextTick(() => {
const el = menuRef.value
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.right > window.innerWidth) {
el.style.left = (window.innerWidth - rect.width - 4) + 'px'
}
if (rect.bottom > window.innerHeight) {
el.style.top = (window.innerHeight - rect.height - 4) + 'px'
}
})
})
/**
* 处理菜单项点击
*/

View File

@@ -84,8 +84,8 @@ const error = ref('')
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
const hoverTimer = ref<number | null>(null)
const leaveTimer = ref<number | null>(null)
const hoverTimer = ref<NodeJS.Timeout | null>(null)
const leaveTimer = ref<NodeJS.Timeout | null>(null)
const hoveringMenu = ref(false)
const menuKey = `menu-${props.item.path}-${props.level}`

View File

@@ -1,5 +1,5 @@
<template>
<div class="file-editor-panel" :style="{ width: width + '%' }">
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
<div class="panel-header">
<span class="panel-title">
<template v-if="config.isImageView">🖼 图片预览</template>
@@ -13,7 +13,14 @@
<template v-else-if="config.isCsvFile">📋 CSV 预览</template>
<template v-else>📝 文件内容</template>
</span>
<div v-if="config.currentFileName" class="filename-with-copy">
<div class="header-actions">
<a-tooltip v-if="config.currentFileName" content="全屏预览 (F11)" position="left">
<a-button size="mini" type="text" @click="toggleFullscreen">
<icon-fullscreen v-if="!isFullscreen" />
<icon-fullscreen-exit v-else />
</a-button>
</a-tooltip>
<div v-if="config.currentFileName" class="filename-with-copy">
<a-tooltip :content="config.currentFileFullPath" position="left">
<span
class="panel-filename"
@@ -28,6 +35,7 @@
@click="handleCopyPath"
/>
</div>
</div>
</div>
<div class="editor-content">
@@ -194,6 +202,16 @@
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- PDF 导出按钮仅在预览模式显示 -->
<a-tooltip v-if="!config.isEditMode" position="left" content="导出">
<a-button
type="outline"
size="small"
@click="handleExportPDF"
>
<template #icon><icon-file-pdf /></template>
</a-button>
</a-tooltip>
<!-- 预览/编辑切换按钮 -->
<a-tooltip
position="left"
@@ -288,7 +306,7 @@
<script setup lang="ts">
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
import { getFileName } from '@/utils/fileUtils'
import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
@@ -309,6 +327,30 @@ const csvPreviewRef = ref<HTMLElement | null>(null)
// Markdown 预览容器引用
const markdownPreviewRef = ref<HTMLElement | null>(null)
// 全屏
const panelRef = ref<HTMLElement | null>(null)
const isFullscreen = ref(false)
function toggleFullscreen() {
if (!panelRef.value) return
if (!document.fullscreenElement) {
panelRef.value.requestFullscreen().then(() => { isFullscreen.value = true })
} else {
document.exitFullscreen().then(() => { isFullscreen.value = false })
}
}
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'F11' && props.config.currentFileName) {
e.preventDefault()
toggleFullscreen()
}
}
// Props
interface Props {
config: FileEditorPanelConfig
@@ -400,6 +442,165 @@ const handleImageError = () => {
emit('imageError')
}
// Markdown PDF 导出处理
const handleExportPDF = async () => {
try {
// 获取 Markdown 预览容器
const markdownContent = markdownPreviewRef.value
if (!markdownContent) {
Message.error('无法获取 Markdown 内容')
return
}
// 打开打印窗口
const printWindow = window.open('', '_blank')
if (!printWindow) {
Message.error('无法打开打印窗口,请检查浏览器设置')
return
}
// 设置打印样式
const style = `
<style>
@media print {
body {
font-size: 12pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 20px;
}
.no-print {
display: none !important;
}
.markdown-content {
max-width: 100%;
margin: 0;
padding: 0;
}
.markdown-content h1 {
font-size: 24pt;
margin-bottom: 12pt;
border-bottom: 2px solid #333;
}
.markdown-content h2 {
font-size: 18pt;
margin-bottom: 10pt;
border-bottom: 1px solid #ccc;
}
.markdown-content h3 {
font-size: 14pt;
margin-bottom: 8pt;
}
.markdown-content p {
margin-bottom: 10pt;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 10pt;
}
.markdown-content li {
margin-bottom: 4pt;
}
.markdown-content table {
border-collapse: collapse;
margin-bottom: 12pt;
width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.markdown-content th {
background-color: #f5f5f5;
font-weight: bold;
}
.markdown-content img {
max-width: 100%;
height: auto;
}
.markdown-content blockquote {
border-left: 4px solid #ddd;
margin: 16px 0;
padding: 10px 20px;
color: #666;
}
.markdown-content code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
}
.markdown-content pre {
background-color: #f5f5f5;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
}
.markdown-content pre code {
background-color: transparent;
padding: 0;
}
}
/* 优化屏幕显示 */
.markdown-content {
background: white;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
</style>
`
// 构建打印页面
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${props.config.currentFileName || 'Markdown 导出 PDF'}</title>
${style}
</head>
<body>
<div class="markdown-content">
${markdownContent.innerHTML}
</div>
</body>
</html>
`)
printWindow.document.close()
// 延迟一点时间让样式加载完成
setTimeout(() => {
printWindow.print()
// 不关闭窗口,让用户可以手动关闭或继续打印
}, 500)
Message.success('PDF 导出窗口已打开')
} catch (error) {
console.error('[handleExportPDF] 导出失败:', error)
Message.error('PDF 导出失败,请重试')
}
}
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
// 从编辑模式切换到预览模式
@@ -607,9 +808,11 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
}
}
// 监听 iframe 的 postMessage
// 监听 iframe 的 postMessage + 全屏事件
onMounted(() => {
window.addEventListener('message', handleHtmlIframeMessage)
document.addEventListener('fullscreenchange', onFullscreenChange)
document.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
@@ -617,6 +820,8 @@ onUnmounted(() => {
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
}
window.removeEventListener('message', handleHtmlIframeMessage)
document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('keydown', onKeyDown)
})
</script>
@@ -628,6 +833,12 @@ onUnmounted(() => {
background: var(--color-bg-1);
}
.file-editor-panel:fullscreen {
width: 100vw !important;
height: 100vh;
z-index: 1000;
}
.panel-header {
display: flex;
align-items: center;
@@ -641,8 +852,25 @@ onUnmounted(() => {
gap: 12px;
}
.panel-header > * {
--wails-draggable: no-drag;
}
/* 仅全屏模式下 header 可拖动窗口 */
.file-editor-panel:fullscreen .panel-header {
--wails-draggable: drag;
}
.panel-title {
color: var(--color-text-1);
flex-shrink: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.filename-with-copy {
@@ -1056,16 +1284,61 @@ onUnmounted(() => {
fill: var(--color-text-1);
}
/* ========== 深色模式适配 ========== */
/* ========== 代码高亮主题色(不依赖 hljs 主题 CSS ========== */
/* Mermaid 图表深色模式 */
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid) {
background: rgba(255, 255, 255, 0.05);
/* 亮色模式 - GitHub 配色 */
.markdown-preview-content :deep(.hljs) {
color: #24292e;
background: #f6f8fa;
}
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid *) {
color: var(--color-text-1) !important;
stroke: var(--color-text-1) !important;
.markdown-preview-content :deep(.hljs-comment),
.markdown-preview-content :deep(.hljs-quote) { color: #6a737d; font-style: italic; }
.markdown-preview-content :deep(.hljs-keyword),
.markdown-preview-content :deep(.hljs-selector-tag),
.markdown-preview-content :deep(.hljs-subst) { color: #d73a49; }
.markdown-preview-content :deep(.hljs-string),
.markdown-preview-content :deep(.hljs-doctag) { color: #032f62; }
.markdown-preview-content :deep(.hljs-number),
.markdown-preview-content :deep(.hljs-literal),
.markdown-preview-content :deep(.hljs-variable),
.markdown-preview-content :deep(.hljs-template-variable),
.markdown-preview-content :deep(.hljs-tag .hljs-attr) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-title),
.markdown-preview-content :deep(.hljs-section),
.markdown-preview-content :deep(.hljs-selector-id) { color: #6f42c1; font-weight: bold; }
.markdown-preview-content :deep(.hljs-type),
.markdown-preview-content :deep(.hljs-class .hljs-title) { color: #6f42c1; }
.markdown-preview-content :deep(.hljs-tag .hljs-keyword),
.markdown-preview-content :deep(.hljs-tag .hljs-title) { color: #22863a; }
.markdown-preview-content :deep(.hljs-bullet) { color: #e36209; }
.markdown-preview-content :deep(.hljs-symbol) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-built_in),
.markdown-preview-content :deep(.hljs-type) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-attr) { color: #e36209; }
.markdown-preview-content :deep(.hljs-meta) { color: #735c0f; }
.markdown-preview-content :deep(.hljs-addition) { color: #22863a; background-color: #f0fff4; }
.markdown-preview-content :deep(.hljs-deletion) { color: #b31d28; background-color: #ffeef0; }
/* ========== 深色模式适配 ========== */
/* Mermaid 图表深色模式 - 使用原生 dark 主题,仅需背景适配 */
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid) {
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
}
/* 代码高亮深色模式 - 使用 CSS 自定义属性 */

View File

@@ -31,7 +31,7 @@
<span v-else class="file-item-name" :title="file.name">{{ file.name }}</span>
<!-- 文件大小 -->
<span v-if="!file.is_dir && !isEditing" class="file-item-size">
<span v-if="!file.isDir && !isEditing" class="file-item-size">
{{ formattedSize }}
</span>
@@ -54,8 +54,7 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
import { formatBytes } from '@/utils/fileUtils'
import { getFileIcon } from '@/utils/fileUtils'
import { formatBytes, getFileIcon } from '@/utils/fileUtils'
import type { FileItem } from '@/types/file-system'
// Props

View File

@@ -55,9 +55,8 @@
<script setup lang="ts">
import { ref, computed, watch, provide, type Ref } from 'vue'
import { IconRight, IconFolder, IconFile, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
import { IconRight, IconFolder } from '@arco-design/web-vue/es/icon'
import { listDir } from '@/api/system'
import { getParentPath, joinPath, normalizePathSeparators } from '@/utils/pathHelpers'
import { sortFileList } from '@/utils/fileUtils'
import { useTimeout } from '@/composables/useTimeout'
import DropdownItem from './DropdownItem.vue'
@@ -118,17 +117,22 @@ const segments = computed<PathSegment[]>(() => {
})
const activeIndex = ref<number | null>(null)
const hoverTimer = ref<NodeJS.Timeout | null>(null)
const closeTimer = ref<NodeJS.Timeout | null>(null)
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
const loading = ref(false)
const error = ref('')
const lastLoadedPath = ref('')
const loadChildren = async (path: string) => {
if (path === lastLoadedPath.value) return
loading.value = true
error.value = ''
try {
const files = await listDir(path)
lastLoadedPath.value = path
children.value = sortFileList(files.map(f => ({
name: f.name,
path: f.path,
@@ -150,17 +154,22 @@ const resetAndClose = () => {
const onHover = (segment: PathSegment, index: number) => {
if (index === segments.value.length - 1) return
delay(() => {
if (hoverTimer.value) clearTimeout(hoverTimer.value)
if (closeTimer.value) clearTimeout(closeTimer.value)
hoverTimer.value = delay(() => {
activeIndex.value = index
loadChildren(segment.path)
}, 200)
}
const onMenuEnter = () => {
if (hoverTimer.value) clearTimeout(hoverTimer.value)
if (closeTimer.value) clearTimeout(closeTimer.value)
}
const onMenuLeave = () => {
if (hoverTimer.value) clearTimeout(hoverTimer.value)
closeTimer.value = delay(() => {
resetAndClose()
}, 100)
@@ -184,6 +193,7 @@ const onOpenFile = (path: string) => {
watch(() => props.path, () => {
activeIndex.value = null
children.value = []
lastLoadedPath.value = ''
openMenus.value = new Map()
})
</script>

View File

@@ -84,7 +84,6 @@
<!-- 刷新按钮 -->
<a-button
type="primary"
size="small"
:loading="config.fileLoading"
@click="handleRefresh"
@@ -137,14 +136,6 @@ interface Emits {
const emit = defineEmits<Emits>()
// 事件处理
const handlePathUpdate = (path: string) => {
emit('update:filePath', path)
}
const handlePathSelect = (value: string) => {
emit('goToPath', value)
}
const handleGoToPath = (path: string) => {
emit('goToPath', path)
}

View File

@@ -3,8 +3,10 @@
* 提供收藏文件的添加、删除、排序等功能
*/
import { ref, watch } from 'vue'
import { STORAGE_KEYS } from '@/utils/constants'
import { ref } from 'vue'
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
import { getPathSeparator } from '@/utils/fileUtils'
import { Message } from '@arco-design/web-vue'
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
export function useFavorites() {
@@ -67,13 +69,23 @@ export function useFavorites() {
}
}
/**
* 标准化路径用于比较Windows 大小写不敏感)
*/
const normalizePath = (path: string): string => {
return path.toLowerCase()
}
/**
* 添加收藏
*/
const addFavorite = (file: FileItem) => {
// 检查是否已存在
const exists = favorites.value.some(fav => fav.path === file.path)
if (exists) {
if (isFavorite(file.path)) {
return false
}
if (favorites.value.length >= DEFAULTS.MAX_FAVORITES_LENGTH) {
Message.warning(`收藏夹已满,最多收藏 ${DEFAULTS.MAX_FAVORITES_LENGTH}`)
return false
}
@@ -81,17 +93,11 @@ export function useFavorites() {
...file,
addedAt: Date.now()
} as FavoriteFile)
sortFavorites()
saveFavorites()
return true
}
/**
* 标准化路径用于比较(后端已统一为 /,直接转小写)
*/
const normalizePath = (path: string): string => {
return path.toLowerCase()
}
/**
* 删除收藏
*/
@@ -108,14 +114,12 @@ export function useFavorites() {
* 切换收藏状态
*/
const toggleFavorite = (file: FileItem) => {
const exists = isFavorite(file.path)
if (exists) {
if (isFavorite(file.path)) {
removeFavorite(file.path)
return false
} else {
addFavorite(file)
return true
}
addFavorite(file)
return true
}
/**
@@ -131,15 +135,9 @@ export function useFavorites() {
*/
const togglePin = (path: string) => {
const normalizedPath = normalizePath(path)
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
const fav = favorites.value.find(f => normalizePath(fav.path) === normalizedPath)
if (fav) {
if (fav.pinnedAt) {
// 取消置顶
fav.pinnedAt = undefined
} else {
// 设置置顶
fav.pinnedAt = Date.now()
}
fav.pinnedAt = fav.pinnedAt ? undefined : Date.now()
sortFavorites()
saveFavorites()
}
@@ -150,28 +148,37 @@ export function useFavorites() {
*/
const isPinned = (path: string): boolean => {
const normalizedPath = normalizePath(path)
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
const fav = favorites.value.find(f => normalizePath(fav.path) === normalizedPath)
return !!fav?.pinnedAt
}
/**
* 长按开始
* 更新收藏项路径(重命名时使用,保留置顶状态和添加时间)
*/
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
const isMouse = event instanceof MouseEvent
const isTouch = event instanceof TouchEvent
const updateFavoritePath = (oldPath: string, newName: string) => {
const normalizedOld = normalizePath(oldPath)
const fav = favorites.value.find(f => normalizePath(fav.path) === normalizedOld)
if (!fav) return
// 只支持鼠标左键或触摸
if (isMouse && event.button !== 0) return
if (!isMouse && !isTouch) return
const separator = getPathSeparator(oldPath)
const parentPath = oldPath.substring(
0,
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
)
fav.path = parentPath + separator + newName
fav.name = newName
saveFavorites()
}
// 拖拽方法
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
if (event instanceof MouseEvent && event.button !== 0) return
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
draggingState.value.pressedIndex = index
draggingState.value.draggedIndex = index
}
/**
* 长按取消
*/
const onLongPressCancel = () => {
if (!draggingState.value.isDragging) {
draggingState.value.pressedIndex = -1
@@ -179,23 +186,15 @@ export function useFavorites() {
}
}
/**
* 拖拽开始
*/
const onDragStart = (event: DragEvent, index: number) => {
draggingState.value.isDragging = true
draggingState.value.draggedIndex = index
// 设置拖拽数据
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', index.toString())
}
}
/**
* 拖拽经过
*/
const onDragOver = (event: DragEvent) => {
event.preventDefault()
if (event.dataTransfer) {
@@ -203,81 +202,53 @@ export function useFavorites() {
}
}
/**
* 放置
*/
const onDrop = (event: DragEvent, targetIndex: number) => {
event.preventDefault()
const fromIndex = draggingState.value.draggedIndex
const toIndex = targetIndex
if (fromIndex === toIndex || fromIndex === -1) {
if (fromIndex === targetIndex || fromIndex === -1) {
resetDragging()
return
}
// 移动元素
const item = favorites.value.splice(fromIndex, 1)[0]
favorites.value.splice(toIndex, 0, item)
favorites.value.splice(targetIndex, 0, item)
saveFavorites()
resetDragging()
}
/**
* 拖拽结束
*/
const onDragEnd = () => {
resetDragging()
}
/**
* 重置拖拽状态
*/
const resetDragging = () => {
draggingState.value.isDragging = false
draggingState.value.draggedIndex = -1
draggingState.value.pressedIndex = -1
}
/**
* 重新排序
*/
const reorder = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return
const item = favorites.value.splice(fromIndex, 1)[0]
favorites.value.splice(toIndex, 0, item)
saveFavorites()
}
// 组件挂载时加载收藏列表
loadFavorites()
return {
// 状态
favorites,
draggingState,
// 方法
addFavorite,
removeFavorite,
toggleFavorite,
isFavorite,
togglePin,
isPinned,
updateFavoritePath,
// 拖拽方法
onLongPressStart,
onLongPressCancel,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
reorder,
// 工具方法
loadFavorites,
saveFavorites,
resetDragging

View File

@@ -6,6 +6,12 @@
import { ref, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { STORAGE_KEYS, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { getExt } from '@/utils/fileUtils'
import {
isImageFile, isVideoFile, isAudioFile, isPdfFile,
isExcelFile, isWordFile, isCsvFile,
isTextEditable, isConfigFile
} from '@/utils/fileTypeHelpers'
import { useFileOperations } from './useFileOperations'
export interface UseFileEditOptions {
@@ -63,96 +69,29 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
return ''
}
/**
* 获取文件扩展名
*/
const getFileExtension = (filepath: any): string => {
const path = getFilePath(filepath)
if (!path || typeof path !== 'string') return ''
return path.split('.').pop()?.toLowerCase() || ''
}
/**
* 判断是否为图片文件
*/
const isImageFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
if (!ext) return false
return FILE_EXTENSIONS.IMAGE.includes(ext)
}
/**
* 判断是否为视频文件
*/
const isVideoFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
if (!ext) return false
return FILE_EXTENSIONS.VIDEO.includes(ext)
}
/**
* 判断是否为音频文件
*/
const isAudioFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
if (!ext) return false
return FILE_EXTENSIONS.AUDIO.includes(ext)
}
/**
* 判断是否为 PDF 文件
*/
const isPdfFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
return ext === 'pdf'
}
/**
* 判断是否为 Excel 文件
*/
const isExcelFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
return ['xlsx', 'xls'].includes(ext)
}
/**
* 判断是否为 Word 文件
*/
const isWordFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
return ['docx', 'doc'].includes(ext)
}
/**
* 判断是否为 CSV/TSV 文件
*/
const isCsvFile = (filepath: any): boolean => {
const ext = getFileExtension(filepath)
return ['csv', 'tsv'].includes(ext)
}
/**
* 判断是否为二进制文件(基于扩展名)
* 注意媒体文件图片、视频、音频、PDF不是二进制文件它们可以预览
* 对于无扩展名的文件,返回 null 表示未知,需要内容检测
*/
const isBinaryFileByExt = (filepath: any): boolean | null => {
const ext = getFileExtension(filepath)
const path = getFilePath(filepath)
const ext = getExt(path)
if (!ext) return null // 无扩展名返回 null表示需要进一步检测
// 媒体文件(可预览,不算二进制)
const isMediaFile = FILE_EXTENSIONS.IMAGE.includes(ext) ||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext)
const isMediaFile = isImageFile(path) ||
isVideoFile(path) ||
isAudioFile(path) ||
isPdfFile(path) ||
['html', 'htm', 'md', 'markdown'].includes(ext)
// Office 文件和 CSV可预览
const isOfficeFile = ['xlsx', 'xls', 'docx', 'doc', 'csv', 'tsv'].includes(ext)
const isOfficeFile = isExcelFile(path) || isWordFile(path) || isCsvFile(path)
// 文本或代码文件(可编辑)
const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) ||
FILE_EXTENSIONS.CODE.includes(ext) ||
FILE_EXTENSIONS.CONFIG.includes(ext)
const isTextFile = isTextEditable(path) || isConfigFile(path) ||
FILE_EXTENSIONS.CODE.includes(ext)
// 如果是媒体文件、Office 文件或文本文件,就不是二进制
if (isMediaFile || isOfficeFile || isTextFile) return false
@@ -243,7 +182,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 新内容加载完成后会直接替换旧内容
const filename = getFilePath(path)
const ext = getFileExtension(filename)
const ext = getExt(filename)
// Office 文件和 CSV 文件直接读取内容进行预览,跳过二进制检测
if (isExcelFile(filename) || isWordFile(filename) || isCsvFile(filename)) {
@@ -658,13 +597,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
setEditorHeight,
// 文件类型检查
isImageFile,
isVideoFile,
isAudioFile,
isPdfFile,
isExcelFile,
isWordFile,
isCsvFile,
isBinaryFileByExt,
isFileInCurrentDirectory
}

View File

@@ -3,8 +3,8 @@
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
*/
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { getPathSeparator } from '@/utils/fileUtils'
import {
listDir,
readFile as readFileApi,
@@ -13,12 +13,12 @@ import {
createFile,
createDir,
renamePath as renamePathApi,
listZipContents,
listZipContents as listZipContentsApi,
extractFileFromZip,
extractFileFromZipToTemp,
getFileServerURL
extractFileFromZipToTemp as extractZipToTempApi,
getFileServerURL as getFileServerUrlApi
} from '@/api'
import type { FileOperationResult } from '@/types/file-system'
import type { FileItem, FileOperationResult } from '@/types/file-system'
export interface UseFileOperationsOptions {
onSuccess?: (operation: string, data: any) => void
@@ -133,7 +133,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
*/
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
// 构造新路径
const separator = oldPath.includes('\\') ? '\\' : '/'
const separator = getPathSeparator(oldPath)
const parentPath = oldPath.substring(
0,
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
@@ -186,7 +186,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
*/
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
try {
const result = await listZipContents(zipPath)
const result = await listZipContentsApi(zipPath)
onSuccess?.('listZipContents', { zipPath, count: result.length })
return result
} catch (error) {
@@ -216,7 +216,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
*/
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
try {
const tempPath = await extractFileFromZipToTemp(zipPath, filePath)
const tempPath = await extractZipToTempApi(zipPath, filePath)
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
return tempPath
} catch (error) {
@@ -231,7 +231,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
*/
const getFileServerURL = async (): Promise<string> => {
try {
const url = await getFileServerURL()
const url = await getFileServerUrlApi()
onSuccess?.('getFileServerURL', { url })
return url
} catch (error) {

View File

@@ -3,10 +3,15 @@
* 提供文件预览 URL 生成、媒体元数据获取等功能
*/
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { normalizeFilePath } from '@/utils/fileUtils'
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
import { detectFileTypeByContent } from '@/api/system'
import {
isImageFile, isVideoFile, isAudioFile, isPdfFile,
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
isTextEditable, isConfigFile
} from '@/utils/fileTypeHelpers'
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
// 内容检测大小限制(与后端一致)
@@ -81,159 +86,42 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
const getFileType = (filename: string): FileType => {
if (!filename || typeof filename !== 'string') return 'Binary' as FileType
const ext = filename.split('.').pop()?.toLowerCase() || ''
if (isImageFile(filename)) return 'Image' as FileType
if (isVideoFile(filename)) return 'Video' as FileType
if (isAudioFile(filename)) return 'Audio' as FileType
if (isPdfFile(filename)) return 'Pdf' as FileType
if (isHtmlFile(filename)) return 'Html' as FileType
if (isMarkdownFile(filename)) return 'Markdown' as FileType
if (FILE_EXTENSIONS.CODE.includes(getExt(filename))) return 'Code' as FileType
if (isConfigFile(filename)) return 'Code' as FileType
if (isTextEditable(filename)) return 'Text' as FileType
// 图片
if (FILE_EXTENSIONS.IMAGE.includes(ext)) {
return 'Image' as FileType
}
// 视频
if (FILE_EXTENSIONS.VIDEO.includes(ext)) {
return 'Video' as FileType
}
// 音频
if (FILE_EXTENSIONS.AUDIO.includes(ext)) {
return 'Audio' as FileType
}
// PDF
if (ext === 'pdf') {
return 'Pdf' as FileType
}
// HTML
if (['html', 'htm'].includes(ext)) {
return 'Html' as FileType
}
// Markdown
if (['md', 'markdown'].includes(ext)) {
return 'Markdown' as FileType
}
// 代码
if (FILE_EXTENSIONS.CODE.includes(ext)) {
return 'Code' as FileType
}
// 配置文件(返回 Code 类型,因为它们也是可编辑的文本格式)
if (FILE_EXTENSIONS.CONFIG.includes(ext)) {
return 'Code' as FileType
}
// 文本
if (FILE_EXTENSIONS.TEXT.includes(ext)) {
return 'Text' as FileType
}
// 默认为二进制
return 'Binary' as FileType
}
/**
* 判断是否为图片文件
*/
const isImageFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.IMAGE.includes(ext)
}
/**
* 判断是否为视频文件
*/
const isVideoFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.VIDEO.includes(ext)
}
/**
* 判断是否为音频文件
*/
const isAudioFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.AUDIO.includes(ext)
}
/**
* 判断是否为 PDF 文件
*/
const isPdfFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return ext === 'pdf'
}
/**
* 判断是否为 HTML 文件
*/
const isHtmlFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return ['html', 'htm'].includes(ext)
}
/**
* 判断是否为 Markdown 文件
*/
const isMarkdownFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return ['md', 'markdown'].includes(ext)
}
/**
* 判断是否为代码文件
*/
const isCodeFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.CODE.includes(ext)
}
/**
* 判断是否为文本文件
*/
const isTextFile = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.TEXT.includes(ext)
}
/**
* 判断文件是否可预览
*/
const isPreviewable = (filename: string): boolean => {
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
return FILE_EXTENSIONS.IMAGE.includes(ext) ||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
ext === 'pdf' ||
['html', 'htm'].includes(ext) ||
['md', 'markdown'].includes(ext)
return isPreviewableType(filename)
}
/**
* 判断文件是否可编辑
*/
const isEditable = (filename: string, fileSize: number): boolean => {
// 检查文件大小
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
return false
}
// 检查文件类型
if (!filename || typeof filename !== 'string') return false
const ext = filename.split('.').pop()?.toLowerCase() || ''
const ext = getExt(filename)
return FILE_EXTENSIONS.CODE.includes(ext) ||
FILE_EXTENSIONS.TEXT.includes(ext) ||
FILE_EXTENSIONS.CONFIG.includes(ext) ||
['html', 'htm', 'md', 'markdown'].includes(ext)
isTextEditable(filename) ||
isConfigFile(filename) ||
isHtmlFile(filename) ||
isMarkdownFile(filename)
}
/**
@@ -306,8 +194,6 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
isPdfFile,
isHtmlFile,
isMarkdownFile,
isCodeFile,
isTextFile,
isPreviewable,
isEditable,

View File

@@ -5,7 +5,7 @@
import { ref, watch, computed } from 'vue'
import { STORAGE_KEYS } from '@/utils/constants'
import { normalizePathSeparators } from '@/utils/pathHelpers'
import { normalizePathSeparators } from '@/utils/fileUtils'
import type { PathHistory } from '@/types/file-system'
export interface UsePathNavigationOptions {

View File

@@ -99,10 +99,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick, watchEffect } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue'
import { marked, renderMermaidDiagrams } from '@/utils/markedExtensions'
import 'highlight.js/styles/github-dark.css'
// 导入子组件
import Toolbar from './components/Toolbar.vue'
@@ -121,7 +121,6 @@ import { useCommonPaths } from './composables/useCommonPaths'
// 导入工具函数
import { getFileName, sortFileList } from '@/utils/fileUtils'
import { getParentPath } from '@/utils/pathHelpers'
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
import { listDir } from '@/api/system'
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
@@ -225,7 +224,7 @@ const fileOps = useFileOperations({
})
// 收藏夹
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin } = useFavorites()
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
// 路径导航
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath } =
@@ -463,50 +462,31 @@ const handleTogglePin = (path: string) => {
}
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
// 拖拽开始
onLongPressStart(event, index)
}
const handleLongPressCancel = () => {
// 拖拽取消
onLongPressCancel()
}
const handleDragStart = (event: DragEvent, index: number) => {
// 拖拽开始
onDragStart(event, index)
}
const handleDragOver = (event: DragEvent) => {
// 拖拽经过
onDragOver(event)
}
const handleDrop = (event: DragEvent, targetIndex: number) => {
// 放置
onDrop(event, targetIndex)
}
const handleDragEnd = () => {
// 拖拽结束
onDragEnd()
}
// 文件列表事件
const handleFileClick = async (file: FileItem) => {
// ZIP 浏览模式 - 暂时禁用
/*
if (false) { // ZIP 浏览模式已禁用
await zipBrowser.handleClick(file.path, fileList.value, {
selectFile: (f: FileItem) => {
selectedFileItem.value = f
},
isImage: isImageFile,
extractAndPreview: extractZipImageAndPreview,
extractAndRead: extractZipTextAndRead,
loadZipContents: loadZipDirectoryContents,
updateFileList: (files: FileItem[]) => {
fileList.value = files
}
})
return
}
*/
// 正常文件系统浏览
if (file.isDir) {
// 目录:使用 navigate 函数,确保历史记录正确更新
@@ -522,25 +502,6 @@ const handleFileDoubleClick = async (file: FileItem) => {
if (file.isDir) {
await navigate(file.path)
} else {
// 检查是否为 ZIP 文件 - 暂时禁用
/*
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (ext === 'zip' && !zipBrowser.isActive.value) {
// ZIP 文件:进入 ZIP 浏览模式
await zipBrowser.enter(file.path, {
saveBeforePath: () => {
// 保存当前路径
return filePath.value
},
loadZipContents: loadZipDirectoryContents,
updateFileList: (files: FileItem[]) => {
fileList.value = files
}
})
} else {
selectFile(file.path)
}
*/
selectFile(file.path)
}
}
@@ -573,7 +534,8 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
const trimmedName = newName.trim()
// 如果名称没有变化,直接返回
const oldName = oldPath.substring(oldPath.lastIndexOf('\\') + 1)
const lastSep = Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
const oldName = oldPath.substring(lastSep + 1)
if (trimmedName === oldName) {
editingFilePath.value = ''
editingFileName.value = ''
@@ -588,7 +550,7 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
}
// 构造新路径
const separator = oldPath.includes('\\') ? '\\' : '/'
const separator = getPathSeparator(oldPath)
const dirPath = oldPath.substring(0, oldPath.lastIndexOf(separator))
const newPath = dirPath + separator + trimmedName
@@ -649,10 +611,9 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
// 更新文件列表(保留收藏状态)
updateFileInList(oldPath, renamedFile)
// 如果重命名的是收藏的文件,更新收藏夹中的路径
// 如果重命名的是收藏的文件,更新收藏夹中的路径(保留置顶状态)
if (isFavorite(oldPath)) {
removeFav(oldPath)
toggleFav(renamedFile)
updateFavoritePath(oldPath, trimmedName)
}
Message.success(`✓ 重命名成功: ${trimmedName}`)
@@ -669,7 +630,6 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
// 针对常见错误提供友好提示
if (errorMsg.includes('being used by another process') ||
errorMsg.includes('being used by another process') ||
errorMsg.includes('被另一个进程占用')) {
errorMsg = '文件正在被其他程序使用,请先关闭该文件后重试'
if (selectedFileItem.value?.isDir) {
@@ -799,11 +759,6 @@ const handleCreateFile = async () => {
return
}
if (false) { // ZIP 浏览模式已禁用
Message.warning('ZIP 浏览模式下不支持创建文件')
return
}
showInputDialog(
UI_TEXT.CREATE_FILE,
UI_TEXT.ENTER_FILE_NAME,
@@ -831,11 +786,8 @@ const handleCreateFile = async () => {
return
}
// 构建完整路径
const fullPath = `${filePath.value}\\${fileName}`
try {
const newFile = await fileOps.createNewFile(fullPath)
const newFile = await fileOps.createNewFile(filePath.value, fileName)
Message.success(`✓ 文件 "${fileName}" 创建成功`)
addFileToList(newFile)
} catch (error: any) {
@@ -855,11 +807,6 @@ const handleCreateDir = async () => {
return
}
if (false) { // ZIP 浏览模式已禁用
Message.warning('ZIP 浏览模式下不支持创建文件夹')
return
}
showInputDialog(
UI_TEXT.CREATE_FOLDER,
UI_TEXT.ENTER_FOLDER_NAME,
@@ -887,11 +834,8 @@ const handleCreateDir = async () => {
return
}
// 构建完整路径
const fullPath = `${filePath.value}\\${folderName}`
try {
const newDir = await fileOps.createNewDir(fullPath)
const newDir = await fileOps.createNewDir(filePath.value, folderName)
Message.success(`✓ 文件夹 "${folderName}" 创建成功`)
addFileToList(newDir)
} catch (error: any) {
@@ -1033,7 +977,7 @@ const selectFile = async (path: string) => {
name: fileName,
isDir: false,
size: 0,
mod_time: '',
modified_time: '',
is_favorite: isFavorite(path)
}
}
@@ -1150,7 +1094,7 @@ const loadZipDirectoryContents = async (zipPath: string, currentDir: string): Pr
path: f.path,
isDir: f.isDir,
size: f.size || 0,
mod_time: f.mod_time || '',
modified_time: f.modified_time || '',
is_favorite: false
}))
@@ -1206,7 +1150,8 @@ const startResizeHorizontal = (event: MouseEvent) => {
const onMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startX
const newLeftWidth = Math.max(200, Math.min(containerRect.width - 200, startLeftWidth + deltaX))
const minPx = (DEFAULTS.MIN_PANEL_WIDTH / 100) * containerRect.width
const newLeftWidth = Math.max(minPx, Math.min(containerRect.width - minPx, startLeftWidth + deltaX))
const newLeftPercent = (newLeftWidth / containerRect.width) * 100
panelWidth.value = {

View File

@@ -0,0 +1,563 @@
<template>
<div class="markdown-editor-container">
<div class="editor-header">
<div class="title">
<icon-file />
<span>Markdown 编辑器</span>
<a-divider type="vertical" />
<a-tooltip content="自动保存已启用">
<span class="save-status" :class="{ 'saved': !hasChanges }">
{{ hasChanges ? '未保存' : '已保存' }}
</span>
</a-tooltip>
</div>
<div class="actions">
<a-tooltip content="清空内容">
<a-button size="small" type="outline" @click="clearContent">
<icon-delete />
</a-button>
</a-tooltip>
<a-tooltip content="全屏编辑">
<a-button size="small" type="outline" @click="toggleFullscreen">
<icon-expand />
</a-button>
</a-tooltip>
<PdfExportButton @export-complete="onExportComplete" />
</div>
</div>
<div class="editor-content" :class="{ 'fullscreen': isFullscreen }">
<div class="editor-panel" :class="{ 'expanded': isEditorExpanded }">
<div class="panel-header">
<span>编辑</span>
<div class="panel-controls">
<a-tooltip content="展开编辑器">
<a-button size="small" type="text" @click="toggleEditorExpand">
<icon-align-left v-if="!isEditorExpanded" />
<icon-shrink v-else />
</a-button>
</a-tooltip>
</div>
</div>
<div class="editor-wrapper">
<textarea
ref="textarea"
v-model="markdownContent"
class="markdown-textarea"
placeholder="在这里输入 Markdown 内容...
# 标题
## 二级标题
**粗体** *斜体*
- 列表项 1
- 列表项 2
\`\`\`javascript
console.log('Hello, World!')
\`\`\`
> 引用内容"
@input="handleInput"
@keydown="handleKeydown"
/>
</div>
</div>
<div class="resizer" @mousedown="startResize"></div>
<div class="preview-panel" :class="{ 'expanded': isPreviewExpanded }">
<div class="panel-header">
<span>预览</span>
<div class="panel-controls">
<a-tooltip content="展开预览">
<a-button size="small" type="text" @click="togglePreviewExpand">
<icon-align-left v-if="!isPreviewExpanded" />
<icon-shrink v-else />
</a-button>
</a-tooltip>
<a-tooltip content="刷新预览">
<a-button size="small" type="text" @click="renderPreview">
<icon-sync />
</a-button>
</a-tooltip>
</div>
</div>
<div class="preview-wrapper">
<MarkdownPreview :content="markdownContent" />
</div>
</div>
</div>
<div class="editor-footer">
<div class="status">
<span>{{ wordCount }} 字符 | {{ lineCount }} | {{ readingTime }} 分钟阅读</span>
</div>
<div class="shortcuts">
<a-tooltip content="快捷键: Ctrl + S 保存">
<a-button size="small" @click="saveContent" :disabled="!hasChanges">
<icon-save />
保存
</a-button>
</a-tooltip>
<a-tooltip content="快捷键: Ctrl + / 切换预览">
<a-button size="small" @click="togglePreview">
<icon-eye />
</a-button>
</a-tooltip>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import MarkdownPreview from './MarkdownPreview.vue'
import PdfExportButton from './PdfExportButton.vue'
import IconFile from '@arco-design/web-vue/es/icon/icon-file'
import IconDelete from '@arco-design/web-vue/es/icon/icon-delete'
import IconExpand from '@arco-design/web-vue/es/icon/icon-expand'
import IconShrink from '@arco-design/web-vue/es/icon/icon-shrink'
import IconSync from '@arco-design/web-vue/es/icon/icon-sync'
import IconSave from '@arco-design/web-vue/es/icon/icon-save'
import IconEye from '@arco-design/web-vue/es/icon/icon-eye'
import IconAlignLeft from '@arco-design/web-vue/es/icon/icon-align-left'
export default {
name: 'MarkdownEditor',
components: {
MarkdownPreview,
PdfExportButton,
IconFile,
IconDelete,
IconExpand,
IconShrink,
IconSync,
IconSave,
IconEye,
IconAlignLeft
},
emits: ['content-change', 'update:content', 'save'],
props: {
content: {
type: String,
default: ''
}
},
setup(props, { emit }) {
const markdownContent = ref(props.content)
const textarea = ref(null)
const hasChanges = ref(false)
const lastSavedContent = ref('')
const isFullscreen = ref(false)
const isEditorExpanded = ref(false)
const isPreviewExpanded = ref(false)
const showPreview = ref(true)
// 计算属性
const wordCount = computed(() => {
return markdownContent.value.length
})
const lineCount = computed(() => {
return markdownContent.value.split('\n').length
})
const readingTime = computed(() => {
// 平均阅读速度:每分钟 200 字符
const wordsPerMinute = 200
const minutes = Math.ceil(wordCount.value / wordsPerMinute)
return minutes
})
// 方法
const handleInput = () => {
hasChanges.value = markdownContent.value !== lastSavedContent.value
emit('content-change', markdownContent.value)
emit('update:content', markdownContent.value)
}
const handleKeydown = (event) => {
// Ctrl + S 保存
if (event.ctrlKey && event.key === 's') {
event.preventDefault()
saveContent()
}
// Ctrl + / 切换预览
if (event.ctrlKey && event.key === '/') {
event.preventDefault()
togglePreview()
}
}
const saveContent = () => {
lastSavedContent.value = markdownContent.value
hasChanges.value = false
emit('save', markdownContent.value)
Message.success('内容已保存')
// 保存到本地存储
localStorage.setItem('u-desk-markdown-content', markdownContent.value)
}
const onExportComplete = () => {
Message.success('PDF 导出完成')
}
// 自动调整 textarea 高度
const adjustTextareaHeight = () => {
if (textarea.value) {
textarea.value.style.height = 'auto'
textarea.value.style.height = textarea.value.scrollHeight + 'px'
}
}
// 窗口大小调整
const startResize = (event) => {
if (!showPreview.value) return
const startX = event.clientX
const startWidth = document.querySelector('.editor-panel').offsetWidth
const startPreviewWidth = document.querySelector('.preview-panel').offsetWidth
const doResize = (moveEvent) => {
const deltaX = moveEvent.clientX - startX
const newEditorWidth = startWidth + deltaX
const newPreviewWidth = startPreviewWidth - deltaX
if (newEditorWidth > 100 && newPreviewWidth > 100) {
document.querySelector('.editor-panel').style.width = newEditorWidth + 'px'
document.querySelector('.preview-panel').style.width = newPreviewWidth + 'px'
}
}
const stopResize = () => {
document.removeEventListener('mousemove', doResize)
document.removeEventListener('mouseup', stopResize)
}
document.addEventListener('mousemove', doResize)
document.addEventListener('mouseup', stopResize)
}
// 切换功能
const togglePreview = () => {
showPreview.value = !showPreview.value
if (showPreview.value) {
// 恢复预览时重新调整大小
nextTick(() => {
adjustTextareaHeight()
})
}
}
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
if (isFullscreen.value) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
const toggleEditorExpand = () => {
isEditorExpanded.value = !isEditorExpanded.value
if (isEditorExpanded.value && isPreviewExpanded.value) {
isPreviewExpanded.value = false
}
nextTick(() => {
adjustTextareaHeight()
})
}
const togglePreviewExpand = () => {
isPreviewExpanded.value = !isPreviewExpanded.value
if (isPreviewExpanded.value && isEditorExpanded.value) {
isEditorExpanded.value = false
}
}
const clearContent = () => {
Modal.confirm({
title: '确认清空',
content: '确定要清空所有内容吗?此操作不可恢复。',
okButtonProps: { status: 'danger' },
onOk: () => {
markdownContent.value = ''
hasChanges.value = true
lastSavedContent.value = ''
emit('content-change', '')
Message.success('内容已清空')
}
})
}
const renderPreview = () => {
// 强制重新渲染预览
const previewElement = document.querySelector('.preview-wrapper')
if (previewElement) {
previewElement.style.opacity = '0'
nextTick(() => {
previewElement.style.opacity = '1'
})
}
}
// 自动保存定时器
let autoSaveTimer = null
// 监听内容变化:自动保存 + 调整高度
watch(markdownContent, () => {
// 自动保存
if (hasChanges.value) {
clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(() => {
saveContent()
}, 5000)
}
// 调整高度
nextTick(() => {
adjustTextareaHeight()
})
}, { deep: true })
// 初始化
onMounted(() => {
nextTick(() => {
adjustTextareaHeight()
})
})
onUnmounted(() => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
})
// 导出方法
const getMarkdownContent = () => {
return markdownContent.value
}
const setMarkdownContent = (content) => {
markdownContent.value = content
hasChanges.value = content !== lastSavedContent.value
}
return {
markdownContent,
textarea,
hasChanges,
wordCount,
lineCount,
readingTime,
isFullscreen,
isEditorExpanded,
isPreviewExpanded,
showPreview,
handleInput,
handleKeydown,
saveContent,
onExportComplete,
getMarkdownContent,
setMarkdownContent,
startResize,
togglePreview,
toggleFullscreen,
toggleEditorExpand,
togglePreviewExpand,
clearContent,
renderPreview
}
}
}
</script>
<style scoped>
.markdown-editor-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-2);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.markdown-editor-container.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
border-radius: 0;
height: 100vh;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--color-bg-1);
border-bottom: 1px solid var(--color-border);
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--color-text-1);
}
.save-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
background: var(--color-warning-light-1);
color: var(--color-warning-6);
}
.save-status.saved {
background: var(--color-success-light-1);
color: var(--color-success-6);
}
.actions {
display: flex;
gap: 8px;
}
.editor-content {
flex: 1;
display: flex;
overflow: hidden;
}
.editor-panel,
.preview-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 100px;
}
.editor-panel.expanded {
flex: 2;
}
.preview-panel.expanded {
flex: 2;
}
.editor-panel {
border-right: 1px solid var(--color-border);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: var(--color-fill-2);
border-bottom: 1px solid var(--color-border);
font-size: 12px;
font-weight: 500;
color: var(--color-text-2);
}
.panel-controls {
display: flex;
gap: 4px;
}
.editor-wrapper {
flex: 1;
padding: 16px;
overflow: auto;
}
.preview-wrapper {
flex: 1;
padding: 16px;
overflow: auto;
background: var(--color-bg-1);
transition: opacity 0.2s;
}
.resizer {
width: 4px;
cursor: col-resize;
background: var(--color-border);
transition: background-color 0.2s;
}
.resizer:hover {
background: var(--color-primary-light-3);
}
.markdown-textarea {
width: 100%;
height: 100%;
min-height: 200px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
resize: none;
outline: none;
background: var(--color-bg-1);
color: var(--color-text-1);
}
.markdown-textarea:focus {
border-color: var(--color-primary-6);
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.2);
}
.markdown-textarea::placeholder {
color: var(--color-text-3);
}
.editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: var(--color-bg-1);
border-top: 1px solid var(--color-border);
}
.status {
font-size: 12px;
color: var(--color-text-2);
}
.shortcuts {
display: flex;
gap: 8px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.editor-content {
flex-direction: column;
}
.editor-panel {
border-right: none;
border-bottom: 1px solid var(--color-border);
}
.resizer {
height: 4px;
width: 100%;
cursor: row-resize;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="md-preview">
<div v-html="renderedMarkdown" class="markdown-body"></div>
</div>
</template>
<script>
import { marked } from '@/utils/markedExtensions'
function sanitizeHtml(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<script[^>]*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
.replace(/javascript\s*:/gi, 'blocked:')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<object[\s\S]*?<\/object>/gi, '')
.replace(/<embed[^>]*>/gi, '')
.replace(/<form[\s\S]*?<\/form>/gi, '')
}
export default {
name: 'MarkdownPreview',
props: {
content: {
type: String,
default: ''
}
},
computed: {
renderedMarkdown() {
return sanitizeHtml(marked(this.content))
}
}
}
</script>
<style scoped>
.md-preview {
padding: 20px;
background: white;
border-radius: 8px;
min-height: 200px;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<a-tooltip content="导出" position="bottom">
<a-button
size="small"
type="outline"
@click="exportPDF"
:loading="exporting"
>
<template #icon>
<icon-file-pdf />
</template>
</a-button>
</a-tooltip>
</template>
<script>
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
export default {
name: 'PdfExportButton',
emits: ['export-start', 'export-complete'],
props: {
title: {
type: String,
default: '文档'
},
containerSelector: {
type: String,
default: '.markdown-body'
}
},
setup(props, { emit }) {
const exporting = ref(false)
function escapeHtml(str) {
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
return str.replace(/[&<>"']/g, c => map[c])
}
function stripScripts(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<script[^>]*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<object[\s\S]*?<\/object>/gi, '')
.replace(/<embed[^>]*>/gi, '')
}
const exportPDF = async () => {
if (exporting.value) return
exporting.value = true
emit('export-start')
try {
// 获取渲染后的 Markdown 内容
const contentElement = document.querySelector(props.containerSelector)
if (!contentElement) {
Message.error('没有可导出的内容')
exporting.value = false
return
}
const htmlContent = stripScripts(contentElement.innerHTML)
if (!htmlContent || !htmlContent.trim()) {
Message.error('内容为空,无法导出')
exporting.value = false
return
}
// 打开打印窗口
const printWindow = window.open('', '_blank', 'width=800,height=600')
if (!printWindow) {
Message.error('无法打开打印窗口,请检查浏览器弹窗设置')
exporting.value = false
return
}
// 写入打印内容
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${escapeHtml(props.title)}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
padding: 40px;
max-width: 800px;
margin: 0 auto;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
page-break-after: avoid;
}
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1em; }
h5 { font-size: 0.875em; }
h6 { font-size: 0.85em; color: #6a737d; }
p {
margin-top: 0;
margin-bottom: 16px;
}
ul, ol {
padding-left: 2em;
margin-top: 0;
margin-bottom: 16px;
}
li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 16px 0;
}
code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
}
pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 6px;
margin-top: 0;
margin-bottom: 16px;
page-break-inside: avoid;
}
pre code {
padding: 0;
margin: 0;
background-color: transparent;
border: 0;
}
table {
border-spacing: 0;
border-collapse: collapse;
margin-top: 0;
margin-bottom: 16px;
width: 100%;
page-break-inside: avoid;
}
table th,
table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
table th {
font-weight: 600;
background-color: #f6f8fa;
}
table tr:nth-child(even) {
background-color: #f8f8f8;
}
img {
max-width: 100%;
page-break-inside: avoid;
}
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
@media print {
body {
padding: 0;
}
@page {
margin: 15mm;
size: A4;
}
}
</style>
</head>
<body>
${htmlContent}
</body>
</html>
`)
printWindow.document.close()
// 等待内容加载完成后自动打印
let printTriggered = false
printWindow.onload = () => {
printTriggered = true
printWindow.print()
}
// 兼容性处理:如果 onload 未触发
setTimeout(() => {
if (!printTriggered && printWindow && !printWindow.closed) {
printWindow.print()
}
}, 500)
Message.success('请在打印对话框中选择"另存为 PDF"')
emit('export-complete')
} catch (error) {
console.error('PDF导出失败:', error)
Message.error(`PDF导出失败${error.message || '未知错误'}`)
} finally {
exporting.value = false
}
}
return {
exporting,
exportPDF
}
}
}
</script>

View File

@@ -66,8 +66,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
return
}
favoriteFiles.value.sort((a, b) => {
const timeA = a.created_at || 0
const timeB = b.created_at || 0
const timeA = a.addedAt || 0
const timeB = b.addedAt || 0
return timeB - timeA // 倒序:最新的在上面
})
}
@@ -106,8 +106,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
favoriteFiles.value.push({
path: item.path,
name: item.name,
is_dir: item.is_dir || false,
created_at: Date.now(), // 添加时间戳(用于 getSortedFavorites
isDir: item.isDir || false,
addedAt: Date.now(),
})
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
@@ -201,8 +201,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
const getSortedFavorites = (order = 'desc') => {
const sorted = [...favoriteFiles.value]
sorted.sort((a, b) => {
const timeA = a.created_at || 0
const timeB = b.created_at || 0
const timeA = a.addedAt || 0
const timeB = b.addedAt || 0
return order === 'desc' ? timeB - timeA : timeA - timeB
})
return sorted
@@ -255,9 +255,27 @@ export function useFavoriteFiles(storageKey, options = {}) {
return true
}
// 组件挂载时加载数据(不自动排序,保持用户拖拽的顺序
// 旧字段名 → 新字段名迁移(一次性,迁移后自动回写
const migrateFieldNames = (list) => {
if (!Array.isArray(list)) return
const map = { is_dir: 'isDir', created_at: 'addedAt' }
let changed = false
list.forEach(item => {
for (const [old, newKey] of Object.entries(map)) {
if (old in item) {
if (!(newKey in item)) item[newKey] = item[old]
delete item[old]
changed = true
}
}
})
if (changed) save(list)
}
// 组件挂载时加载数据并迁移旧字段
onMounted(() => {
load()
migrateFieldNames(favoriteFiles.value)
})
return {

View File

@@ -1,34 +0,0 @@
/**
* LocalStorage composable
* 通用的 localStorage 操作
*/
import { watch, type Ref } from 'vue'
export function useLocalStorage<T>(
key: string,
defaultValue: T,
storage: Storage = localStorage
): [Ref<T>, (value: T) => void, () => void] {
const stored = storage.getItem(key)
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue)
const setValue = (newValue: T) => {
value.value = newValue
}
const clearValue = () => {
value.value = defaultValue
storage.removeItem(key)
}
watch(value, (newValue) => {
try {
storage.setItem(key, JSON.stringify(newValue))
} catch (e) {
console.warn(`Failed to save ${key} to localStorage:`, e)
}
}, { deep: true })
return [value, setValue, clearValue]
}

View File

@@ -66,12 +66,20 @@ export const useConfigStore = defineStore('config', () => {
const defaultTab = computed(() => appConfig.value.defaultTab)
// ==================== 核心方法 ====================
let _retryCount = 0
const MAX_RETRIES = 30 // 最多重试30次约30秒
/**
* 加载配置
*/
const loadConfig = async () => {
if (!window.go?.main?.App) {
console.warn('Wails 绑定未准备好1秒后重试')
_retryCount++
if (_retryCount > MAX_RETRIES) {
console.error('Wails 绑定初始化超时,使用默认配置')
useDefaultConfig()
return
}
setTimeout(loadConfig, 1000)
return
}
@@ -104,9 +112,10 @@ export const useConfigStore = defineStore('config', () => {
appConfig.value = {
tabs: [
{ key: 'file-system', title: '文件管理', visible: true, enabled: true },
{ key: 'db-cli', 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'],
visibleTabs: ['file-system', 'db-cli', 'markdown-editor'],
defaultTab: 'file-system'
}
}

View File

@@ -50,6 +50,292 @@ body {
scrollbar-color: var(--color-border-2, rgba(0, 0, 0, 0.1)) transparent;
}
/* Highlight.js CSS */
.hljs {
display: block;
overflow-x: auto;
padding: 1em;
background: #f6f8fa;
border-radius: 6px;
color: #333;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-addition {
color: #d73a49;
}
.hljs-number,
.hljs-string,
.hljs-meta .hljs-string,
.hljs-literal,
.hljs-doctag,
.hljs-regexp {
color: #032f62;
}
.hljs-title,
.hljs-section,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #6f42c1;
}
.hljs-attribute,
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-class .hljs-title,
.hljs-type {
color: #005cc5;
}
.hljs-symbol,
.hljs-bullet,
.hljs-subst,
.hljs-meta,
.hljs-meta .hljs-keyword,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-link {
color: #735c0f;
}
.hljs-built_in,
.hljs-deletion {
color: #d73a49;
}
.hljs-formula {
background: #f6f8fa;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
/* GitHub 风格的 Markdown 预览样式 */
.markdown-body {
line-height: 1.6;
color: #333;
font-size: 14px;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
.markdown-body h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
.markdown-body h3 { font-size: 1.25em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h5 { font-size: 0.875em; }
.markdown-body h6 { font-size: 0.85em; color: #6a737d; }
.markdown-body p {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 2em;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.markdown-body blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 16px 0;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
}
.markdown-body pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 6px;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body pre code {
padding: 0;
margin: 0;
background-color: transparent;
border: 0;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
margin-top: 0;
margin-bottom: 16px;
display: block;
width: 100%;
overflow: auto;
}
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
.markdown-body table th {
font-weight: 600;
background-color: #f6f8fa;
}
.markdown-body table tr:nth-child(even) {
background-color: #f8f8f8;
}
.markdown-body img {
max-width: 100%;
box-sizing: content-box;
background-color: #fff;
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
/* 打印样式 */
@media print {
.no-print {
display: none !important;
}
.markdown-body {
max-width: 100%;
margin: 0;
padding: 20px;
font-size: 12pt;
line-height: 1.4;
}
.markdown-body h1 {
font-size: 24pt;
margin-bottom: 12pt;
border-bottom: 1px solid #ccc;
}
.markdown-body h2 {
font-size: 18pt;
margin-bottom: 10pt;
border-bottom: 1px solid #eee;
}
.markdown-body h3 {
font-size: 14pt;
margin-bottom: 8pt;
}
.markdown-body p {
margin-bottom: 10pt;
}
.markdown-body ul,
.markdown-body ol {
margin-bottom: 10pt;
}
.markdown-body li {
margin-bottom: 4pt;
}
.markdown-body table {
border-collapse: collapse;
margin-bottom: 12pt;
width: 100%;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.markdown-body th {
background-color: #f5f5f5;
font-weight: bold;
}
.markdown-body img {
max-width: 100%;
height: auto;
}
.markdown-body blockquote {
border-left: 4px solid #ddd;
margin: 16px 0;
padding: 10px 20px;
color: #666;
}
.markdown-body code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-body pre {
background-color: #f5f5f5;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
}
.markdown-body pre code {
background-color: transparent;
padding: 0;
}
}
/* Markdown 标题锚点链接样式 */
.heading {
position: relative;
@@ -83,4 +369,34 @@ body {
/* 平滑滚动 */
html {
scroll-behavior: smooth;
}
/* Tooltip 全局样式 */
.arco-tooltip {
font-size: 12px !important;
}
.arco-tooltip-content {
background: var(--color-bg-5) !important;
color: var(--color-text-1) !important;
padding: 6px 10px !important;
border-radius: 6px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
max-width: 240px;
line-height: 1.5;
white-space: nowrap;
}
.arco-tooltip-content::before {
background: var(--color-bg-5) !important;
}
.arco-tooltip-content-white {
background: var(--color-bg-1) !important;
border: 1px solid var(--color-border-2) !important;
color: var(--color-text-1) !important;
}
.arco-tooltip-content-white::before {
background: var(--color-bg-1) !important;
}

View File

@@ -6,8 +6,9 @@
import {
javascript, json, yaml, html, css,
cpp, rust, go, python, php, sql, markdown, java,
shell, StreamLanguage
shell, powerShell, dart, StreamLanguage
} from './codemirrorExports'
import { getCmLanguage } from './languageMap'
const languageCache = new Map()
@@ -17,14 +18,12 @@ const languageCache = new Map()
* @returns {Extension|null} CodeMirror 语言扩展
*/
export function loadLanguageExtension(language) {
// 检查缓存
if (languageCache.has(language)) {
return languageCache.get(language)
}
let extension = null
// 使用静态导入的语言包
switch (language) {
case 'javascript':
extension = javascript({ jsx: true })
@@ -74,6 +73,12 @@ export function loadLanguageExtension(language) {
case 'sh':
extension = StreamLanguage.define(shell)
break
case 'powershell':
extension = StreamLanguage.define(powerShell)
break
case 'dart':
extension = StreamLanguage.define(dart)
break
default:
return null
}
@@ -90,34 +95,5 @@ export function loadLanguageExtension(language) {
* @returns {string} 语言名称
*/
export function getLanguageFromExtension(extension) {
const ext = extension.toLowerCase()
const langMap = {
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript',
json: 'json',
yaml: 'yaml', yml: 'yaml',
html: 'html', htm: 'html',
css: 'css', scss: 'css', sass: 'css', less: 'css',
cpp: 'cpp', c: 'c', cc: 'cpp', cxx: 'cpp', h: 'cpp', hpp: 'cpp', hxx: 'cpp',
rust: 'rust', rs: 'rust',
go: 'go',
python: 'python', py: 'python', pyw: 'python',
php: 'php',
sql: 'sql',
markdown: 'markdown', md: 'markdown',
java: 'java',
sh: 'shell', bash: 'shell', shell: 'shell', zsh: 'shell'
}
return langMap[ext] || 'text'
}
/**
* 预加载常用语言包
* 用于在应用启动时预热缓存
*/
export function preloadCommonLanguages() {
// 现在是同步的,不需要 Promise.all
;['javascript', 'json', 'markdown', 'python', 'sql'].forEach(loadLanguageExtension)
return getCmLanguage(extension)
}

View File

@@ -25,5 +25,7 @@ export { sql } from '@codemirror/lang-sql'
export { markdown } from '@codemirror/lang-markdown'
export { java } from '@codemirror/lang-java'
// Legacy language modes (shell)
// Legacy language modes (shell, powershell, dart)
export { shell } from '@codemirror/legacy-modes/mode/shell'
export { powerShell } from '@codemirror/legacy-modes/mode/powershell'
export { dart } from '@codemirror/legacy-modes/mode/clike'

View File

@@ -73,9 +73,9 @@ export const FILE_EXTENSIONS = {
CODE: [
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
'scala', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
'tex', 'm', 'r', 'matlab', 'latex', 'rst', 'adoc'
'm', 'r', 'matlab'
],
// 配置文件(可编辑的文本格式)
@@ -154,6 +154,7 @@ export const FILE_ICONS = {
RUST: '🦀',
PHP: '🐘',
RUBY: '💎',
DART: '🎯',
// 数据库
DATABASE: '🗄️',
@@ -266,6 +267,8 @@ const initIconMap = () => {
'gem': FILE_ICONS.RUBY,
// SQL
'sql': FILE_ICONS.SQL,
// Dart
'dart': FILE_ICONS.DART,
}
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))

View File

@@ -1,41 +0,0 @@
/**
* 文件类型工具函数
*/
import { FILE_EXTENSIONS } from './constants'
// 获取文件扩展名
export const getExt = (path) => {
if (!path) return ''
const dot = path.lastIndexOf('.')
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
if (dot === -1 || dot < slash) return ''
return path.slice(dot + 1).toLowerCase()
}
// 文件类型检查
export const isImage = (path) => FILE_EXTENSIONS.IMAGE.includes(getExt(path))
export const isVideo = (path) => FILE_EXTENSIONS.VIDEO_BROWSER.includes(getExt(path))
export const isAudio = (path) => FILE_EXTENSIONS.AUDIO.includes(getExt(path))
export const isPdf = (path) => getExt(path) === 'pdf'
export const isHtml = (path) => { const e = getExt(path); return e === 'html' || e === 'htm' }
export const isMarkdown = (path) => { const e = getExt(path); return e === 'md' || e === 'markdown' }
export const isCode = (path) => FILE_EXTENSIONS.CODE.includes(getExt(path))
export const isArchive = (path) => FILE_EXTENSIONS.ARCHIVE.includes(getExt(path))
export const isDatabase = (path) => FILE_EXTENSIONS.DATABASE.includes(getExt(path))
export const isExecutable = (path) => FILE_EXTENSIONS.EXECUTABLE.includes(getExt(path))
// 复合检查
export const isVideoAny = (path) => {
const e = getExt(path)
return FILE_EXTENSIONS.VIDEO_BROWSER.includes(e) || FILE_EXTENSIONS.VIDEO_EXTERNAL.includes(e)
}
export const isEditableDoc = (path) => {
const e = getExt(path)
return FILE_EXTENSIONS.DOCUMENT.includes(e) && e !== 'pdf'
}
export const isBinary = (path) => isVideoAny(path) || isAudio(path) || isArchive(path) || isExecutable(path)
export const canPreview = (path) => isImage(path) || isVideo(path) || isAudio(path) || isPdf(path)
export const canEdit = (path) => !isBinary(path) && !isImage(path)

View File

@@ -2,10 +2,8 @@
* Office 文件预览处理器
*/
// 获取文件扩展名(统一方法)
function getExt(fileName) {
return fileName?.split('.').pop()?.toLowerCase() || ''
}
import { escapeHtml } from './fileUtils'
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
// 每批加载行数
const BATCH_ROWS = 200
@@ -37,10 +35,10 @@ export async function previewExcel(file, container) {
.excel-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
.excel-content{flex:1;overflow:auto;padding:12px}
.excel-content table{width:100%;border-collapse:collapse;font-size:13px}
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
.excel-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
.excel-content th.row-num{z-index:3}
.excel-content th.row-num{z-index:3;top:0;left:0}
.excel-content tr:hover td{background:var(--color-fill-1)}
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
</style>
@@ -61,8 +59,6 @@ export async function previewExcel(file, container) {
// 渲染表格(带行号)
const renderTable = (data, startRow = 0) => {
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
let html = '<table><thead><tr><th class="row-num">#</th>'
if (data[0]) {
data[0].forEach((cell, i) => {
@@ -86,7 +82,6 @@ export async function previewExcel(file, container) {
// 追加行
const appendRows = (data, fromRow, toRow) => {
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const tbody = contentEl.querySelector('tbody')
if (!tbody) return
@@ -191,15 +186,8 @@ export async function previewWord(file, container) {
return { success: true }
}
// 文件类型判断
const OFFICE_EXTS = { xlsx: 1, xls: 1, docx: 1, doc: 1 }
const EXCEL_EXTS = { xlsx: 1, xls: 1 }
const WORD_EXTS = { docx: 1, doc: 1 }
export const isOfficeFile = (name) => OFFICE_EXTS[getExt(name)] || false
export const isExcelFile = (name) => EXCEL_EXTS[getExt(name)] || false
export const isWordFile = (name) => WORD_EXTS[getExt(name)] || false
export const isCsvFile = (name) => ['csv', 'tsv'].includes(getExt(name))
// 文件类型判断(从 fileTypeHelpers 导入)
export { isOfficeFile, isExcelFile, isWordFile, isCsvFile }
// CSV/TSV 预览处理器(原生实现,支持滚动加载)
export async function previewCsv(file, container) {
@@ -243,8 +231,6 @@ export async function previewCsv(file, container) {
const delimiter = file.name.endsWith('.tsv') ? '\t' : ','
const rows = lines.map(line => parseLine(line, delimiter))
const escapeHtml = (str) => str == null ? '' : String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
container.innerHTML = `
<div class="csv-preview">
<div class="csv-info">📋 ${file.name}</div>
@@ -255,10 +241,10 @@ export async function previewCsv(file, container) {
.csv-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
.csv-content{flex:1;overflow:auto;padding:12px}
.csv-content table{width:100%;border-collapse:collapse;font-size:13px}
.csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap}
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;left:0;z-index:2}
.csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
.csv-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
.csv-content th.row-num{z-index:3}
.csv-content th.row-num{z-index:3;top:0;left:0}
.csv-content tr:hover td{background:var(--color-fill-1)}
.csv-content tr:hover .row-num{background:var(--color-fill-2)}
</style>

View File

@@ -6,7 +6,7 @@
*/
import { FILE_EXTENSIONS } from './constants'
import { getExt } from './pathHelpers'
import { getExt } from './fileUtils'
/**
* 可预览的文件类型(有专门的预览处理)

View File

@@ -5,8 +5,51 @@
* @description 提供文件相关的通用工具函数,避免代码重复
*/
import { normalizePathSeparators } from './pathHelpers.js'
import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT, FILE_EXTENSIONS } from './constants'
import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT } from './constants'
/**
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
* @type {RegExp}
*/
export const PATH_SEPARATOR_REGEX = /[/\\]/
/**
* 规范化路径分隔符(统一为正斜杠)
* @param {string} path - 文件路径
* @returns {string} 规范化后的路径
*/
export const normalizePathSeparators = (path) => {
if (!path) return ''
return path.replace(/\\/g, '/')
}
/**
* HTML 转义,防止 XSS
* @param {string} str - 原始字符串
* @returns {string} 转义后的字符串
*/
export const escapeHtml = (str) => {
if (str == null) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 获取文件扩展名(路径安全)
* @param {string} path - 文件路径
* @returns {string} 扩展名(小写,不含点号)
*/
export const getExt = (path) => {
if (!path) return ''
const dot = path.lastIndexOf('.')
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
if (dot === -1 || dot < slash) return ''
return path.slice(dot + 1).toLowerCase()
}
/**
* 格式化文件大小
@@ -46,34 +89,29 @@ export function formatBytes(bytes) {
*/
export function getFileName(path) {
if (!path) return ''
// 后端已统一返回 / 路径,直接分割
const parts = path.split('/')
const parts = path.split(PATH_SEPARATOR_REGEX)
return parts[parts.length - 1] || path
}
/**
* 从文件路径中提取文件扩展名
* 分割路径为多个部分
* @param {string} path - 文件路径
* @returns {string} 扩展名(小写,不含点号)
*
* @example
* getFileExtension('/path/to/file.txt') // "txt"
* getFileExtension('/path/to/file.TXT') // "txt"
* getFileExtension('/path/to/file') // ""
* @returns {string[]} 路径数组
*/
export function getFileExtension(path) {
if (!path) return ''
export const splitPath = (path) => {
if (!path) return []
return path.split(PATH_SEPARATOR_REGEX)
}
/**
* 获取文件名(不含扩展名)
* @param {string} path - 文件路径
* @returns {string} 文件名(不含扩展名)
*/
export const getFileNameWithoutExt = (path) => {
const fileName = getFileName(path)
const lastDotIndex = fileName.lastIndexOf('.')
if (lastDotIndex === -1 || lastDotIndex === fileName.length - 1) {
return ''
}
return fileName.substring(lastDotIndex + 1).toLowerCase()
const lastDot = fileName.lastIndexOf('.')
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
}
/**
@@ -97,51 +135,12 @@ export function getFileIcon(fileInfo) {
}
// 获取文件扩展名
const ext = getFileExtension(fileInfo.name)
const ext = getExt(fileInfo.name)
// 从映射表中查找图标
return FILE_ICON_MAP.get(ext) || FILE_ICONS.FILE
}
/**
* 判断文件是否为图片
* @param {string} path - 文件路径
* @returns {boolean} 是否为图片文件
*/
export function isImageFile(path) {
const ext = getFileExtension(path)
return FILE_EXTENSIONS.IMAGE.includes(ext)
}
/**
* 判断文件是否为视频
* @param {string} path - 文件路径
* @returns {boolean} 是否为视频文件
*/
export function isVideoFile(path) {
const ext = getFileExtension(path)
return [...FILE_EXTENSIONS.VIDEO_BROWSER, ...FILE_EXTENSIONS.VIDEO_EXTERNAL].includes(ext)
}
/**
* 判断文件是否为音频
* @param {string} path - 文件路径
* @returns {boolean} 是否为音频文件
*/
export function isAudioFile(path) {
const ext = getFileExtension(path)
return FILE_EXTENSIONS.AUDIO.includes(ext)
}
/**
* 判断文件是否为PDF
* @param {string} path - 文件路径
* @returns {boolean} 是否为PDF文件
*/
export function isPdfFile(path) {
return getFileExtension(path) === 'pdf'
}
/**
* 规范化文件路径将反斜杠转换为正斜杠并进行URL编码
* @param {string} path - 原始路径
@@ -189,7 +188,7 @@ export function normalizeFilePath(path, encode = false) {
* getFileTypeName('unknown.xyz') // "XYZ文件"
*/
export function getFileTypeName(path) {
const ext = getFileExtension(path)
const ext = getExt(path)
const extUpper = ext.toUpperCase()
// 图片
@@ -226,23 +225,6 @@ export function getFileTypeName(path) {
return ext ? `${extUpper}文件` : '文件'
}
/**
* 判断文件是否为二进制文件
* @param {string} path - 文件路径
* @returns {boolean} 是否为二进制文件
*/
export function isBinaryFile(path) {
const ext = getFileExtension(path)
const binaryExtensions = [
'exe', 'dll', 'so', 'dylib', // 可执行文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', // 压缩文件
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', // Office文档
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'mp3', 'mp4', // 媒体文件
'eot', 'ttf', 'otf', 'woff', 'woff2', // 字体文件
]
return binaryExtensions.includes(ext)
}
/**
* 检查路径是否为绝对路径
* @param {string} path - 文件路径
@@ -278,6 +260,15 @@ export function joinPaths(...parts) {
return parts.join('/').replace(/\/+/g, '/')
}
/**
* 获取路径使用的分隔符Windows 反斜杠或 Unix 正斜杠)
* @param {string} path - 文件路径
* @returns {string} 分隔符 '\\' 或 '/'
*/
export function getPathSeparator(path) {
return path.includes('\\') ? '\\' : '/'
}
/**
* 获取父目录路径
* @param {string} path - 文件路径
@@ -290,14 +281,24 @@ export function joinPaths(...parts) {
export function getParentPath(path) {
if (!path) return ''
const normalizedPath = normalizeFilePath(path)
const normalizedPath = normalizePathSeparators(path)
const lastSlashIndex = normalizedPath.lastIndexOf('/')
if (lastSlashIndex <= 0) {
return '/' // 根目录
if (/^[A-Za-z]:$/.test(normalizedPath)) {
return normalizedPath + '/'
}
return normalizedPath || '/'
}
return normalizedPath.substring(0, lastSlashIndex)
const parentPath = normalizedPath.substring(0, lastSlashIndex)
// 盘符根目录下文件E:/file.txt → E:/
if (/^[A-Za-z]:$/.test(parentPath)) {
return parentPath + '/'
}
return parentPath || '/'
}
/**

View File

@@ -0,0 +1,129 @@
/**
* 统一语言映射
* 供 highlight.jsMarkdown 预览)和 CodeMirror代码编辑器共用
*/
/**
* 文件扩展名/缩写 → 语言标识符
* - hljs: 用于 markedExtensions.ts 的代码块高亮
* - cm: 用于 codeMirrorLoader.js 的编辑器语言
* 值为 false 表示该扩展名不对应任何编程语言
*/
const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
// === JavaScript / TypeScript ===
js: { hljs: 'javascript', cm: 'javascript' },
jsx: { hljs: 'javascript', cm: 'javascript' },
mjs: { hljs: 'javascript', cm: 'javascript' },
cjs: { hljs: 'javascript', cm: 'javascript' },
ts: { hljs: 'typescript', cm: 'typescript' },
tsx: { hljs: 'typescript', cm: 'typescript' },
cts: { hljs: 'typescript', cm: 'typescript' },
mts: { hljs: 'typescript', cm: 'typescript' },
// === Web ===
html: { hljs: 'xml', cm: 'html' },
htm: { hljs: 'xml', cm: 'html' },
css: { hljs: 'css', cm: 'css' },
scss: { hljs: 'scss', cm: 'css' },
sass: { hljs: 'scss', cm: 'css' },
less: { hljs: 'less', cm: 'css' },
vue: { hljs: 'xml', cm: 'html' },
// === 数据格式 ===
json: { hljs: 'json', cm: 'json' },
xml: { hljs: 'xml', cm: 'html' },
yaml: { hljs: 'yaml', cm: 'yaml' },
yml: { hljs: 'yaml', cm: 'yaml' },
toml: { cm: 'text' },
csv: { cm: 'text' },
tsv: { cm: 'text' },
// === C / C++ / 系统编程 ===
c: { hljs: 'c', cm: 'cpp' },
cpp: { hljs: 'cpp', cm: 'cpp' },
cc: { hljs: 'cpp', cm: 'cpp' },
cxx: { hljs: 'cpp', cm: 'cpp' },
h: { hljs: 'cpp', cm: 'cpp' },
hpp: { hljs: 'cpp', cm: 'cpp' },
hxx: { hljs: 'cpp', cm: 'cpp' },
cs: { hljs: 'csharp', cm: 'text' },
swift: { hljs: 'swift', cm: 'text' },
kt: { hljs: 'kotlin', cm: 'text' },
rs: { hljs: 'rust', cm: 'rust' },
go: { hljs: 'go', cm: 'go' },
java: { hljs: 'java', cm: 'java' },
pch: { hljs: 'cpp', cm: 'cpp' },
tcc: { hljs: 'cpp', cm: 'cpp' },
// === 脚本 ===
py: { hljs: 'python', cm: 'python' },
pyw: { hljs: 'python', cm: 'python' },
rb: { hljs: 'ruby', cm: 'text' },
php: { hljs: 'php', cm: 'php' },
sh: { hljs: 'bash', cm: 'shell' },
bash: { hljs: 'bash', cm: 'shell' },
shell: { hljs: 'bash', cm: 'shell' },
zsh: { hljs: 'bash', cm: 'shell' },
ps1: { hljs: 'powershell', cm: 'powershell' },
bat: { hljs: 'dos', cm: 'text' },
ahk: { hljs: 'autohotkey', cm: 'text' },
lua: { hljs: 'lua', cm: 'text' },
r: { hljs: 'r', cm: 'text' },
m: { hljs: 'objectivec', cm: 'text' },
scala: { hljs: 'scala', cm: 'text' },
dart: { hljs: 'dart', cm: 'dart' },
// === 数据库 / 标记 ===
sql: { hljs: 'sql', cm: 'sql' },
md: { hljs: 'markdown', cm: 'markdown' },
markdown: { hljs: 'markdown', cm: 'markdown' },
tex: { hljs: 'latex', cm: 'text' },
rst: { hljs: 'plaintext', cm: 'text' },
adoc: { hljs: 'plaintext', cm: 'text' },
// === 构建工具 / 配置 ===
dockerfile: { hljs: 'dockerfile', cm: 'text' },
makefile: { hljs: 'makefile', cm: 'text' },
mk: { hljs: 'makefile', cm: 'text' },
cmake: { hljs: 'cmake', cm: 'text' },
ini: { hljs: 'ini', cm: 'text' },
cfg: { hljs: 'ini', cm: 'text' },
conf: { hljs: 'ini', cm: 'text' },
env: { cm: 'text' },
props: { cm: 'text' },
manifest: { cm: 'text' },
lock: { cm: 'text' },
ignore: { cm: 'text' },
// === 纯文本 ===
txt: { cm: 'text' },
text: { cm: 'text' },
log: { cm: 'text' },
msg: { cm: 'text' },
}
/**
* 获取 hljs 语言标识(带别名解析)
*/
export function getHljsLanguage(langOrExt: string): string {
if (!langOrExt) return 'plaintext'
const lower = langOrExt.toLowerCase()
// 先查扩展名映射
const mapped = extensionToLanguage[lower]
if (mapped?.hljs) return mapped.hljs
// 再查 hljs 直接注册名
if (typeof hljs !== 'undefined' && hljs.getLanguage(lower)) return lower
return 'plaintext'
}
/**
* 获取 CodeMirror 语言标识
*/
export function getCmLanguage(extension: string): string {
if (!extension) return 'text'
const lower = extension.toLowerCase()
return extensionToLanguage[lower]?.cm || 'text'
}

View File

@@ -1,38 +1,66 @@
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/lib/common'
// 额外导入 common 包不包含的语言
import 'highlight.js/lib/languages/bash'
import 'highlight.js/lib/languages/go'
import 'highlight.js/styles/github-dark.css'
import 'highlight.js/styles/github.css'
// 语言别名映射sh -> bash 等)
const languageAliases: Record<string, string> = {
'sh': 'bash',
'shell': 'bash',
'zsh': 'bash',
'ksh': 'bash',
'ts': 'typescript',
'js': 'javascript',
'py': 'python',
'rb': 'ruby',
'yml': 'yaml',
'md': 'markdown'
}
// 按需导入 common 包不包含的语言
import 'highlight.js/lib/languages/powershell'
import 'highlight.js/lib/languages/dos'
import 'highlight.js/lib/languages/autohotkey'
import 'highlight.js/lib/languages/latex'
import 'highlight.js/lib/languages/dockerfile'
import 'highlight.js/lib/languages/cmake'
import 'highlight.js/lib/languages/scala'
import 'highlight.js/lib/languages/dart'
import { getHljsLanguage } from './languageMap'
let mermaidInstance: typeof import('mermaid').default | null = null
let mermaidTheme: string | null = null
// 检测当前是否为暗色主题
function isDarkTheme(): boolean {
if (typeof document === 'undefined') return false
return document.body.getAttribute('arco-theme')?.includes('dark') ?? false
}
async function loadMermaid() {
if (mermaidInstance) return mermaidInstance
const currentTheme = isDarkTheme() ? 'dark' : 'default'
if (mermaidInstance && mermaidTheme === currentTheme) {
return mermaidInstance
}
try {
const mermaid = await import('mermaid')
mermaid.default.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
theme: currentTheme,
securityLevel: 'strict',
themeVariables: currentTheme === 'dark' ? {
primaryColor: '#165DFF',
primaryTextColor: '#ffffff',
primaryBorderColor: '#4080FF',
lineColor: '#4E5969',
secondaryColor: '#0E42D2',
tertiaryColor: '#0FC6C2',
mainBkg: '#17171A',
nodeBorder: '#165DFF',
clusterBkg: '#232324',
titleColor: '#FFFFFF',
edgeLabelBackground: '#232324'
} : {
primaryColor: '#165DFF',
primaryTextColor: '#ffffff',
primaryBorderColor: '#4080FF',
lineColor: '#86909C',
secondaryColor: '#E8F3FF',
tertiaryColor: '#722ED1',
mainBkg: '#F2F3F5',
nodeBorder: '#165DFF',
clusterBkg: '#F7F8FA',
titleColor: '#1D2129',
edgeLabelBackground: '#F2F3F5'
}
})
mermaidTheme = currentTheme
mermaidInstance = mermaid.default
return mermaidInstance
} catch {
@@ -47,14 +75,7 @@ renderer.code = function(token: any) {
return `<pre class="mermaid">${token.text}</pre>`
}
// 获取语言,支持别名
let lang = token.lang || 'plaintext'
lang = languageAliases[lang] || lang
// 检查语言是否支持
if (!hljs.getLanguage(lang)) {
lang = 'plaintext'
}
const lang = getHljsLanguage(token.lang)
const highlighted = hljs.highlight(token.text, { language: lang }).value
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
@@ -81,34 +102,26 @@ renderer.heading = function(token: any) {
// 判断是否为本地文件链接(相对路径或本地绝对路径)
const isLocalFileLink = (href: string): boolean => {
if (!href) return false
// 排除 http/https/ftp/mailto 等外部链接
if (/^(https?|ftp|mailto|tel|data):/i.test(href)) return false
// 排除锚点链接
if (href.startsWith('#')) return false
// 相对路径或本地路径(如 ./file.md, ../file.md, /path/to/file, C:\path\file
return true
}
// 自定义链接渲染器 - 支持本地文件链接
renderer.link = function(token: any) {
const href = token.href || ''
// 解析链接文本中的内联元素(如加粗、斜体等)
const text = this.parser.parseInline(token.tokens) || token.text || ''
const title = token.title || ''
const titleAttr = title ? ` title="${title}"` : ''
// 锚点链接 - 保持原样,页面内跳转
if (href.startsWith('#')) {
return `<a href="${href}"${titleAttr}>${text}</a>`
}
// 判断是否为本地文件链接
if (isLocalFileLink(href)) {
return `<a href="javascript:void(0)" data-local-link="${href}" class="local-file-link"${titleAttr}>${text}</a>`
}
// 外部链接使用默认行为
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
@@ -122,5 +135,3 @@ export async function renderMermaidDiagrams() {
await mermaid.run()
}
}

View File

@@ -1,118 +0,0 @@
/**
* 路径处理工具函数
*
* @module utils/pathHelpers
* @description 统一路径分割、文件名获取等操作,避免重复代码
*/
import { getExt as getExtFromFileHelpers } from './fileHelpers'
// 重新导出 getExt避免重复定义
export const getExt = getExtFromFileHelpers
/**
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
* @type {RegExp}
*/
export const PATH_SEPARATOR_REGEX = /[/\\]/
/**
* 分割路径为多个部分
* @param {string} path - 文件路径
* @returns {string[]} 路径数组
* @example
* splitPath('C:\\Users\\file.txt') // ['C:', 'Users', 'file.txt']
* splitPath('/home/user/file.txt') // ['home', 'user', 'file.txt']
*/
export const splitPath = (path) => {
if (!path) return []
return path.split(PATH_SEPARATOR_REGEX)
}
/**
* 获取文件名(含扩展名)
* @param {string} path - 文件路径
* @returns {string} 文件名
* @example
* getFileName('C:\\Users\\file.txt') // 'file.txt'
* getFileName('/home/user/file.txt') // 'file.txt'
*/
export const getFileName = (path) => {
if (!path) return ''
const parts = splitPath(path)
return parts[parts.length - 1] || path
}
/**
* 获取父目录路径
* @param {string} path - 文件或目录路径
* @returns {string} 父目录路径
* @example
* getParentPath('C:\\Users\\file.txt') // 'C:\\Users'
* getParentPath('/home/user/file.txt') // '/home/user'
* getParentPath('E:/file.txt') // 'E:/'
*/
export const getParentPath = (path) => {
if (!path) return ''
// 规范化路径分隔符
const normalizedPath = path.replace(/\\/g, '/')
// 查找最后一个分隔符的位置
const lastSep = normalizedPath.lastIndexOf('/')
if (lastSep <= 0) {
// 没有分隔符或分隔符在开头,返回根目录(对于盘符情况)
if (/^[A-Za-z]:$/.test(normalizedPath)) {
return normalizedPath + '/' // E: 转换为 E:/
}
return normalizedPath
}
const parentPath = normalizedPath.substring(0, lastSep)
// 特殊处理如果是盘符根目录下的文件E:/file.txt -> E:/
if (/^[A-Za-z]:$/.test(parentPath)) {
return parentPath + '/' // 确保根目录带斜杠
}
return parentPath || '/'
}
/**
* 获取文件名(不含扩展名)
* @param {string} path - 文件路径
* @returns {string} 文件名(不含扩展名)
* @example
* getFileNameWithoutExt('file.txt') // 'file'
* getFileNameWithoutExt('archive.tar.gz') // 'archive.tar'
*/
export const getFileNameWithoutExt = (path) => {
const fileName = getFileName(path)
const lastDot = fileName.lastIndexOf('.')
return lastDot > 0 ? fileName.substring(0, lastDot) : fileName
}
/**
* 规范化路径分隔符(统一为正斜杠)
* @param {string} path - 文件路径
* @returns {string} 规范化后的路径
*/
export const normalizePathSeparators = (path) => {
if (!path) return ''
return path.replace(/\\/g, '/')
}
/**
* 连接路径片段
* @param {...string} parts - 路径片段
* @returns {string} 连接后的路径
* @example
* joinPath('C:', 'Users', 'file.txt') // 'C:/Users/file.txt'
*/
export const joinPath = (...parts) => {
return parts
.filter(part => part && part !== '')
.map(part => part.replace(/[\/\\]+$/, '').replace(/^[\/\\]+/, ''))
.join('/')
}

View File

@@ -0,0 +1,220 @@
<template>
<div class="markdown-viewer-container">
<div class="viewer-header">
<div class="title">
<icon-file-text />
<span>{{ currentFile?.name || 'Markdown 文档' }}</span>
</div>
<div class="actions">
<PdfExportButton @export-complete="onExportComplete" />
<a-button @click="handleBackToList" type="outline">
<icon-arrow-left />
返回列表
</a-button>
</div>
</div>
<div class="viewer-content">
<MarkdownEditor
:content="fileContent"
@content-change="handleContentChange"
@save="handleSave"
/>
</div>
<!-- 底部状态栏 -->
<div class="status-bar">
<div class="file-info">
<span>{{ currentFile?.path }}</span>
</div>
<div class="content-info">
<span>{{ wordCount }} 字符 | {{ lineCount }} </span>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import MarkdownEditor from '@/components/MarkdownEditor.vue'
import PdfExportButton from '@/components/PdfExportButton.vue'
import { useFileOperations } from '@/composables/useFileOperations'
export default {
name: 'MarkdownViewer',
components: {
MarkdownEditor,
PdfExportButton
},
props: {
filePath: {
type: String,
required: true
}
},
emits: ['back'],
setup(props, { emit }) {
const fileOperations = useFileOperations()
const fileContent = ref('')
const currentFile = ref(null)
const hasChanges = ref(false)
const lastSavedContent = ref('')
// 计算属性
const wordCount = computed(() => {
return fileContent.value.length
})
const lineCount = computed(() => {
return fileContent.value.split('\n').length
})
// 方法
const loadFile = async () => {
try {
const response = await fileOperations.readFile(props.filePath)
fileContent.value = response.content
lastSavedContent.value = response.content
hasChanges.value = false
// 获取文件信息
const fileName = props.filePath.split('/').pop() || props.filePath.split('\\').pop()
currentFile.value = {
name: fileName,
path: props.filePath
}
} catch (error) {
Message.error(`读取文件失败: ${error?.message ?? String(error)}`)
}
}
const handleContentChange = (content) => {
hasChanges.value = content !== lastSavedContent.value
}
const handleSave = async () => {
try {
await fileOperations.saveFile(props.filePath, fileContent.value)
lastSavedContent.value = fileContent.value
hasChanges.value = false
Message.success('文件已保存')
} catch (error) {
Message.error(`保存文件失败: ${error?.message ?? String(error)}`)
}
}
const onExportComplete = () => {
Message.success('PDF 导出完成')
}
const handleBackToList = () => {
emit('back')
}
// 初始化
onMounted(() => {
loadFile()
})
return {
fileContent,
currentFile,
hasChanges,
wordCount,
lineCount,
handleContentChange,
handleSave,
onExportComplete,
handleBackToList
}
}
}
</script>
<style scoped>
.markdown-viewer-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
.viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #e8e8e8;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.actions {
display: flex;
gap: 8px;
align-items: center;
}
.viewer-content {
flex: 1;
overflow: auto;
background: white;
margin: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background: white;
border-top: 1px solid #e8e8e8;
font-size: 12px;
color: #666;
}
.file-info {
font-family: monospace;
color: #999;
}
.content-info {
color: #666;
}
/* 响应式设计 */
@media (max-width: 768px) {
.viewer-header {
padding: 12px 16px;
}
.title {
font-size: 14px;
}
.actions {
gap: 4px;
}
.viewer-content {
margin: 8px;
}
.status-bar {
padding: 6px 16px;
font-size: 11px;
}
}
</style>

View File

@@ -495,6 +495,7 @@ import { Input, Select, Checkbox, InputGroup, Button, Option, Optgroup, Tooltip
import MySQLCreate from './MySQLCreate.vue'
import { STORAGE_KEYS } from '../constants/storage'
import { useResultHistory, type ResultHistoryItem } from '../composables/useResultHistory'
import { escapeHtml } from '@/utils/fileUtils'
// MySQL 数据类型选项
const mysqlDataTypeOptions = [
@@ -1125,13 +1126,7 @@ const formatJSON = (data: unknown): string => JSON.stringify(data, null, 2)
const highlightJSON = (data: unknown): string => {
const json = JSON.stringify(data, null, 2)
if (!json) return ''
// 转义 HTML 特殊字符
const escapeHtml = (str: string) => str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 语法高亮正则替换
return escapeHtml(json)
// 字符串值(双引号包围,不是键名)

View File

@@ -6,17 +6,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { escapeHtml } from '@/utils/fileUtils'
const props = defineProps<{
data: any[]
}>()
// 转义 HTML
const escapeHtml = (str: string) => str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// JSON 高亮
const highlightedJson = computed(() => {
const json = JSON.stringify(props.data, null, 2)

View File

@@ -3,6 +3,8 @@
* 支持 CSV、JSON、Excel 格式
*/
import { escapeHtml } from '@/utils/fileUtils'
/**
* 导出为 CSV
*/
@@ -183,20 +185,6 @@ function downloadFile(content, filename, mimeType) {
URL.revokeObjectURL(url)
}
/**
* HTML 转义
*/
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, m => map[m])
}
/**
* 复制到剪贴板
*/

View File

@@ -0,0 +1,113 @@
<template>
<div class="markdown-editor-page">
<div class="editor-container">
<MarkdownEditor
v-model:content="markdownContent"
@content-change="handleContentChange"
@save="handleSave"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import MarkdownEditor from '../../components/MarkdownEditor.vue'
import { Message } from '@arco-design/web-vue'
const markdownContent = ref('')
// 初始化示例内容
const initSampleContent = () => {
markdownContent.value = `# 欢迎使用 Markdown 编辑器
这是一个功能强大的 Markdown 编辑器,支持实时预览和 PDF 导出功能。
## 功能特性
- ✅ **实时预览** - 输入内容即时显示预览效果
- ✅ **语法高亮** - 支持 GitHub 风格的 Markdown 语法
- ✅ **PDF 导出** - 一键导出为格式化的 PDF 文档
- ✅ **自动保存** - 支持 Ctrl + S 快捷键保存
- ✅ **字数统计** - 实时显示字符数和行数
## 使用说明
### 基本语法
\`\`\`markdown
# 一级标题
## 二级标题
### 三级标题
**粗体文本** 和 *斜体文本*
- 无序列表项 1
- 无序列表项 2
- 嵌套列表项 1
1. 有序列表项 1
2. 有序列表项 2
\`\`\`
### 代码块
\`\`\`javascript
function hello() {
console.log('Hello, World!')
}
\`\`\`
### 表格
| 列 1 | 列 2 | 列 3 |
|------|------|------|
| 数据 1 | 数据 2 | 数据 3 |
| 数据 4 | 数据 5 | 数据 6 |
### 引用
> 这是一个引用示例
> 可以包含多行内容
---
**开始创作吧!**`
}
const handleContentChange = (content) => {
// 内容变化时的处理
}
const handleSave = (content) => {
// 保存处理
console.log('Content saved:', content)
Message.success('内容已保存到本地存储')
}
onMounted(() => {
// 从本地存储加载之前保存的内容
const savedContent = localStorage.getItem('u-desk-markdown-content')
if (savedContent) {
markdownContent.value = savedContent
} else {
// 没有保存的内容时显示示例内容
initSampleContent()
}
})
</script>
<style scoped>
.markdown-editor-page {
height: 100%;
width: 100%;
padding: 20px;
background: var(--color-bg-1);
}
.editor-container {
height: 100%;
width: 100%;
}
</style>

View File

@@ -28,6 +28,10 @@ export function EmptyRecycleBin():Promise<void>;
export function ExecuteSQL(arg1:number,arg2:string,arg3:string):Promise<Record<string, any>>;
export function ExportMarkdownToPDF(arg1:string):Promise<string>;
export function ExportPDF(arg1:string,arg2:string,arg3:string,arg4:number,arg5:number,arg6:number):Promise<Record<string, any>>;
export function ExtractFileFromZip(arg1:string,arg2:string):Promise<string>;
export function ExtractFileFromZipToTemp(arg1:string,arg2:string):Promise<string>;
@@ -56,6 +60,12 @@ export function GetIndexes(arg1:number,arg2:string,arg3:string):Promise<Array<Re
export function GetMemoryInfo():Promise<Record<string, any>>;
export function GetRecycleBinEntries():Promise<Array<Record<string, any>>>;
export function GetResultHistory(arg1:any,arg2:string,arg3:number,arg4:number):Promise<Record<string, any>>;
@@ -108,12 +118,16 @@ export function SaveAppConfig(arg1:main.SaveAppConfigRequest):Promise<Record<str
export function SaveDbConnection(arg1:api.SaveConnectionRequest):Promise<void>;
export function SaveResult(arg1:number,arg2:string,arg3:string,arg4:string,arg5:any,arg6:Array<string>,arg7:number,arg8:number):Promise<Record<string, any>>;
export function SaveSqlTabs(arg1:Array<Record<string, any>>):Promise<void>;
export function SelectPDFSaveDirectory():Promise<string>;
export function SetUpdateConfig(arg1:boolean,arg2:number,arg3:string):Promise<Record<string, any>>;
export function TestDbConnection(arg1:number):Promise<void>;
export function TestDbConnectionWithParams(arg1:api.TestConnectionRequest):Promise<void>;
@@ -130,4 +144,6 @@ export function WindowMaximize():Promise<void>;
export function WindowMinimize():Promise<void>;
export function WindowToggleAlwaysOnTop():Promise<boolean>;
export function WriteFile(arg1:main.WriteFileRequest):Promise<void>;

View File

@@ -50,6 +50,14 @@ export function ExecuteSQL(arg1, arg2, arg3) {
return window['go']['main']['App']['ExecuteSQL'](arg1, arg2, arg3);
}
export function ExportMarkdownToPDF(arg1) {
return window['go']['main']['App']['ExportMarkdownToPDF'](arg1);
}
export function ExportPDF(arg1, arg2, arg3, arg4, arg5, arg6) {
return window['go']['main']['App']['ExportPDF'](arg1, arg2, arg3, arg4, arg5, arg6);
}
export function ExtractFileFromZip(arg1, arg2) {
return window['go']['main']['App']['ExtractFileFromZip'](arg1, arg2);
}
@@ -106,6 +114,30 @@ export function GetMemoryInfo() {
return window['go']['main']['App']['GetMemoryInfo']();
}
export function GetOpenClawConfig() {
return window['go']['main']['App']['GetOpenClawConfig']();
}
export function GetOpenClawModelUsage() {
return window['go']['main']['App']['GetOpenClawModelUsage']();
}
export function GetOpenClawSessionHistory(arg1, arg2) {
return window['go']['main']['App']['GetOpenClawSessionHistory'](arg1, arg2);
}
export function GetOpenClawSessions() {
return window['go']['main']['App']['GetOpenClawSessions']();
}
export function GetOpenClawStatus() {
return window['go']['main']['App']['GetOpenClawStatus']();
}
export function GetOpenClawSystemUsage() {
return window['go']['main']['App']['GetOpenClawSystemUsage']();
}
export function GetRecycleBinEntries() {
return window['go']['main']['App']['GetRecycleBinEntries']();
}
@@ -210,6 +242,10 @@ export function SaveDbConnection(arg1) {
return window['go']['main']['App']['SaveDbConnection'](arg1);
}
export function SaveOpenClawConfig(arg1) {
return window['go']['main']['App']['SaveOpenClawConfig'](arg1);
}
export function SaveResult(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
return window['go']['main']['App']['SaveResult'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
}
@@ -218,10 +254,18 @@ export function SaveSqlTabs(arg1) {
return window['go']['main']['App']['SaveSqlTabs'](arg1);
}
export function SelectPDFSaveDirectory() {
return window['go']['main']['App']['SelectPDFSaveDirectory']();
}
export function SetUpdateConfig(arg1, arg2, arg3) {
return window['go']['main']['App']['SetUpdateConfig'](arg1, arg2, arg3);
}
export function SwitchOpenClawSession(arg1) {
return window['go']['main']['App']['SwitchOpenClawSession'](arg1);
}
export function TestDbConnection(arg1) {
return window['go']['main']['App']['TestDbConnection'](arg1);
}
@@ -254,6 +298,10 @@ export function WindowMinimize() {
return window['go']['main']['App']['WindowMinimize']();
}
export function WindowToggleAlwaysOnTop() {
return window['go']['main']['App']['WindowToggleAlwaysOnTop']();
}
export function WriteFile(arg1) {
return window['go']['main']['App']['WriteFile'](arg1);
}