新增:Markdown编辑器/数据库优化/安全修复
- Markdown 编辑器:实时预览、PDF 导出、独立查看器 - 数据库优化:动态连接池、查询缓存、Redis Pipeline - 窗口置顶功能 - 文件系统增强:右键菜单、编辑器集成、收藏夹重构 - 安全修复:XSS 防护、路径穿越、HTML 注入 - 代码质量:正则预编译、缓存锁优化、死代码清理
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,8 +4,12 @@ web/src/wailsjs/
|
||||
|
||||
# 构建产物
|
||||
build/bin/
|
||||
build/*.log
|
||||
web/dist/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
|
||||
# 依赖目录
|
||||
web/node_modules/
|
||||
web/bun.lock
|
||||
|
||||
@@ -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
|
||||
|
||||
### 核心架构重构 🏗️
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -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
161
README.md
@@ -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
159
app.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
9
go.mod
@@ -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
30
go.sum
@@ -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=
|
||||
|
||||
@@ -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
379
internal/api/pdf_api.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
479
internal/dbclient/cache.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
762
internal/dbclient/query_optimizer.go
Normal file
762
internal/dbclient/query_optimizer.go
Normal 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() {
|
||||
}
|
||||
151
internal/dbclient/redis_pipeline.go
Normal file
151
internal/dbclient/redis_pipeline.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理菜单项点击
|
||||
*/
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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 自定义属性 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
563
web/src/components/MarkdownEditor.vue
Normal file
563
web/src/components/MarkdownEditor.vue
Normal 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>
|
||||
45
web/src/components/MarkdownPreview.vue
Normal file
45
web/src/components/MarkdownPreview.vue
Normal 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>
|
||||
262
web/src/components/PdfExportButton.vue
Normal file
262
web/src/components/PdfExportButton.vue
Normal 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { FILE_EXTENSIONS } from './constants'
|
||||
import { getExt } from './pathHelpers'
|
||||
import { getExt } from './fileUtils'
|
||||
|
||||
/**
|
||||
* 可预览的文件类型(有专门的预览处理)
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名(路径安全)
|
||||
* @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 || '/'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
129
web/src/utils/languageMap.ts
Normal file
129
web/src/utils/languageMap.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 统一语言映射
|
||||
* 供 highlight.js(Markdown 预览)和 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'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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('/')
|
||||
}
|
||||
220
web/src/views/MarkdownViewer.vue
Normal file
220
web/src/views/MarkdownViewer.vue
Normal 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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
|
||||
// 语法高亮正则替换
|
||||
return escapeHtml(json)
|
||||
// 字符串值(双引号包围,不是键名)
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// JSON 高亮
|
||||
const highlightedJson = computed(() => {
|
||||
const json = JSON.stringify(props.data, null, 2)
|
||||
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, m => map[m])
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制到剪贴板
|
||||
*/
|
||||
|
||||
113
web/src/views/markdown-editor/index.vue
Normal file
113
web/src/views/markdown-editor/index.vue
Normal 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>
|
||||
16
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
16
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user