Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742581c5d6 | |||
| 4ffac72999 | |||
| 72fef3e56f | |||
| 691e38604f | |||
| 756028af0f | |||
| 7dbd57a8b6 | |||
| efc042fcd3 | |||
| fb12ec48e8 | |||
| e5dbe89a6f | |||
| 5f94ccf13b | |||
| 1eaf61cf41 | |||
| c5e6ff3ba6 | |||
| a6f99e0c49 | |||
| e198fd4ee1 | |||
| bfe5226bfe | |||
| ded8989fe3 | |||
| 22f5862f15 | |||
| 4a1f0213df | |||
| d62b9ca7bd | |||
| 0229cab550 | |||
| 9eb39fbb8f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,8 +4,12 @@ web/src/wailsjs/
|
|||||||
|
|
||||||
# 构建产物
|
# 构建产物
|
||||||
build/bin/
|
build/bin/
|
||||||
|
build/*.log
|
||||||
web/dist/
|
web/dist/
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.tmp
|
||||||
|
|
||||||
# 依赖目录
|
# 依赖目录
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/bun.lock
|
web/bun.lock
|
||||||
|
|||||||
@@ -2,6 +2,392 @@
|
|||||||
|
|
||||||
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-04-13
|
||||||
|
|
||||||
|
### 架构新增 🏗️
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
### 核心架构重构 🏗️
|
||||||
|
|
||||||
|
#### CodeMirror 统一导出机制
|
||||||
|
**问题**: 多处直接从 `@codemirror/*` 导入导致多实例问题,影响状态共享和主题切换
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 新增 `web/src/utils/codemirrorExports.js` 统一导出层
|
||||||
|
- 所有 CodeMirror 模块通过此文件导出,确保单实例
|
||||||
|
- 包括核心、语言包、主题等 27+ 个模块
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 核心模块
|
||||||
|
export { EditorView, lineNumbers, ... } from '@codemirror/view'
|
||||||
|
export { EditorState, Compartment, Facet, ... } from '@codemirror/state'
|
||||||
|
|
||||||
|
// 语言包
|
||||||
|
export { javascript } from '@codemirror/lang-javascript'
|
||||||
|
export { sql } from '@codemirror/lang-sql'
|
||||||
|
// ... 13 个语言包
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响组件**:
|
||||||
|
- `web/src/components/CodeEditor.vue`
|
||||||
|
- `web/src/views/db-cli/components/SqlEditor.vue`
|
||||||
|
- `web/src/views/db-cli/components/SqlPreviewDialog.vue`
|
||||||
|
|
||||||
|
#### 语言加载器简化
|
||||||
|
**优化前** - 异步动态导入:
|
||||||
|
```javascript
|
||||||
|
export async function loadLanguageExtension(language) {
|
||||||
|
const [path, method] = modernLangs[language]
|
||||||
|
const mod = await import(path) // 异步加载
|
||||||
|
return mod[method]()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后** - 同步静态导入:
|
||||||
|
```javascript
|
||||||
|
import { javascript, json, sql, ... } from './codemirrorExports'
|
||||||
|
|
||||||
|
export function loadLanguageExtension(language) {
|
||||||
|
switch (language) {
|
||||||
|
case 'javascript': return javascript({ jsx: true })
|
||||||
|
case 'sql': return sql()
|
||||||
|
// ... 同步返回
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- ✅ 消除异步加载失败风险
|
||||||
|
- ✅ 代码逻辑简化 70%
|
||||||
|
- ✅ 类型提示更完善
|
||||||
|
- ✅ 移除 13 种 legacy 语言支持(ruby, shell, kotlin 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 动态主题切换优化 ⚡
|
||||||
|
|
||||||
|
#### 使用 Compartment 实现无损切换
|
||||||
|
**优化前** - 销毁重建方式:
|
||||||
|
```javascript
|
||||||
|
watch([isDark, fileExtension], async () => {
|
||||||
|
await nextTick()
|
||||||
|
const currentDoc = view.state.doc.toString()
|
||||||
|
view.destroy()
|
||||||
|
await createEditor(currentDoc) // 丢失光标、选择、历史
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**优化后** - Compartment 动态重配置:
|
||||||
|
```javascript
|
||||||
|
const themeCompartment = new Compartment()
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
// 主题切换
|
||||||
|
watch(() => themeStore.isDark, () => {
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 语言切换
|
||||||
|
watch(() => props.fileExtension, () => {
|
||||||
|
initLanguage() // 使用 languageCompartment.reconfigure
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**保留状态**:
|
||||||
|
- ✅ 光标位置
|
||||||
|
- ✅ 选择内容
|
||||||
|
- ✅ 撤销/重做历史
|
||||||
|
- ✅ 滚动位置
|
||||||
|
|
||||||
|
**性能提升**:
|
||||||
|
- 切换耗时: 150ms → 15ms(90% 提升)
|
||||||
|
- 无需重新解析文档
|
||||||
|
|
||||||
|
#### 亮色主题改进
|
||||||
|
**新增专用亮色主题定义**:
|
||||||
|
```javascript
|
||||||
|
const lightTheme = EditorView.theme({
|
||||||
|
'&': { backgroundColor: '#ffffff' },
|
||||||
|
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
||||||
|
'.cm-line': { caretColor: '#000' },
|
||||||
|
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
||||||
|
'.cm-cursor': { borderLeftColor: '#000' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
结合 `defaultHighlightStyle` 提供完整语法高亮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 性能优化 🚀
|
||||||
|
|
||||||
|
#### 内容更新防抖
|
||||||
|
**问题**: 每次按键都触发 `emit('update:modelValue')`,导致频繁的 Vue 响应式更新
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```javascript
|
||||||
|
let emitTimeout = null
|
||||||
|
const debouncedEmit = (value) => {
|
||||||
|
if (emitTimeout) clearTimeout(emitTimeout)
|
||||||
|
emitTimeout = setTimeout(() => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
debouncedEmit(update.state.doc.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- ✅ 减少 85% 的 emit 调用
|
||||||
|
- ✅ 输入流畅度显著提升
|
||||||
|
- ✅ 组件更新压力降低
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 依赖和构建优化 📦
|
||||||
|
|
||||||
|
#### 移除废弃依赖
|
||||||
|
```diff
|
||||||
|
- "@codemirror/highlight": "^0.19.8" // 已废弃
|
||||||
|
- "@codemirror/legacy-modes": "^6.5.2" // 不需要
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- `@codemirror/highlight` v0.19.8 已废弃,功能整合到 `@codemirror/language@6.12.1`
|
||||||
|
- `@codemirror/legacy-modes` 支持的语言项目不需要
|
||||||
|
|
||||||
|
#### Vite 配置简化
|
||||||
|
**移除 manualChunks 配置**:
|
||||||
|
```diff
|
||||||
|
- rollupOptions: {
|
||||||
|
- output: {
|
||||||
|
- manualChunks: (id) => {
|
||||||
|
- if (id.includes('@codemirror')) return 'vendor-codemirror'
|
||||||
|
- if (id.includes('@arco-design')) return 'vendor-arco'
|
||||||
|
- ...
|
||||||
|
- }
|
||||||
|
- }
|
||||||
|
- }
|
||||||
|
```
|
||||||
|
|
||||||
|
**简化 optimizeDeps 配置**:
|
||||||
|
```diff
|
||||||
|
- optimizeDeps: {
|
||||||
|
- include: [
|
||||||
|
- 'vue', 'pinia', '@arco-design/web-vue',
|
||||||
|
- '@codemirror/view', '@codemirror/state',
|
||||||
|
- '@codemirror/language', '@codemirror/commands',
|
||||||
|
- ... 20+ 个 CodeMirror 包
|
||||||
|
- ]
|
||||||
|
- }
|
||||||
|
+ optimizeDeps: {
|
||||||
|
+ include: ['vue', 'pinia', '@arco-design/web-vue', 'marked', 'highlight.js']
|
||||||
|
+ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- ✅ 配置行数减少 40+
|
||||||
|
- ✅ Vite 自动依赖预构建更高效
|
||||||
|
- ✅ 构建速度提升 15%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 代码清理 🧹
|
||||||
|
|
||||||
|
#### 删除过期文档
|
||||||
|
移除 9 个代码审查相关文档(2026-01-29/30 时期的临时文档)
|
||||||
|
|
||||||
|
#### 删除冗余代码
|
||||||
|
- `web/src/components/FileSystem/components/FileEditor/CodeEditor.vue` - 旧编辑器实现
|
||||||
|
- `web/src/components/FileSystem/components/FileEditorPanel.new.vue` - 未使用的原型文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 技术细节
|
||||||
|
|
||||||
|
#### 核心文件变更
|
||||||
|
|
||||||
|
| 文件 | 类型 | 行数变化 | 说明 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| `web/src/utils/codemirrorExports.js` | 新增 | +27 | 统一导出 |
|
||||||
|
| `web/src/utils/codeMirrorLoader.js` | 重构 | -50 | 简化语言加载 |
|
||||||
|
| `web/src/components/CodeEditor.vue` | 重构 | +80/-40 | Compartment + 防抖 |
|
||||||
|
| `web/package.json` | 优化 | -2 | 移除废弃包 |
|
||||||
|
| `web/vite.config.js` | 优化 | -40 | 简化配置 |
|
||||||
|
| `internal/service/version.go` | 更新 | ±1 | 版本号 0.3.0 → 0.3.2 |
|
||||||
|
|
||||||
|
#### 依赖变化
|
||||||
|
```diff
|
||||||
|
dependencies:
|
||||||
|
- @codemirror/highlight: ^0.19.8
|
||||||
|
- @codemirror/legacy-modes: ^6.5.2
|
||||||
|
|
||||||
|
(共移除 2 个包,减少约 80KB 打包体积)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 构建验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
✓ 依赖安装: npm install (无警告)
|
||||||
|
✓ 开发构建: npm run dev (正常启动)
|
||||||
|
✓ 生产构建: npm run build (10.2s)
|
||||||
|
✓ 类型检查: 无错误
|
||||||
|
✓ 运行测试: 编辑器功能正常,主题切换流畅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 相关文档
|
||||||
|
- [详细 changelog](docs/项目管理/版本管理/changelog-2026-02-05.md)
|
||||||
|
- [CodeMirror 配置优化总结](docs/CodeMirror-配置优化总结.md)
|
||||||
|
- [CodeEditor 优化报告](docs/CodeEditor-优化报告.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.3.0] - 2026-02-04
|
## [0.3.0] - 2026-02-04
|
||||||
|
|
||||||
### 新增功能 ✨
|
### 新增功能 ✨
|
||||||
|
|||||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -1,5 +1,106 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## [0.3.4] - 2026-04-22
|
||||||
|
|
||||||
|
### 新增 ✨
|
||||||
|
- **CodeMirror 搜索功能**: Ctrl+F / Ctrl+H 全局查找替换,`@codemirror/search` 集成
|
||||||
|
- **编辑器滚动位置恢复**: LRU 缓存(最多5份/3分钟TTL),切换文件不丢位置
|
||||||
|
- **文件列表列排序**: 图标/名称/时间/大小四列可排序,升序降序切换
|
||||||
|
- **文件搜索过滤**: 工具栏实时搜索框,按文件名过滤列表
|
||||||
|
- **Toolbar UI 重排**: 快捷访问内嵌面包屑左侧、历史记录改为图标+tooltip、Ctrl+H 快捷键
|
||||||
|
- **更新面板 Markdown 渲染**: changelog 用 `marked.parse()` 结构化渲染,替代纯文本
|
||||||
|
- **重命名零闪烁**: `updateFilePath()` 仅迁移路径引用+草稿key,不重新加载内容
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- **路径安全重构**: `validateFilePath()` 提取统一函数,消除两处重复校验代码
|
||||||
|
- **requireUpdateAPI 模式**: 7 处重复 nil 检查收敛为 guard 方法
|
||||||
|
- **端口统一**: 文件服务器端口 18765→8073,全局一致消除魔法数字分散
|
||||||
|
- **文件服务器 URL 动态获取**: 前端从后端 API 获取,不再硬编码
|
||||||
|
- **Tab 配置迁移扩展**: MigrateTabConfig 改为 map 驱动,覆盖 openclaw-manager→version 迁移
|
||||||
|
- **updateContent 简化**: 去掉时间窗口双重检查,仅保留版本号机制
|
||||||
|
|
||||||
|
### 安全修复 🔒
|
||||||
|
- **sentinel error 替代字符串匹配**: validateFilePath 错误用 `errors.Is()` 判断,消息变更不再静默失效
|
||||||
|
- **sanitizeHtml 防御远程 Markdown XSS**: 过滤 script/iframe/embed/on* 事件属性
|
||||||
|
|
||||||
|
### 修复 🐛
|
||||||
|
- **showHeader 默认值修正**: localStorage 无值时默认显示表头(兼容旧行为)
|
||||||
|
- **外层容器双重 scroll reset 移除**: 避免 CodeEditor 内部滚动恢复与外层 reset 冲突闪烁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-04-13
|
||||||
|
|
||||||
|
### 新增 ✨
|
||||||
|
- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存
|
||||||
|
- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面 (`views/markdown-editor/`)
|
||||||
|
- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式
|
||||||
|
- **窗口置顶**: 支持窗口始终置顶
|
||||||
|
- **收藏夹置顶**: 收藏项支持置顶排序
|
||||||
|
- **文件预览**: Excel/Word 文件预览支持
|
||||||
|
- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器
|
||||||
|
- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块 (`database-error.ts`)
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容
|
||||||
|
- SQL 查询优化器 — 查询缓存、慢查询日志
|
||||||
|
- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持
|
||||||
|
- Office/CSV 预览增强 — 本地文件服务器获取文件
|
||||||
|
- Markdown 增强 — 本地文件链接支持、Shell 语法高亮
|
||||||
|
- HTML 预览 — 改用 iframe src 替代 srcdoc
|
||||||
|
- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复
|
||||||
|
- 文件列表 UI 重构 — 统一渲染逻辑,提升滚动性能
|
||||||
|
- CSV 编辑模式优化 + PDF 导出重构
|
||||||
|
- 拷贝功能优化
|
||||||
|
|
||||||
|
### 修复 🐛
|
||||||
|
- Office 文件预览:修复类型检测与二进制误判
|
||||||
|
- 本地文件服务器 CORS 跨域问题
|
||||||
|
- 大文件点击卡死问题
|
||||||
|
- 收藏夹 bug 修复
|
||||||
|
- FileEditorPanel 语法错误
|
||||||
|
|
||||||
|
### 安全修复 🔒
|
||||||
|
- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)
|
||||||
|
- PDF 导出路径穿越防护
|
||||||
|
- PDF 导出标题 HTML 注入防护
|
||||||
|
|
||||||
|
### 重构 🔧
|
||||||
|
- CodeMirror 架构优化 — 统一导出避免多实例问题
|
||||||
|
- 消除代码重复 — storage/connection_service 重构、useVisibleDatabases 抽取
|
||||||
|
- 大规模死代码清理,显著减小包体积
|
||||||
|
- 配置加载超时保护(最多重试 30 次)
|
||||||
|
- 正则表达式预编译、缓存读锁优化
|
||||||
|
- 禁止 Ctrl+滚轮缩放
|
||||||
|
- Dockerfile 语法高亮支持
|
||||||
|
- 滚动条样式修复
|
||||||
|
|
||||||
|
### 文件系统 📁
|
||||||
|
- 右键菜单新增新建文件/文件夹
|
||||||
|
- FileEditorPanel 集成 PDF 导出按钮
|
||||||
|
- Markdown 文件自动预览与编辑/预览模式切换
|
||||||
|
- 面包屑导航组件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-02-05
|
||||||
|
|
||||||
|
### 重构 🔧
|
||||||
|
- **CodeMirror 架构优化** - 统一导出避免多实例问题
|
||||||
|
- **语言加载器优化** - 从动态 import 改为静态导入,提升稳定性
|
||||||
|
- **动态主题切换** - 使用 Compartment 实现无损切换
|
||||||
|
|
||||||
|
### 优化 🚀
|
||||||
|
- **编辑器性能** - 添加内容更新防抖,减少不必要的渲染
|
||||||
|
- **亮色主题** - 改进代码编辑器亮色模式样式
|
||||||
|
- **构建配置** - 简化 Vite 配置,优化打包效率
|
||||||
|
|
||||||
|
### 依赖清理 🧹
|
||||||
|
- 移除废弃的 `@codemirror/highlight` 包
|
||||||
|
- 移除不再使用的 `@codemirror/legacy-modes` 包
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.3.0] - 2026-02-04
|
## [0.3.0] - 2026-02-04
|
||||||
|
|
||||||
### 新增 ✨
|
### 新增 ✨
|
||||||
@@ -45,5 +146,3 @@
|
|||||||
- **主版本号** - 不兼容的 API 修改
|
- **主版本号** - 不兼容的 API 修改
|
||||||
- **次版本号** - 向下兼容的功能性新增
|
- **次版本号** - 向下兼容的功能性新增
|
||||||
- **修订号** - 向下兼容的问题修复
|
- **修订号** - 向下兼容的问题修复
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
159
README.md
159
README.md
@@ -1,155 +1,22 @@
|
|||||||
# U-Desk
|
# U-Desk v0.3.4
|
||||||
|
|
||||||
基于 Wails 的桌面应用程序,集成数据库客户端、文件管理、设备测试等功能。
|
## 功能
|
||||||
|
- **文件管理** — 本地文件浏览、编辑(CodeMirror 语法高亮+搜索)、预览(图片/视频/PDF/HTML/Markdown/Excel/Word/CSV)
|
||||||
|
- **数据库客户端** — 多数据库连接管理、SQL 执行、查询历史、表结构管理
|
||||||
|
- **Markdown 编辑器** — 独立编辑页面、实时预览、PDF 导出
|
||||||
|
- **版本更新** — 自动检查更新、下载安装、changelog 渲染
|
||||||
|
- **系统信息** — CPU/内存/磁盘硬件信息查询
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
- **后端**: Go + Wails v2 (桌面应用框架)
|
||||||
- **后端**:Go 1.25+、Wails v2
|
- **前端**: Vue 3 + Arco Design + CodeMirror 6 + Pinia
|
||||||
- **前端**:Vue 3、Arco Design Vue、Vite
|
- **存储**: SQLite (GORM)
|
||||||
- **存储**:SQLite、MySQL、Redis、MongoDB
|
- **本地文件服务器**: `localhost:8073`(CSS/JS 路径转换、HTML 预览)
|
||||||
|
|
||||||
## 核心功能
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Go 依赖
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
# 前端依赖
|
|
||||||
cd web
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 构建前端(必须)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
**重要**:每次修改前端代码后都需要重新构建,Wails 使用 `web/dist` 目录中的构建产物。
|
|
||||||
|
|
||||||
### 3. 开发模式运行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 在项目根目录
|
|
||||||
wails dev
|
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)
|
|
||||||
- 测试用例和检查报告
|
|
||||||
|
|
||||||
## 许可
|
|
||||||
|
|
||||||
本项目用于学习和测试目的。
|
|
||||||
|
|
||||||
|
|||||||
265
app.go
265
app.go
@@ -3,16 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
stdruntime "runtime"
|
stdruntime "runtime"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
"u-desk/internal/api"
|
"u-desk/internal/api"
|
||||||
"u-desk/internal/common"
|
"u-desk/internal/common"
|
||||||
"u-desk/internal/database"
|
|
||||||
"u-desk/internal/filesystem"
|
"u-desk/internal/filesystem"
|
||||||
"u-desk/internal/service"
|
"u-desk/internal/service"
|
||||||
"u-desk/internal/storage"
|
"u-desk/internal/storage"
|
||||||
@@ -23,17 +22,21 @@ import (
|
|||||||
|
|
||||||
// App 应用结构体
|
// App 应用结构体
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
db *database.DB
|
connectionAPI *api.ConnectionAPI
|
||||||
connectionAPI *api.ConnectionAPI
|
sqlAPI *api.SqlAPI
|
||||||
sqlAPI *api.SqlAPI
|
tabAPI *api.TabAPI
|
||||||
tabAPI *api.TabAPI
|
updateAPI *api.UpdateAPI
|
||||||
updateAPI *api.UpdateAPI
|
configAPI *api.ConfigAPI
|
||||||
configAPI *api.ConfigAPI
|
pdfAPI *api.PdfAPI
|
||||||
fileServer *http.Server
|
filesystem *filesystem.FileSystemService
|
||||||
filesystem *filesystem.FileSystemService
|
isAlwaysOnTop bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App 方法命名约定:
|
||||||
|
// - 多参数操作 → XxxRequest 结构体(Wails 自动生成 TS 类型)
|
||||||
|
// - 单参数查询/简单操作 → 直接参数
|
||||||
|
|
||||||
// NewApp 创建新的应用实例
|
// NewApp 创建新的应用实例
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{}
|
return &App{}
|
||||||
@@ -60,6 +63,17 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
// 2.5. 迁移旧配置
|
// 2.5. 迁移旧配置
|
||||||
_ = a.configAPI.MigrateTabConfig()
|
_ = 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. 初始化版本号(提前触发缓存,避免后续重复计算)
|
// 3. 初始化版本号(提前触发缓存,避免后续重复计算)
|
||||||
version := service.GetCurrentVersion()
|
version := service.GetCurrentVersion()
|
||||||
fmt.Printf("[启动] 当前版本: %s\n", version)
|
fmt.Printf("[启动] 当前版本: %s\n", version)
|
||||||
@@ -75,7 +89,7 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
|
|
||||||
// 5. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
// 5. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
|
||||||
go func() {
|
go func() {
|
||||||
if updateAPI, err := api.NewUpdateAPI("https://img.1216.top/u-desk/last-version.json"); err == nil {
|
if updateAPI, err := api.NewUpdateAPI("https://c.1216.top/last-version.json"); err == nil {
|
||||||
a.updateAPI = updateAPI
|
a.updateAPI = updateAPI
|
||||||
a.updateAPI.SetContext(ctx)
|
a.updateAPI.SetContext(ctx)
|
||||||
a.startAutoUpdateCheck()
|
a.startAutoUpdateCheck()
|
||||||
@@ -178,7 +192,7 @@ func (a *App) startFileServer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[文件服务器] 启动在 http://localhost:18765")
|
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown 应用关闭时调用
|
// Shutdown 应用关闭时调用
|
||||||
@@ -206,36 +220,6 @@ func (a *App) Shutdown(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryUsers 查询用户列表
|
|
||||||
func (a *App) QueryUsers(keyword string, status int, role int, organid int, page int, pageSize int, sortField string, sortOrder string) (map[string]interface{}, error) {
|
|
||||||
db, err := a.getDB()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return db.QueryUsers(keyword, status, role, organid, page, pageSize, sortField, sortOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDB 获取数据库连接(延迟加载,按需初始化)
|
|
||||||
func (a *App) getDB() (*database.DB, error) {
|
|
||||||
if a.db != nil {
|
|
||||||
return a.db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 首次调用时才连接数据库
|
|
||||||
db, err := database.Init()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("数据库连接失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.db = db
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Greet 测试方法
|
|
||||||
func (a *App) Greet(name string) string {
|
|
||||||
return "Hello " + name + ", It's show time!"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSystemInfo 获取系统信息
|
// GetSystemInfo 获取系统信息
|
||||||
func (a *App) GetSystemInfo() (map[string]interface{}, error) {
|
func (a *App) GetSystemInfo() (map[string]interface{}, error) {
|
||||||
return system.GetSystemInfo()
|
return system.GetSystemInfo()
|
||||||
@@ -272,6 +256,17 @@ func (a *App) WriteFile(req WriteFileRequest) error {
|
|||||||
return a.filesystem.WriteFile(req.Path, req.Content)
|
return a.filesystem.WriteFile(req.Path, req.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveBase64FileRequest 保存 Base64 编码的二进制文件
|
||||||
|
type SaveBase64FileRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"` // base64 编码的文件内容
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
|
||||||
|
func (a *App) SaveBase64File(req SaveBase64FileRequest) error {
|
||||||
|
return a.filesystem.SaveBase64File(req.Path, req.Content)
|
||||||
|
}
|
||||||
|
|
||||||
// ListDir 列出目录
|
// ListDir 列出目录
|
||||||
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
|
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
|
||||||
return a.filesystem.ListDir(path)
|
return a.filesystem.ListDir(path)
|
||||||
@@ -362,9 +357,9 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// 目标文件不存在或无法访问
|
// 目标文件不存在或无法访问
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"targetPath": targetPath,
|
"targetPath": targetPath,
|
||||||
"targetExists": false,
|
"targetExists": false,
|
||||||
"targetAccessible": false,
|
"targetAccessible": false,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -379,6 +374,31 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getWindowsSpecialFolder 从注册表读取 Windows 特殊文件夹的真实路径
|
||||||
|
// 用户可通过系统设置修改下载/桌面/文档等目录位置,注册表记录实际路径
|
||||||
|
func getWindowsSpecialFolder(guid string, fallbackName string) string {
|
||||||
|
key, err := registry.OpenKey(registry.CURRENT_USER,
|
||||||
|
`Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders`,
|
||||||
|
registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
val, _, err := key.GetStringValue(guid)
|
||||||
|
if err != nil || val == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开 %USERPROFILE% 等环境变量
|
||||||
|
path := os.ExpandEnv(val)
|
||||||
|
// 验证路径存在
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// GetCommonPaths 获取常用系统路径
|
// GetCommonPaths 获取常用系统路径
|
||||||
func (a *App) GetCommonPaths() (map[string]string, error) {
|
func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -387,10 +407,22 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
paths := map[string]string{
|
paths := map[string]string{
|
||||||
"home": homeDir,
|
"home": homeDir,
|
||||||
"desktop": filepath.Join(homeDir, "Desktop"),
|
}
|
||||||
"documents": filepath.Join(homeDir, "Documents"),
|
|
||||||
"downloads": filepath.Join(homeDir, "Downloads"),
|
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
|
||||||
|
folderGUIDs := map[string]string{
|
||||||
|
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||||
|
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
|
||||||
|
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||||
|
}
|
||||||
|
for name, guid := range folderGUIDs {
|
||||||
|
if p := getWindowsSpecialFolder(guid, name); p != "" {
|
||||||
|
paths[name] = p
|
||||||
|
} else {
|
||||||
|
// folderGUIDs 的 key 均为 ASCII,无需 Unicode 处理
|
||||||
|
paths[name] = filepath.Join(homeDir, strings.ToUpper(name[:1])+name[1:])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows: 动态添加所有盘符
|
// Windows: 动态添加所有盘符
|
||||||
@@ -434,6 +466,11 @@ func (a *App) TestDbConnectionWithParams(req api.TestConnectionRequest) error {
|
|||||||
return a.connectionAPI.TestDbConnectionWithParams(req)
|
return a.connectionAPI.TestDbConnectionWithParams(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadAllDatabases 加载全部数据库列表
|
||||||
|
func (a *App) LoadAllDatabases(req api.LoadAllDatabasesRequest) ([]string, error) {
|
||||||
|
return a.connectionAPI.LoadAllDatabases(req)
|
||||||
|
}
|
||||||
|
|
||||||
// ExecuteSQL 执行 SQL 语句
|
// ExecuteSQL 执行 SQL 语句
|
||||||
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
// 注意:SQL 语句应该已经包含分页信息(LIMIT 和 OFFSET),由客户端添加
|
||||||
func (a *App) ExecuteSQL(connectionId uint, sqlStr string, database string) (map[string]interface{}, error) {
|
func (a *App) ExecuteSQL(connectionId uint, sqlStr string, database string) (map[string]interface{}, error) {
|
||||||
@@ -540,6 +577,16 @@ func (a *App) WindowIsMaximized() bool {
|
|||||||
return false
|
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 标签页管理接口 ==========
|
// ========== SQL 标签页管理接口 ==========
|
||||||
|
|
||||||
// SaveSqlTabs 保存 SQL 标签页列表
|
// SaveSqlTabs 保存 SQL 标签页列表
|
||||||
@@ -554,68 +601,84 @@ func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
// ========== 版本更新管理接口 ==========
|
// ========== 版本更新管理接口 ==========
|
||||||
|
|
||||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
|
||||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
|
||||||
if a.updateAPI == nil {
|
if a.updateAPI == nil {
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||||
}
|
}
|
||||||
return a.updateAPI.CheckUpdate()
|
return a.updateAPI, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||||
|
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||||
|
api, err := a.requireUpdateAPI()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return api.CheckUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentVersion 获取当前版本号
|
// GetCurrentVersion 获取当前版本号
|
||||||
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.GetCurrentVersion()
|
return api.GetCurrentVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUpdateConfig 获取更新配置
|
// GetUpdateConfig 获取更新配置
|
||||||
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.GetUpdateConfig()
|
return api.GetUpdateConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetUpdateConfig 设置更新配置
|
// SetUpdateConfig 设置更新配置
|
||||||
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadUpdate 下载更新包
|
// DownloadUpdate 下载更新包
|
||||||
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.DownloadUpdate(downloadURL)
|
return api.DownloadUpdate(downloadURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallUpdate 安装更新包
|
// InstallUpdate 安装更新包
|
||||||
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
|
return api.InstallUpdate(installerPath, autoRestart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||||
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyUpdateFile 验证更新文件哈希值
|
// VerifyUpdateFile 验证更新文件哈希值
|
||||||
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||||
if a.updateAPI == nil {
|
api, err := a.requireUpdateAPI()
|
||||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
|
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startAutoUpdateCheck 启动自动更新检查
|
// startAutoUpdateCheck 启动自动更新检查
|
||||||
@@ -625,7 +688,11 @@ func (a *App) startAutoUpdateCheck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config, err := a.updateAPI.GetUpdateConfig()
|
config, err := a.updateAPI.GetUpdateConfig()
|
||||||
if err != nil || !config["success"].(bool) {
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success, ok := config["success"].(bool)
|
||||||
|
if !ok || !success {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,7 +767,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
// GetFileServerURL 获取本地文件服务器的URL
|
// GetFileServerURL 获取本地文件服务器的URL
|
||||||
func (a *App) GetFileServerURL() string {
|
func (a *App) GetFileServerURL() string {
|
||||||
return "http://localhost:18765"
|
return "http://localhost:8073"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
||||||
@@ -858,3 +925,47 @@ func (a *App) initFilesystemModule() {
|
|||||||
|
|
||||||
fmt.Println("[模块] 文件系统模块初始化完成")
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 35 KiB |
1
build/publish/last-version.json
Normal file
1
build/publish/last-version.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- Markdown 编辑器: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- PDF 导出: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- 窗口置顶 + 收藏夹置顶\n- Excel/Word 文件预览支持\n- 数据库 UI 大幅改进: 查询历史、查询模板、SQL 工具栏、结果导出\n- 数据库可见性过滤与连接管理增强\n\n### 优化 🚀\n- MySQL 动态连接池重构(健康检查、性能权重、自适应扩缩容)\n- SQL 查询优化器(查询缓存、慢查询日志)\n- Redis Pipeline 支持\n- Wails 框架升级 + FileListPanel 重写\n- CSV 编辑模式优化 + 拷贝功能优化\n\n### 修复 🐛\n- Office 类型检测修复、CORS 跨域修复、大文件卡死修复\n\n### 安全修复 🔒\n- XSS 防护、PDF 路径穿越防护、HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化、大规模死代码清理(-1306行)", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c", "force_update": false}
|
||||||
1
build/publish/versions.json
Normal file
1
build/publish/versions.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"updated_at": "2026-04-13T23:45:00+08:00", "versions": [{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c"}, {"version": "0.3.2", "release_date": "2026-02-05", "changelog": "### 重构 🔧\n- CodeMirror 架构优化 - 统一导出避免多实例问题\n- 语言加载器优化 - 从动态 import 改为静态导入\n- 动态主题切换 - 使用 Compartment 实现无损切换\n\n### 优化 🚀\n- 编辑器性能 - 添加内容更新防抖\n- 亮色主题 - 改进代码编辑器亮色模式样式", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.3.0", "release_date": "2026-02-04", "changelog": "### 新增 ✨\n- Markdown 图表支持 - Mermaid 流程图、时序图、类图等\n- 代码语法高亮 - 支持 20+ 种常用编程语言\n- 文件列表优化 - 文件夹优先显示,同类型按名称排序", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.2.0", "release_date": "2026-01-28", "changelog": "### 新增 ✨\n- 应用配置管理 - 全新设置面板,支持自定义显示模块和默认启动页\n- 智能更新提醒 - 新增版本更新通知组件\n- 模块重命名 - 应用更名为 u-desk", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.5", "release_date": "2026-01-22", "changelog": "### 新增 ✨\n- 文件管理模块 - 文件浏览、编辑、操作功能\n- 版本更新管理 - 自动检查和下载更新\n- 系统信息查询 - CPU、内存、磁盘等硬件信息", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.0", "release_date": "2026-01-18", "changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能", "download_url": "", "file_size": 0, "sha256": ""}]}
|
||||||
BIN
build/windows/app-icon.png
Normal file
BIN
build/windows/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
44
build/windows/convert-ico.ps1
Normal file
44
build/windows/convert-ico.ps1
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$srcPath = "E:\wk-lab\u-desk\build\windows\app-icon.png"
|
||||||
|
$icoPath = "E:\wk-lab\u-desk\build\windows\icon.ico"
|
||||||
|
$sizes = @(256, 128, 64, 48, 32, 16)
|
||||||
|
|
||||||
|
$src = [System.Drawing.Image]::FromFile($srcPath)
|
||||||
|
$fs = New-Object System.IO.FileStream($icoPath, [System.IO.FileMode]::Create)
|
||||||
|
$w = New-Object System.IO.BinaryWriter($fs)
|
||||||
|
|
||||||
|
$w.Write([uint16]0)
|
||||||
|
$w.Write([uint16]1)
|
||||||
|
$w.Write([uint16]$sizes.Count)
|
||||||
|
|
||||||
|
foreach ($sz in $sizes) {
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap($sz, $sz, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.DrawImage($src, 0, 0, $sz, $sz)
|
||||||
|
$g.Dispose()
|
||||||
|
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bytes = $ms.ToArray()
|
||||||
|
$ms.Dispose()
|
||||||
|
$bmp.Dispose()
|
||||||
|
|
||||||
|
$w.Write([uint32]40)
|
||||||
|
$w.Write([int32]$sz)
|
||||||
|
$w.Write([int32]$sz)
|
||||||
|
$w.Write([uint16]1)
|
||||||
|
$w.Write([uint32]32)
|
||||||
|
$w.Write([uint32]$bytes.Length)
|
||||||
|
$w.Write([uint32]22)
|
||||||
|
$w.Write($bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
$w.Close()
|
||||||
|
$fs.Close()
|
||||||
|
$src.Dispose()
|
||||||
|
|
||||||
|
$item = Get-Item $icoPath
|
||||||
|
Write-Output "ICO: $($item.Name) ($([math]::Round($item.Length / 1KB)) KB)"
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 53 KiB |
BIN
build/windows/u-desk-icon.png
Normal file
BIN
build/windows/u-desk-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
73
cmd/debug_db/main.go
Normal file
73
cmd/debug_db/main.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"u-desk/internal/storage"
|
||||||
|
"u-desk/internal/storage/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 初始化数据库
|
||||||
|
db, err := storage.Init()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("数据库初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=== 数据库连接配置调试工具 ===")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 列出所有连接
|
||||||
|
var connections []models.DbConnection
|
||||||
|
result := db.Order("id").Find(&connections)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Fatalf("查询失败: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("当前有 %d 个连接配置:\n", len(connections))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
for _, conn := range connections {
|
||||||
|
fmt.Printf("ID: %d\n", conn.ID)
|
||||||
|
fmt.Printf(" 名称: %s\n", conn.Name)
|
||||||
|
fmt.Printf(" 类型: %s\n", conn.Type)
|
||||||
|
fmt.Printf(" 主机: %s:%d\n", conn.Host, conn.Port)
|
||||||
|
fmt.Printf(" 用户名: %s\n", conn.Username)
|
||||||
|
fmt.Printf(" 创建时间: %s\n", conn.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 询问用户操作
|
||||||
|
var choice int
|
||||||
|
fmt.Print("请选择操作:\n")
|
||||||
|
fmt.Print("1. 删除指定 ID 的连接\n")
|
||||||
|
fmt.Print("2. 列出连接详情\n")
|
||||||
|
fmt.Print("0. 退出\n")
|
||||||
|
fmt.Print("请输入: ")
|
||||||
|
fmt.Scanln(&choice)
|
||||||
|
|
||||||
|
if choice == 1 {
|
||||||
|
var id uint
|
||||||
|
fmt.Print("请输入要删除的连接 ID: ")
|
||||||
|
fmt.Scanln(&id)
|
||||||
|
|
||||||
|
// 确认
|
||||||
|
var confirm string
|
||||||
|
fmt.Printf("确认删除 ID=%d 的连接吗?(y/N): ", id)
|
||||||
|
fmt.Scanln(&confirm)
|
||||||
|
|
||||||
|
if confirm == "y" || confirm == "Y" {
|
||||||
|
result := db.Delete(&models.DbConnection{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("删除失败: %v", result.Error)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("删除成功!影响行数: %d\n", result.RowsAffected)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("已取消删除")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n工具退出")
|
||||||
|
}
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
# GO-DESK 代码审查总结(2026-01-29)
|
|
||||||
|
|
||||||
## 📊 审查概况
|
|
||||||
|
|
||||||
**审查日期**: 2026-01-29
|
|
||||||
**审查人员**: Claude Code
|
|
||||||
**审查范围**: 核心业务模块(10个文件)
|
|
||||||
**审查时长**: 约2小时
|
|
||||||
**总体评分**: ⭐⭐⭐⭐ (4/5)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 审查成果
|
|
||||||
|
|
||||||
### 发现问题统计
|
|
||||||
- **总计**: 9个问题
|
|
||||||
- **高优先级**: 3个(必须修复)
|
|
||||||
- **中优先级**: 3个(建议修复)
|
|
||||||
- **低优先级**: 3个(可选优化)
|
|
||||||
|
|
||||||
### 生成的文档
|
|
||||||
1. ✅ [代码审查执行摘要.md](../代码审查执行摘要.md) - 快速行动指南
|
|
||||||
2. ✅ [代码审查报告_2026-01-29.md](../代码审查报告_2026-01-29.md) - 详细分析报告
|
|
||||||
3. ✅ [代码重构示例_2026-01-29.md](../代码重构示例_2026-01-29.md) - 重构参考代码
|
|
||||||
4. ✅ [README.md](./README.md) - 文档索引
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 高优先级问题(3个)
|
|
||||||
|
|
||||||
### 1. SQL初始化错误处理缺失
|
|
||||||
**文件**: `internal/storage/sqlite.go:53`
|
|
||||||
**影响**: 可能导致运行时panic
|
|
||||||
**修复时间**: 5分钟
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 修复前
|
|
||||||
sqlDB, _ := db.DB()
|
|
||||||
|
|
||||||
// 修复后
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取底层SQL数据库失败: %v", err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. BYTE_UNITS常量拼写错误
|
|
||||||
**文件**: `web/src/utils/constants.js:274`
|
|
||||||
**影响**: 文件大小格式化功能bug
|
|
||||||
**修复时间**: 2分钟
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 修复前
|
|
||||||
export const BYTE_UNITS = ['B', 'KMGTPE']
|
|
||||||
|
|
||||||
// 修复后
|
|
||||||
export const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 哈希计算逻辑重复
|
|
||||||
**文件**: `internal/service/update_download.go:284-338`
|
|
||||||
**影响**: 维护困难,违反DRY原则
|
|
||||||
**修复时间**: 2小时
|
|
||||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例1-哈希计算逻辑合并)
|
|
||||||
|
|
||||||
**预计收益**:
|
|
||||||
- 代码行数减少40%
|
|
||||||
- 消除重复逻辑
|
|
||||||
- 易于扩展新的哈希类型
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 中优先级问题(3个)
|
|
||||||
|
|
||||||
### 4. readFile函数过长(150+行)
|
|
||||||
**文件**: `web/src/components/FileSystem.vue:987-1138`
|
|
||||||
**影响**: 可读性和维护性差
|
|
||||||
**修复时间**: 4小时
|
|
||||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例4-复杂函数拆分)
|
|
||||||
|
|
||||||
**预期收益**:
|
|
||||||
- 函数长度减少50%
|
|
||||||
- 职责更清晰
|
|
||||||
- 易于测试
|
|
||||||
|
|
||||||
### 5. 频繁的localStorage写入
|
|
||||||
**文件**: `web/src/composables/useFileOperations.js:330`
|
|
||||||
**影响**: 性能问题
|
|
||||||
**修复时间**: 30分钟
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 添加防抖
|
|
||||||
import { debounce } from 'lodash-es'
|
|
||||||
|
|
||||||
const savePathToStorage = debounce((newPath) => {
|
|
||||||
localStorage.setItem(STORAGE_KEY_LAST_PATH, newPath)
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
watch(filePath, savePathToStorage)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 重复的Message提示模式
|
|
||||||
**文件**: `web/src/composables/useFileOperations.js`, `useFavoriteFiles.js`
|
|
||||||
**影响**: 违反DRY原则,用户体验不一致
|
|
||||||
**修复时间**: 3小时
|
|
||||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例3-message提示模式)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟢 低优先级问题(3个)
|
|
||||||
|
|
||||||
### 7. 文件类型检查逻辑分散
|
|
||||||
**修复时间**: 6小时
|
|
||||||
**详细方案**: 参见[代码重构示例](../代码重构示例_2026-01-29.md#-重构示例2-前端文件类型检查)
|
|
||||||
|
|
||||||
### 8. TypeScript使用不足
|
|
||||||
**建议**: 逐步迁移到TypeScript
|
|
||||||
**时间**: 长期规划
|
|
||||||
|
|
||||||
### 9. 单元测试覆盖不足
|
|
||||||
**建议**: 为核心逻辑添加单元测试
|
|
||||||
**目标**: 覆盖率从10%提升到60%+
|
|
||||||
**时间**: 长期规划
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 代码质量指标
|
|
||||||
|
|
||||||
| 指标 | 当前值 | 目标值 | 差距 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 代码重复率 | 15% | <5% | -10% |
|
|
||||||
| 平均函数长度 | 80行 | <30行 | -50行 |
|
|
||||||
| 圈复杂度 | 15+ | <10 | -5 |
|
|
||||||
| 测试覆盖率 | 10% | >60% | +50% |
|
|
||||||
| TypeScript覆盖率 | 0% | >80% | +80% |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 修复行动计划
|
|
||||||
|
|
||||||
### 第1周(立即执行)
|
|
||||||
**目标**: 修复所有高优先级问题
|
|
||||||
**预计时间**: 2.5小时
|
|
||||||
|
|
||||||
- [ ] 修复SQL初始化错误处理(5分钟)
|
|
||||||
- [ ] 修复BYTE_UNITS常量(2分钟)
|
|
||||||
- [ ] 重构哈希计算逻辑(2小时)
|
|
||||||
|
|
||||||
### 第2-3周(近期执行)
|
|
||||||
**目标**: 修复中优先级问题
|
|
||||||
**预计时间**: 8.5小时
|
|
||||||
|
|
||||||
- [ ] 拆分readFile函数(4小时)
|
|
||||||
- [ ] 添加localStorage防抖(30分钟)
|
|
||||||
- [ ] 提取Message提示模式(3小时)
|
|
||||||
- [ ] 添加单元测试(1.5小时)
|
|
||||||
|
|
||||||
### 第4-8周(中期规划)
|
|
||||||
**目标**: 提升代码质量和测试覆盖率
|
|
||||||
**预计时间**: 16小时
|
|
||||||
|
|
||||||
- [ ] 提取文件类型检查模块(6小时)
|
|
||||||
- [ ] 添加核心功能单元测试(10小时)
|
|
||||||
|
|
||||||
### 长期规划
|
|
||||||
**目标**: 建立完善的代码质量保障体系
|
|
||||||
|
|
||||||
- [ ] 逐步迁移到TypeScript
|
|
||||||
- [ ] 提升测试覆盖率到60%+
|
|
||||||
- [ ] 建立CI/CD流程
|
|
||||||
- [ ] 定期代码审查机制
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 良好实践总结
|
|
||||||
|
|
||||||
### 优点(需保持)
|
|
||||||
1. ✅ **代码规范良好** - Go代码符合标准,错误处理完整
|
|
||||||
2. ✅ **模块化清晰** - composables模式复用良好
|
|
||||||
3. ✅ **文档完整** - 注释和文档较为完善
|
|
||||||
4. ✅ **资源管理正确** - defer使用得当,避免资源泄露
|
|
||||||
5. ✅ **用户反馈良好** - 删除操作有二次确认
|
|
||||||
|
|
||||||
### 需要改进
|
|
||||||
1. ⚠️ **消除代码重复** - 哈希计算、文件类型检查等
|
|
||||||
2. ⚠️ **函数拆分** - readFile等长函数需要拆分
|
|
||||||
3. ⚠️ **性能优化** - localStorage写入、哈希计算缓存
|
|
||||||
4. ⚠️ **类型安全** - 迁移到TypeScript
|
|
||||||
5. ⚠️ **测试覆盖** - 添加单元测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 修复效果预估
|
|
||||||
|
|
||||||
### 短期效果(1个月内)
|
|
||||||
- ✅ 消除所有功能性bug
|
|
||||||
- ✅ 代码重复率从15%降到5%
|
|
||||||
- ✅ 核心函数长度减少50%
|
|
||||||
|
|
||||||
### 中期效果(3个月内)
|
|
||||||
- ✅ 测试覆盖率从10%提升到40%
|
|
||||||
- ✅ TypeScript迁移完成30%
|
|
||||||
- ✅ 代码可维护性显著提升
|
|
||||||
|
|
||||||
### 长期效果(6个月内)
|
|
||||||
- ✅ 测试覆盖率>60%
|
|
||||||
- ✅ TypeScript迁移完成80%
|
|
||||||
- ✅ 建立完善的CI/CD流程
|
|
||||||
- ✅ 代码质量达到行业优秀水平
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 相关资源
|
|
||||||
|
|
||||||
### 文档
|
|
||||||
- [执行摘要](../代码审查执行摘要.md) - 快速行动指南
|
|
||||||
- [完整报告](../代码审查报告_2026-01-29.md) - 详细分析
|
|
||||||
- [重构示例](../代码重构示例_2026-01-29.md) - 代码参考
|
|
||||||
|
|
||||||
### 外部资源
|
|
||||||
- [Effective Go](https://golang.org/doc/effective_go.html)
|
|
||||||
- [Vue风格指南](https://vuejs.org/style-guide/)
|
|
||||||
- [Clean Code](https://www.oreilly.com/library/view/clean-code-a/9780136083238/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 审查结论
|
|
||||||
|
|
||||||
**总体评价**: ⭐⭐⭐⭐ (4/5)
|
|
||||||
|
|
||||||
GO-DESK项目代码质量整体良好,架构清晰,模块化程度高。主要问题集中在代码重复和函数过长上,通过系统性重构可以显著提升代码质量。
|
|
||||||
|
|
||||||
**建议行动**:
|
|
||||||
1. 立即修复高优先级bug(预计2.5小时)
|
|
||||||
2. 近期重构核心函数(预计8.5小时)
|
|
||||||
3. 长期建立质量保障体系
|
|
||||||
|
|
||||||
**预期收益**:
|
|
||||||
- 代码可维护性提升50%
|
|
||||||
- 开发效率提升30%
|
|
||||||
- Bug率降低40%
|
|
||||||
- 团队代码质量意识提升
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**审查人**: Claude Code
|
|
||||||
**审查日期**: 2026-01-29
|
|
||||||
**下次审查**: 建议在重构完成后(约1个月后)
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
# 🎉 代码审查与优化完整总结报告
|
|
||||||
|
|
||||||
## 执行时间
|
|
||||||
2026-01-27
|
|
||||||
|
|
||||||
## 项目概览
|
|
||||||
**项目名称**:go-desk (U-Desk 数据库客户端)
|
|
||||||
**技术栈**:Go + Wails + Vue 3
|
|
||||||
**审查范围**:全代码库(后端 + 前端)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 总体改进统计
|
|
||||||
|
|
||||||
### 代码质量提升
|
|
||||||
|
|
||||||
| 维度 | 初始评分 | 最终评分 | 提升幅度 |
|
|
||||||
|------|---------|---------|---------|
|
|
||||||
| **整体质量** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +40% |
|
|
||||||
| **DRY 原则** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +40% |
|
|
||||||
| **配置管理** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐☆ | +60% |
|
|
||||||
| **代码简洁** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | +40% |
|
|
||||||
| **可维护性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +40% |
|
|
||||||
| **安全意识** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐☆ | +60% |
|
|
||||||
|
|
||||||
### 代码改进量化
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ 消除重复代码: ~100 行
|
|
||||||
✅ 消除硬编码配置: 20+ 处
|
|
||||||
✅ 优化日志记录: 18 个
|
|
||||||
✅ 简化注释: -150 行
|
|
||||||
✅ 删除过度封装: 1 个文件
|
|
||||||
✅ 新增工具函数: 2 个
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 已完成的优化(按级别)
|
|
||||||
|
|
||||||
### P0 级别(严重问题)
|
|
||||||
- ✅ 无严重问题
|
|
||||||
|
|
||||||
### P1 级别(重要)- 3项全部完成
|
|
||||||
|
|
||||||
#### 1. 重复的 formatBytes 函数 ✅
|
|
||||||
**问题**:3处重复实现
|
|
||||||
**解决**:提取到 `internal/common/utils.go`
|
|
||||||
**效果**:消除重复,统一维护
|
|
||||||
|
|
||||||
#### 2. 前端文件类型判断硬编码 ✅
|
|
||||||
**问题**:硬编码扩展名列表
|
|
||||||
**解决**:使用 FILE_EXTENSIONS 常量
|
|
||||||
**效果**:配置集中化
|
|
||||||
|
|
||||||
#### 3. FileSystem.vue 组件过大 ⚠️
|
|
||||||
**问题**:2365行单一文件
|
|
||||||
**状态**:已记录,建议单独重构项目
|
|
||||||
|
|
||||||
### P2 级别(中等)- 3项全部完成
|
|
||||||
|
|
||||||
#### 4. ZIP 文件过度日志 ✅
|
|
||||||
**问题**:18个无条件调试日志
|
|
||||||
**解决**:改为条件日志(UDESK_ZIP_DEBUG=1)
|
|
||||||
**效果**:生产环境安静,开发时可调试
|
|
||||||
|
|
||||||
#### 5. 重复的错误处理模式 ✅
|
|
||||||
**问题**:200+ 处重复错误处理
|
|
||||||
**解决**:创建错误处理辅助函数(后删除过度封装)
|
|
||||||
**效果**:保持简单,不过度抽象
|
|
||||||
|
|
||||||
#### 6. ZIP 路径验证重复 ✅
|
|
||||||
**问题**:4个函数重复验证
|
|
||||||
**解决**:提取 validateZipPath 函数
|
|
||||||
**效果**:代码减少20行
|
|
||||||
|
|
||||||
### P3 级别(轻微)- 2项完成
|
|
||||||
|
|
||||||
#### 7. 超时配置统一 ✅
|
|
||||||
**问题**:14处硬编码超时
|
|
||||||
**解决**:创建 timeout.go 配置
|
|
||||||
**效果**:统一管理,分级策略
|
|
||||||
|
|
||||||
#### 8. 文档注释完善 → 简化 ✅
|
|
||||||
**初始**:过度详细的文档(170行注释)
|
|
||||||
**优化**:简化为适度注释(20行注释)
|
|
||||||
**效果**:更简洁,避免过度
|
|
||||||
|
|
||||||
### 深度优化 - 2项完成
|
|
||||||
|
|
||||||
#### 9. 避免过度封装 ✅
|
|
||||||
**问题**:创建了未被使用的 WrapError
|
|
||||||
**解决**:删除 errors.go,简化注释
|
|
||||||
**效果**:符合 YAGNI 和 KISS 原则
|
|
||||||
|
|
||||||
#### 10. 代码质量和安全检查 ✅
|
|
||||||
**发现**:
|
|
||||||
- 🔴 硬编码数据库密码(安全隐患)
|
|
||||||
- 🟠 40个 console.log
|
|
||||||
- 🟡 未处理的 TODO
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 创建和修改的文件
|
|
||||||
|
|
||||||
### 新增文件(2个)
|
|
||||||
1. ✅ `internal/common/utils.go` - 格式化工具(21行)
|
|
||||||
2. ✅ `internal/common/timeout.go` - 超时配置(12行)
|
|
||||||
|
|
||||||
### 修改文件(6个)
|
|
||||||
1. ✅ `internal/system/system.go` - 使用共享 FormatBytes
|
|
||||||
2. ✅ `internal/filesystem/zip.go` - 提取验证函数 + 条件日志
|
|
||||||
3. ✅ `internal/service/sql_exec_service.go` - 使用统一超时
|
|
||||||
4. ✅ `internal/dbclient/pool.go` - 使用统一超时
|
|
||||||
5. ✅ `internal/dbclient/redis.go` - 使用统一超时
|
|
||||||
6. ✅ `internal/dbclient/mongo.go` - 使用统一超时
|
|
||||||
|
|
||||||
### 前端修改(1个)
|
|
||||||
7. ✅ `web/src/utils/fileUtils.js` - 使用 FILE_EXTENSIONS 常量
|
|
||||||
|
|
||||||
### 生成的文档(4个)
|
|
||||||
1. ✅ `docs/code-review-p3-report.md` - P3 优化报告
|
|
||||||
2. ✅ `docs/code-review-deep-optimization-report.md` - 深度优化报告
|
|
||||||
3. ✅ `docs/anti-over-engineering-report.md` - 避免过度封装报告
|
|
||||||
4. ✅ `docs/code-quality-security-report.md` - 质量和安全检查
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 核心改进亮点
|
|
||||||
|
|
||||||
### 1. 建立了 common 工具包 ✨
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/common/
|
|
||||||
├── utils.go # FormatBytes - 消除重复
|
|
||||||
└── timeout.go # 超时常量 - 统一配置
|
|
||||||
```
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
- ✅ 简洁实用(2个文件,33行代码)
|
|
||||||
- ✅ 每个函数都有实际使用
|
|
||||||
- ✅ 避免过度封装
|
|
||||||
- ✅ 注释适度
|
|
||||||
|
|
||||||
### 2. 超时分级策略 ✨
|
|
||||||
|
|
||||||
| 级别 | 超时 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| Ping | 2秒 | 连接测试 |
|
|
||||||
| Connect | 5秒 | 建立连接 |
|
|
||||||
| FastQuery | 10秒 | 元数据查询 |
|
|
||||||
| Query | 30秒 | 普通查询 |
|
|
||||||
| LongOp | 60秒 | 复杂操作 |
|
|
||||||
|
|
||||||
**价值**:
|
|
||||||
- 14处硬编码 → 统一配置
|
|
||||||
- 平衡用户体验和系统资源
|
|
||||||
- 支持环境差异化
|
|
||||||
|
|
||||||
### 3. 条件日志机制 ✨
|
|
||||||
|
|
||||||
```go
|
|
||||||
var zipDebugMode = os.Getenv("UDESK_ZIP_DEBUG") == "1"
|
|
||||||
|
|
||||||
func debugLog(format string, args ...interface{}) {
|
|
||||||
if zipDebugMode {
|
|
||||||
log.Printf(format, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**使用**:
|
|
||||||
```bash
|
|
||||||
# 生产环境:无调试日志
|
|
||||||
./go-desk
|
|
||||||
|
|
||||||
# 开发环境:启用详细日志
|
|
||||||
UDESK_ZIP_DEBUG=1 ./go-desk
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 前端配置常量化 ✨
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 修改前:硬编码
|
|
||||||
return ['jpg', 'jpeg', 'png', 'gif'].includes(ext)
|
|
||||||
|
|
||||||
// 修改后:使用常量
|
|
||||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
|
||||||
```
|
|
||||||
|
|
||||||
**价值**:
|
|
||||||
- 修改一处,全局生效
|
|
||||||
- 便于扩展新类型
|
|
||||||
- 配置集中管理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 发现的待修复问题
|
|
||||||
|
|
||||||
### 🔴 紧急(安全)
|
|
||||||
|
|
||||||
#### 硬编码数据库凭证
|
|
||||||
**位置**:`internal/database/db.go:36-37`
|
|
||||||
**风险**:代码泄露导致数据库被攻击
|
|
||||||
**建议**:使用环境变量或配置文件
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 建议修改
|
|
||||||
config := mysqldriver.Config{
|
|
||||||
User: os.Getenv("DB_USER"),
|
|
||||||
Passwd: os.Getenv("DB_PASSWORD"),
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🟠 重要(代码质量)
|
|
||||||
|
|
||||||
#### 1. 过多的 console.log
|
|
||||||
**位置**:`web/src/components/FileSystem.vue`
|
|
||||||
**数量**:40个
|
|
||||||
**建议**:创建条件日志工具
|
|
||||||
|
|
||||||
#### 2. FileSystem.vue 组件过大
|
|
||||||
**大小**:2365行
|
|
||||||
**建议**:拆分为多个小组件和 composables
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 最终代码质量评分
|
|
||||||
|
|
||||||
### 总体评分:⭐⭐⭐⭐☆ (4.5/5)
|
|
||||||
|
|
||||||
| 评分维度 | 得分 | 说明 |
|
|
||||||
|---------|------|------|
|
|
||||||
| **DRY 原则** | ⭐⭐⭐⭐⭐ | 无重复代码 |
|
|
||||||
| **配置管理** | ⭐⭐⭐⭐☆ | 统一配置管理 |
|
|
||||||
| **代码简洁** | ⭐⭐⭐⭐☆ | 简洁易读 |
|
|
||||||
| **可维护性** | ⭐⭐⭐⭐⭐ | 结构清晰 |
|
|
||||||
| **日志管理** | ⭐⭐⭐⭐☆ | 可控可调 |
|
|
||||||
| **安全意识** | ⭐⭐⭐☆☆ | 有保护,需改进 |
|
|
||||||
|
|
||||||
**说明**:
|
|
||||||
- ✅ 代码质量优秀,结构清晰
|
|
||||||
- ⚠️ 需要修复硬编码凭证(安全)
|
|
||||||
- ⚠️ 建议重构大组件(可维护性)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ 安全检查结果
|
|
||||||
|
|
||||||
### ✅ 已有的安全措施
|
|
||||||
|
|
||||||
1. **路径遍历保护** ✅
|
|
||||||
```go
|
|
||||||
func isSafePath(path string) bool {
|
|
||||||
if strings.Contains(cleanPath, "..") {
|
|
||||||
return false // ✅ 防止 ../ 攻击
|
|
||||||
}
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **SQL 注入防护** ✅
|
|
||||||
```go
|
|
||||||
query.Where("membername LIKE ?", keyword) // ✅ 参数化查询
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **系统目录保护** ✅
|
|
||||||
```go
|
|
||||||
forbidden := []string{
|
|
||||||
`c:\windows`,
|
|
||||||
`c:\program files`,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚠️ 发现的安全隐患
|
|
||||||
|
|
||||||
1. **硬编码凭证** 🔴
|
|
||||||
- 数据库密码:123456
|
|
||||||
- 建议:使用环境变量
|
|
||||||
|
|
||||||
2. **调试日志过多** 🟠
|
|
||||||
- 40个 console.log
|
|
||||||
- 建议:条件日志
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 最佳实践应用
|
|
||||||
|
|
||||||
### ✅ 成功应用的原则
|
|
||||||
|
|
||||||
1. **DRY(Don't Repeat Yourself)**
|
|
||||||
- ✅ 提取 FormatBytes
|
|
||||||
- ✅ 提取 validateZipPath
|
|
||||||
- ✅ 统一超时配置
|
|
||||||
|
|
||||||
2. **YAGNI(You Aren't Gonna Need It)**
|
|
||||||
- ✅ 删除未使用的 WrapError
|
|
||||||
- ✅ 删除过度封装
|
|
||||||
- ✅ 简化冗长注释
|
|
||||||
|
|
||||||
3. **KISS(Keep It Simple, Stupid)**
|
|
||||||
- ✅ 优先使用标准库
|
|
||||||
- ✅ 避免过度抽象
|
|
||||||
- ✅ 代码简洁明了
|
|
||||||
|
|
||||||
4. **防御性编程(适度)**
|
|
||||||
- ✅ 路径安全检查
|
|
||||||
- ✅ SQL 参数化查询
|
|
||||||
- ⚠️ 避免过度防御
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 优化前后对比
|
|
||||||
|
|
||||||
### 代码重复
|
|
||||||
|
|
||||||
| 类型 | 优化前 | 优化后 | 改善 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| formatBytes | 3处重复 | 1处共享 | -67% |
|
|
||||||
| ZIP验证 | 4处重复 | 1处共享 | -75% |
|
|
||||||
| 文件扩展名 | 7处重复 | 1处常量 | -86% |
|
|
||||||
|
|
||||||
### 配置管理
|
|
||||||
|
|
||||||
| 类型 | 优化前 | 优化后 | 改善 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 超时时间 | 14处硬编码 | 5个常量 | 集中化 |
|
|
||||||
| 文件类型 | 7处硬编码 | 1个常量 | 集中化 |
|
|
||||||
| 日志输出 | 18个无条件 | 条件控制 | 可配置 |
|
|
||||||
|
|
||||||
### 文档注释
|
|
||||||
|
|
||||||
| 类型 | 优化前 | 优化后 | 改善 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 注释总量 | ~200行 | ~30行 | -85% |
|
|
||||||
| 注释质量 | 过度详细 | 适度精简 | 更实用 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 后续建议
|
|
||||||
|
|
||||||
### 🔴 紧急(本周内)
|
|
||||||
|
|
||||||
1. **修复硬编码凭证**
|
|
||||||
```bash
|
|
||||||
# 使用环境变量
|
|
||||||
export DB_USER=root
|
|
||||||
export DB_PASSWORD=your_secure_password
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **创建 .gitignore**
|
|
||||||
```
|
|
||||||
.env
|
|
||||||
config.local.json
|
|
||||||
*.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🟠 重要(本月内)
|
|
||||||
|
|
||||||
3. **重构 FileSystem.vue**
|
|
||||||
- 拆分为多个小组件
|
|
||||||
- 提取 composables
|
|
||||||
- 减少到 <500 行
|
|
||||||
|
|
||||||
4. **清理 console.log**
|
|
||||||
- 创建条件日志工具
|
|
||||||
- 仅开发环境输出
|
|
||||||
|
|
||||||
### 🟢 优化(下个迭代)
|
|
||||||
|
|
||||||
5. **添加单元测试**
|
|
||||||
- common 包测试
|
|
||||||
- 关键函数测试
|
|
||||||
- 集成测试
|
|
||||||
|
|
||||||
6. **性能优化**
|
|
||||||
- 大文件处理
|
|
||||||
- ZIP 读取优化
|
|
||||||
- 内存使用优化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 验证状态
|
|
||||||
|
|
||||||
### 编译验证
|
|
||||||
```bash
|
|
||||||
$ go build -v
|
|
||||||
go-desk/internal/common
|
|
||||||
go-desk/internal/system
|
|
||||||
go-desk/internal/dbclient
|
|
||||||
go-desk/internal/service
|
|
||||||
go-desk/internal/api
|
|
||||||
go-desk
|
|
||||||
✅ 编译成功
|
|
||||||
```
|
|
||||||
|
|
||||||
### 代码检查
|
|
||||||
```bash
|
|
||||||
$ go vet ./...
|
|
||||||
✅ 无问题
|
|
||||||
|
|
||||||
$ go fmt ./...
|
|
||||||
✅ 格式正确
|
|
||||||
```
|
|
||||||
|
|
||||||
### 兼容性
|
|
||||||
- ✅ 无破坏性修改
|
|
||||||
- ✅ 向后兼容
|
|
||||||
- ✅ API 未改变
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 生成的文档
|
|
||||||
|
|
||||||
### 审查报告
|
|
||||||
1. ✅ **code-review-p3-report.md** - P3 级别优化报告
|
|
||||||
2. ✅ **code-review-deep-optimization-report.md** - 深度优化报告
|
|
||||||
3. ✅ **anti-over-engineering-report.md** - 避免过度封装报告
|
|
||||||
4. ✅ **code-quality-security-report.md** - 质量和安全检查
|
|
||||||
|
|
||||||
### 内容涵盖
|
|
||||||
- ✅ 问题分析
|
|
||||||
- ✅ 解决方案
|
|
||||||
- ✅ 代码示例
|
|
||||||
- ✅ 使用指南
|
|
||||||
- ✅ 后续建议
|
|
||||||
- ✅ 最佳实践
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 经验总结
|
|
||||||
|
|
||||||
### 成功经验
|
|
||||||
|
|
||||||
1. **小步快跑,持续优化**
|
|
||||||
- 分 P0/P1/P2/P3 优先级处理
|
|
||||||
- 每次改进后立即验证
|
|
||||||
- 避免大爆炸式重构
|
|
||||||
|
|
||||||
2. **审查过度封装**
|
|
||||||
- 删除了未使用的 WrapError
|
|
||||||
- 简化了冗长的注释
|
|
||||||
- 保持了代码简洁性
|
|
||||||
|
|
||||||
3. **统一配置管理**
|
|
||||||
- 超时配置集中化
|
|
||||||
- 文件类型常量化
|
|
||||||
- 便于维护和修改
|
|
||||||
|
|
||||||
4. **条件化调试输出**
|
|
||||||
- 日志可配置
|
|
||||||
- 生产环境安静
|
|
||||||
- 开发环境详细
|
|
||||||
|
|
||||||
### 需要改进
|
|
||||||
|
|
||||||
1. **凭证管理**
|
|
||||||
- 避免硬编码
|
|
||||||
- 使用环境变量
|
|
||||||
- 密钥管理最佳实践
|
|
||||||
|
|
||||||
2. **组件拆分**
|
|
||||||
- 避免超大组件
|
|
||||||
- 单一职责原则
|
|
||||||
- 提高可测试性
|
|
||||||
|
|
||||||
3. **测试覆盖**
|
|
||||||
- 添加单元测试
|
|
||||||
- 集成测试
|
|
||||||
- 自动化测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎊 最终评价
|
|
||||||
|
|
||||||
### 代码现状:⭐⭐⭐⭐☆ (4.5/5)
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- ✅ 代码质量优秀
|
|
||||||
- ✅ 结构清晰合理
|
|
||||||
- ✅ 无重复代码
|
|
||||||
- ✅ 配置集中管理
|
|
||||||
- ✅ 日志可控可调
|
|
||||||
- ✅ 有安全防护措施
|
|
||||||
|
|
||||||
**待改进**:
|
|
||||||
- ⚠️ 需修复硬编码凭证(安全)
|
|
||||||
- ⚠️ 建议重构大组件(可维护性)
|
|
||||||
- ⚠️ 添加单元测试(质量保证)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 附录
|
|
||||||
|
|
||||||
### 修改文件统计
|
|
||||||
- 新增文件:2个
|
|
||||||
- 修改文件:7个
|
|
||||||
- 删除文件:1个(过度封装)
|
|
||||||
- 生成文档:4个
|
|
||||||
|
|
||||||
### 代码行数变化
|
|
||||||
- 删除重复代码:~100行
|
|
||||||
- 新增工具代码:~30行
|
|
||||||
- 简化注释:-150行
|
|
||||||
- 净减少:~220行
|
|
||||||
|
|
||||||
### 编译验证
|
|
||||||
- ✅ Go 编译通过
|
|
||||||
- ✅ go vet 无问题
|
|
||||||
- ✅ go fmt 已格式化
|
|
||||||
- ✅ 无语法错误
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**报告生成时间**:2026-01-27
|
|
||||||
**审查类型**:全面代码审查与优化
|
|
||||||
**审查范围**:全代码库(Go + Vue)
|
|
||||||
**最终状态**:✅ 全部完成
|
|
||||||
**代码质量**:⭐⭐⭐⭐☆ 优秀
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**感谢您的耐心!代码审查和优化工作已圆满完成。** 🎉
|
|
||||||
|
|
||||||
如有任何问题或需要进一步的优化,请随时告知!
|
|
||||||
@@ -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个月后)
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
# 避免过度封装 - 代码清理报告
|
|
||||||
|
|
||||||
## 执行日期
|
|
||||||
2026-01-27
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
在代码优化过程中,需要警惕**过度封装**(Over-engineering)问题。
|
|
||||||
避免为了"优雅"而创建不必要的抽象层。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 检查发现的问题
|
|
||||||
|
|
||||||
### 问题 1: WrapError/WrapErrorf 过度封装 ❌
|
|
||||||
|
|
||||||
**原始实现**:
|
|
||||||
```go
|
|
||||||
// 创建了两个新函数,但代码中没有任何使用
|
|
||||||
func WrapError(operation string, err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s失败: %v", operation, err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题分析**:
|
|
||||||
1. ❌ 实际代码中**零使用**
|
|
||||||
2. ❌ 只是把 `fmt.Errorf` 包装了一层
|
|
||||||
3. ❌ 反而增加了学习成本和依赖
|
|
||||||
4. ❌ 违背了 YAGNI 原则(You Aren't Gonna Need It)
|
|
||||||
|
|
||||||
**正确做法**:
|
|
||||||
```go
|
|
||||||
// 直接使用标准库
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("操作失败: %v", err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**结论**:❌ **删除** - 过度封装,未被使用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 2: 文档注释过于冗长 ❌
|
|
||||||
|
|
||||||
**原始实现**:
|
|
||||||
- timeout.go: 70+ 行注释
|
|
||||||
- utils.go: 40+ 行注释
|
|
||||||
- errors.go: 60+ 行注释
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
1. ❌ 注释比代码还长
|
|
||||||
2. ❌ 包含大量"显而易见"的说明
|
|
||||||
3. ❌ 维护成本高
|
|
||||||
4. ❌ 违背了"代码即文档"原则
|
|
||||||
|
|
||||||
**优化后**:
|
|
||||||
```go
|
|
||||||
// 数据库操作超时配置
|
|
||||||
const (
|
|
||||||
TimeoutPing = 2 * time.Second // 连接测试超时
|
|
||||||
TimeoutConnect = 5 * time.Second // 初始连接超时
|
|
||||||
TimeoutFastQuery = 10 * time.Second // 元数据查询超时
|
|
||||||
TimeoutQuery = 30 * time.Second // 普通查询超时
|
|
||||||
TimeoutLongOp = 60 * time.Second // 长时间操作超时
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**结论**:✅ **简化** - 保持适度注释
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 3: timeout 配置 - 合理封装 ✅
|
|
||||||
|
|
||||||
**使用情况**:
|
|
||||||
```
|
|
||||||
sql_exec_service.go: 5处使用
|
|
||||||
pool.go: 2处使用
|
|
||||||
redis.go: 2处使用
|
|
||||||
mongo.go: 3处使用
|
|
||||||
```
|
|
||||||
|
|
||||||
**价值**:
|
|
||||||
1. ✅ 消除14处硬编码
|
|
||||||
2. ✅ 统一配置管理
|
|
||||||
3. ✅ 便于修改调整
|
|
||||||
4. ✅ 有实际使用价值
|
|
||||||
|
|
||||||
**结论**:✅ **保留** - 合理封装,有实际价值
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题 4: FormatBytes - 合理封装 ✅
|
|
||||||
|
|
||||||
**使用情况**:
|
|
||||||
```
|
|
||||||
system.go: GetMemoryInfo() 中使用
|
|
||||||
system.go: GetDiskInfo() 中使用
|
|
||||||
```
|
|
||||||
|
|
||||||
**价值**:
|
|
||||||
1. ✅ 消除了重复代码
|
|
||||||
2. ✅ 逻辑有一定复杂度(不是简单包装)
|
|
||||||
3. ✅ 有多个调用点
|
|
||||||
|
|
||||||
**结论**:✅ **保留** - DRY 原则应用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 执行的清理操作
|
|
||||||
|
|
||||||
### 1. 删除过度封装的文件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm internal/common/errors.go # WrapError/WrapErrorf 未使用
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- 零使用
|
|
||||||
- 只是对 fmt.Errorf 的简单包装
|
|
||||||
- 增加不必要的抽象层
|
|
||||||
|
|
||||||
### 2. 简化文档注释
|
|
||||||
|
|
||||||
**修改文件**:
|
|
||||||
- `internal/common/timeout.go` - 从 70 行注释减少到 12 行
|
|
||||||
- `internal/common/utils.go` - 从 40 行注释减少到 8 行
|
|
||||||
|
|
||||||
**原则**:
|
|
||||||
- ✅ 保留必要的注释(为什么这样做)
|
|
||||||
- ❌ 删除显而易见的注释(做了什么)
|
|
||||||
- ❌ 删除冗长的示例和说明
|
|
||||||
|
|
||||||
### 3. 保留有价值的封装
|
|
||||||
|
|
||||||
**保留文件**:
|
|
||||||
- `internal/common/utils.go` - FormatBytes(消除重复)
|
|
||||||
- `internal/common/timeout.go` - 超时常量(统一配置)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 清理效果
|
|
||||||
|
|
||||||
| 项目 | 清理前 | 清理后 | 说明 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| **common 包文件** | 3个 | 2个 | 删除 errors.go |
|
|
||||||
| **timeout.go 注释** | 70行 | 12行 | -83% |
|
|
||||||
| **utils.go 注释** | 40行 | 8行 | -80% |
|
|
||||||
| **实际使用的函数** | 3个 | 2个 | -1个 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 封装原则总结
|
|
||||||
|
|
||||||
### ✅ 应该封装的情况
|
|
||||||
|
|
||||||
1. **消除重复代码** (DRY)
|
|
||||||
```go
|
|
||||||
// ✅ 好:FormatBytes 被3个地方使用
|
|
||||||
common.FormatBytes(size)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **复杂逻辑**
|
|
||||||
```go
|
|
||||||
// ✅ 好:逻辑复杂,值得封装
|
|
||||||
func parseComplexConfig(data []byte) (*Config, error) {
|
|
||||||
// 50行复杂逻辑
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **统一配置**
|
|
||||||
```go
|
|
||||||
// ✅ 好:14处使用的配置常量
|
|
||||||
const TimeoutQuery = 30 * time.Second
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ 不应该封装的情况
|
|
||||||
|
|
||||||
1. **简单包装标准库**
|
|
||||||
```go
|
|
||||||
// ❌ 差:只是包装 fmt.Errorf
|
|
||||||
func WrapError(op string, err error) error {
|
|
||||||
return fmt.Errorf("%s失败: %v", op, err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **未被使用的抽象**
|
|
||||||
```go
|
|
||||||
// ❌ 差:定义了但没用
|
|
||||||
type TimeoutConfig struct { ... }
|
|
||||||
var DefaultTimeouts = TimeoutConfig{...}
|
|
||||||
// 实际代码中没人用 TimeoutConfig
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **过度注释**
|
|
||||||
```go
|
|
||||||
// ❌ 差:注释比代码长
|
|
||||||
// FormatBytes 格式化字节大小...
|
|
||||||
//
|
|
||||||
// 参数:
|
|
||||||
// bytes - 字节数...
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// 格式化后的字符串...
|
|
||||||
//
|
|
||||||
// 示例:
|
|
||||||
// fmt.Println(FormatBytes(1024))...
|
|
||||||
//
|
|
||||||
// 注意:
|
|
||||||
// - 使用1024进制...
|
|
||||||
// - 支持PB级别...
|
|
||||||
func FormatBytes(bytes uint64) string { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 封装决策清单
|
|
||||||
|
|
||||||
在创建新函数/常量前,先问自己:
|
|
||||||
|
|
||||||
### 1. 是否消除重复?
|
|
||||||
- [ ] 是否有2个以上使用点?
|
|
||||||
- [ ] 代码是否真的重复?
|
|
||||||
- **如果否** → 不要封装
|
|
||||||
|
|
||||||
### 2. 是否增加价值?
|
|
||||||
- [ ] 是否简化了调用?
|
|
||||||
- [ ] 是否提高了可读性?
|
|
||||||
- [ ] 是否便于维护?
|
|
||||||
- **如果否** → 不要封装
|
|
||||||
|
|
||||||
### 3. 是否过度抽象?
|
|
||||||
- [ ] 是否只是简单包装标准库?
|
|
||||||
- [ ] 是否可以被2-3行代码替代?
|
|
||||||
- **如果是** → 不要封装
|
|
||||||
|
|
||||||
### 4. 是否会被使用?
|
|
||||||
- [ ] 是否有明确的调用者?
|
|
||||||
- [ ] 是否解决了实际问题?
|
|
||||||
- **如果否** → 不要封装
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 验证状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ go build -v
|
|
||||||
go-desk/internal/common
|
|
||||||
go-desk/internal/system
|
|
||||||
go-desk/internal/dbclient
|
|
||||||
go-desk/internal/storage
|
|
||||||
go-desk/internal/service
|
|
||||||
go-desk/internal/api
|
|
||||||
go-desk
|
|
||||||
✅ 编译成功
|
|
||||||
```
|
|
||||||
|
|
||||||
- ✅ 删除未使用的封装
|
|
||||||
- ✅ 简化冗长的注释
|
|
||||||
- ✅ 保留有价值的抽象
|
|
||||||
- ✅ 代码更简洁
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 经验教训
|
|
||||||
|
|
||||||
### YAGNI 原则(You Aren't Gonna Need It)
|
|
||||||
|
|
||||||
> 不要为未来可能需要的功能编写代码。
|
|
||||||
> 只写当前确实需要的功能。
|
|
||||||
|
|
||||||
**应用**:
|
|
||||||
- ❌ 不要"以防万一"创建工具函数
|
|
||||||
- ✅ 等真正需要时再提取
|
|
||||||
- ✅ 重复出现3次以上再考虑封装
|
|
||||||
|
|
||||||
### KISS 原则(Keep It Simple, Stupid)
|
|
||||||
|
|
||||||
> 保持简单,愚蠢。
|
|
||||||
|
|
||||||
**应用**:
|
|
||||||
- ❌ 不要过度设计
|
|
||||||
- ❌ 不要为了"优雅"而封装
|
|
||||||
- ✅ 简单直接往往更好
|
|
||||||
|
|
||||||
### 注释原则
|
|
||||||
|
|
||||||
> 代码是最好的文档。注释说明"为什么",而不是"是什么"。
|
|
||||||
|
|
||||||
**应用**:
|
|
||||||
- ✅ 注释解释为什么这样做
|
|
||||||
- ❌ 不要注释显而易见的代码
|
|
||||||
- ❌ 不要写比代码还长的注释
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 最终状态
|
|
||||||
|
|
||||||
### internal/common 包(简化后)
|
|
||||||
|
|
||||||
```
|
|
||||||
internal/common/
|
|
||||||
├── utils.go # FormatBytes(合理封装,消除重复)
|
|
||||||
└── timeout.go # 超时常量(合理封装,统一配置)
|
|
||||||
```
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
- ✅ 每个函数/常量都有实际使用
|
|
||||||
- ✅ 代码简洁,注释适度
|
|
||||||
- ✅ 避免了过度封装
|
|
||||||
- ✅ 符合 YAGNI 和 KISS 原则
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 参考资源
|
|
||||||
|
|
||||||
### 软件工程原则
|
|
||||||
1. **YAGNI** - You Aren't Gonna Need It
|
|
||||||
2. **KISS** - Keep It Simple, Stupid
|
|
||||||
3. **DRY** - Don't Repeat Yourself(但不要过度)
|
|
||||||
|
|
||||||
### Go 语言哲学
|
|
||||||
- "Clear is better than clever"
|
|
||||||
- "Avoid over-engineering"
|
|
||||||
- "Readability counts"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**报告生成时间**:2026-01-27
|
|
||||||
**清理阶段**:避免过度封装
|
|
||||||
**状态**:✅ 已完成
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# 代码质量和安全检查报告
|
|
||||||
|
|
||||||
## 执行日期
|
|
||||||
2026-01-27
|
|
||||||
|
|
||||||
## 检查范围
|
|
||||||
- Go 代码质量问题
|
|
||||||
- 前端代码质量
|
|
||||||
- 安全隐患
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 发现的问题
|
|
||||||
|
|
||||||
### ⚠️ 安全问题(高优先级)
|
|
||||||
|
|
||||||
#### 1. 硬编码的数据库凭证 🔴
|
|
||||||
|
|
||||||
**位置**:`internal/database/db.go:36-37`
|
|
||||||
|
|
||||||
**问题代码**:
|
|
||||||
```go
|
|
||||||
config := mysqldriver.Config{
|
|
||||||
User: "root",
|
|
||||||
Passwd: "123456", // ❌ 硬编码密码
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**风险等级**:🔴 高危
|
|
||||||
|
|
||||||
**问题描述**:
|
|
||||||
- ❌ 数据库密码硬编码在源代码中
|
|
||||||
- ❌ 密码过于简单(123456)
|
|
||||||
- ❌ 代码泄露会导致数据库被攻击
|
|
||||||
- ❌ 无法为不同环境配置不同凭证
|
|
||||||
|
|
||||||
**建议修复**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 方案1: 使用环境变量
|
|
||||||
config := mysqldriver.Config{
|
|
||||||
User: getEnv("DB_USER", "root"),
|
|
||||||
Passwd: getEnv("DB_PASSWORD", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方案2: 使用配置文件
|
|
||||||
// 从 config.json 或 .env 文件读取
|
|
||||||
|
|
||||||
// 方案3: 使用系统密钥环
|
|
||||||
// Windows: Credential Manager
|
|
||||||
// macOS: Keychain
|
|
||||||
// Linux: libsecret
|
|
||||||
```
|
|
||||||
|
|
||||||
**优先级**:🔴 **紧急修复**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 2. ZIP 文件路径遍历保护 ✅
|
|
||||||
|
|
||||||
**位置**:`internal/filesystem/fs.go`
|
|
||||||
|
|
||||||
**检查结果**:✅ 已有保护
|
|
||||||
```go
|
|
||||||
func isSafePath(path string) bool {
|
|
||||||
cleanPath := filepath.Clean(path)
|
|
||||||
if strings.Contains(cleanPath, "..") {
|
|
||||||
return false // ✅ 防止路径遍历
|
|
||||||
}
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**状态**:✅ 安全
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ⚠️ 代码质量问题
|
|
||||||
|
|
||||||
#### 1. 过多的 console.log
|
|
||||||
|
|
||||||
**位置**:`web/src/components/FileSystem.vue`
|
|
||||||
|
|
||||||
**统计**:
|
|
||||||
- console.log: 40个
|
|
||||||
- console.warn: 若干个
|
|
||||||
- console.error: 3个(已保留,用于错误)
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- 生产环境会暴露调试信息
|
|
||||||
- 影响性能
|
|
||||||
- 可能泄露敏感信息
|
|
||||||
|
|
||||||
**建议**:
|
|
||||||
```javascript
|
|
||||||
// 创建条件日志工具
|
|
||||||
const debugMode = import.meta.env.DEV
|
|
||||||
|
|
||||||
const debugLog = (...args) => {
|
|
||||||
if (debugMode) {
|
|
||||||
console.log('[FileSystem]', ...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用
|
|
||||||
debugLog('操作成功:', data) // 仅开发环境输出
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 2. 前端 Promise 链式调用
|
|
||||||
|
|
||||||
**位置**:`web/src/views/db-cli/components/ConnectionTree.vue`
|
|
||||||
|
|
||||||
**问题代码**:
|
|
||||||
```javascript
|
|
||||||
someMethod().then(result => {
|
|
||||||
...
|
|
||||||
}).catch(error => {
|
|
||||||
...
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议**:使用 async/await
|
|
||||||
```javascript
|
|
||||||
try {
|
|
||||||
const result = await someMethod()
|
|
||||||
...
|
|
||||||
} catch (error) {
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 3. TODO 标记未处理
|
|
||||||
|
|
||||||
**位置**:`internal/database/db.go:100`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// TODO: 关联 sys_member_role 表查询
|
|
||||||
if role > 0 {
|
|
||||||
// 暂时简化
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议**:
|
|
||||||
- 转为 GitHub Issue 跟踪
|
|
||||||
- 或删除已过时的 TODO
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ 代码质量良好的方面
|
|
||||||
|
|
||||||
#### 1. Go 代码编译无警告 ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ go vet ./...
|
|
||||||
✅ 无输出,无问题
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. SQL 参数化查询 ✅
|
|
||||||
|
|
||||||
**位置**:`internal/database/db.go:86-87`
|
|
||||||
|
|
||||||
```go
|
|
||||||
query = query.Where("membername LIKE ? OR account LIKE ?",
|
|
||||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
|
||||||
```
|
|
||||||
|
|
||||||
**评价**:✅ 使用参数化查询,防止 SQL 注入
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 优先修复建议
|
|
||||||
|
|
||||||
### 🔴 紧急(本周)
|
|
||||||
|
|
||||||
1. **修复硬编码密码**
|
|
||||||
- 移除 db.go 中的硬编码凭证
|
|
||||||
- 使用环境变量或配置文件
|
|
||||||
|
|
||||||
### 🟠 重要(本月)
|
|
||||||
|
|
||||||
2. **清理 console.log**
|
|
||||||
- 创建条件日志工具
|
|
||||||
- 仅开发环境输出调试信息
|
|
||||||
|
|
||||||
3. **处理 TODO 标记**
|
|
||||||
- 转为 Issue 或删除
|
|
||||||
|
|
||||||
### 🟢 优化(下个迭代)
|
|
||||||
|
|
||||||
4. **Promise → async/await**
|
|
||||||
- 重构链式调用为 async/await
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 代码质量评分
|
|
||||||
|
|
||||||
| 维度 | 评分 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| **编译检查** | ⭐⭐⭐⭐⭐ | go vet 无问题 |
|
|
||||||
| **SQL 安全** | ⭐⭐⭐⭐⭐ | 参数化查询 |
|
|
||||||
| **路径安全** | ⭐⭐⭐⭐⭐ | 有遍历保护 |
|
|
||||||
| **凭证管理** | ⭐☆☆☆☆ | 硬编码密码 🔴 |
|
|
||||||
| **日志管理** | ⭐⭐⭐☆☆ | 过多调试日志 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ 安全检查清单
|
|
||||||
|
|
||||||
### 数据库安全
|
|
||||||
- [ ] 移除硬编码凭证 🔴
|
|
||||||
- [ ] 使用环境变量
|
|
||||||
- [ ] 密码复杂度要求
|
|
||||||
- [ ] 连接加密
|
|
||||||
|
|
||||||
### 文件系统安全
|
|
||||||
- [x] 路径遍历保护 ✅
|
|
||||||
- [x] 路径安全检查 ✅
|
|
||||||
- [ ] 文件权限验证
|
|
||||||
|
|
||||||
### 前端安全
|
|
||||||
- [ ] 清理调试日志
|
|
||||||
- [ ] 敏感信息过滤
|
|
||||||
- [ ] XSS 防护
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 建议行动
|
|
||||||
|
|
||||||
### 立即执行
|
|
||||||
1. 修复 db.go 硬编码密码(安全隐患)
|
|
||||||
2. 配置 .gitignore 忽略敏感文件
|
|
||||||
|
|
||||||
### 本周完成
|
|
||||||
3. 清理 FileSystem.vue 中的 console.log
|
|
||||||
4. 创建前端日志管理工具
|
|
||||||
|
|
||||||
### 本月完成
|
|
||||||
5. 处理或关闭 TODO 标记
|
|
||||||
6. 重构 Promise 为 async/await
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**报告生成时间**:2026-01-27
|
|
||||||
**检查类型**:代码质量 + 安全检查
|
|
||||||
**状态**:✅ 已完成
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
# 代码审查报告
|
|
||||||
**日期**: 2025-01-30
|
|
||||||
**审查范围**: 前端 Vue 组件、后端 Go 代码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、关键问题总结
|
|
||||||
|
|
||||||
### 🔴 严重问题(必须修复)
|
|
||||||
|
|
||||||
#### 1. **FileSystem.vue 文件过大 - 4266 行**
|
|
||||||
- **问题**: 单文件组件过大,违反单一职责原则
|
|
||||||
- **影响**: 难以维护、测试困难、代码复用性差
|
|
||||||
- **建议**: 拆分为多个小组件和 composables
|
|
||||||
|
|
||||||
#### 2. **重复的扩展名获取逻辑**
|
|
||||||
- **位置**: `FileSystem.vue:3129-3171` vs `fileHelpers.js:8-14`
|
|
||||||
- **问题**: `currentFileExtension` 重复实现了 `getExt` 的功能
|
|
||||||
- **建议**: 统一使用 `getExt` 函数
|
|
||||||
|
|
||||||
#### 3. **调试日志过多 - 58 个**
|
|
||||||
- **位置**: `FileSystem.vue`
|
|
||||||
- **问题**: 过度防御性编程,大量 `debugLog` 和 `console.log`
|
|
||||||
- **影响**: 性能影响、代码可读性差
|
|
||||||
- **建议**: 移除或使用环境变量控制
|
|
||||||
|
|
||||||
### 🟡 中等问题(建议优化)
|
|
||||||
|
|
||||||
#### 4. **重复计算属性**
|
|
||||||
```javascript
|
|
||||||
// FileSystem.vue:3202 - 完全重复
|
|
||||||
const isEditableFile = computed(() => isEditableView.value)
|
|
||||||
```
|
|
||||||
**建议**: 删除,直接使用 `isEditableView`
|
|
||||||
|
|
||||||
#### 5. **相似计算属性可合并**
|
|
||||||
```javascript
|
|
||||||
// FileSystem.vue:3205-3217
|
|
||||||
const canSaveFile = computed(() => {
|
|
||||||
return isEditableView.value &&
|
|
||||||
fileContent.value !== '' &&
|
|
||||||
originalContent.value !== fileContent.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const canResetContent = computed(() => {
|
|
||||||
return isEditableView.value &&
|
|
||||||
fileContent.value !== '' &&
|
|
||||||
originalContent.value !== undefined &&
|
|
||||||
originalContent.value !== fileContent.value
|
|
||||||
})
|
|
||||||
```
|
|
||||||
**建议**: 提取共享逻辑
|
|
||||||
```javascript
|
|
||||||
const contentChanged = computed(() =>
|
|
||||||
fileContent.value !== '' &&
|
|
||||||
originalContent.value !== fileContent.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
|
||||||
const canResetContent = computed(() =>
|
|
||||||
isEditableView.value && contentChanged.value && originalContent.value !== undefined
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6. **currentFileExtension 逻辑嵌套**
|
|
||||||
```javascript
|
|
||||||
// FileSystem.vue:3129-3171
|
|
||||||
const currentFileExtension = computed(() => {
|
|
||||||
let path = ''
|
|
||||||
if (selectedFilePath.value) {
|
|
||||||
path = selectedFilePath.value
|
|
||||||
} else if (filePath.value) {
|
|
||||||
path = filePath.value
|
|
||||||
}
|
|
||||||
// ... 更多嵌套逻辑
|
|
||||||
})
|
|
||||||
```
|
|
||||||
**建议**: 简化为线性流程
|
|
||||||
```javascript
|
|
||||||
const currentFileExtension = computed(() => {
|
|
||||||
const path = selectedFilePath.value || filePath.value
|
|
||||||
if (!path) return ''
|
|
||||||
|
|
||||||
// 特殊文件名映射
|
|
||||||
const fileName = path.split(/[/\\]/).pop()?.toLowerCase() || ''
|
|
||||||
const specialMapping = {/* ... */}
|
|
||||||
if (specialMapping[fileName]) return specialMapping[fileName]
|
|
||||||
|
|
||||||
// 普通扩展名
|
|
||||||
return getExt(path)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7. **CodeEditor.vue 语言包导入冗余**
|
|
||||||
```javascript
|
|
||||||
// CodeEditor.vue:43-88 - 46 行的语言映射
|
|
||||||
const LANGUAGE_MAP = {
|
|
||||||
javascript: ['js', 'jsx', 'mjs', 'cjs'],
|
|
||||||
typescript: ['ts', 'tsx'],
|
|
||||||
// ... 30+ 个映射
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**问题**: 与 `constants.js` 中的 `FILE_EXTENSIONS` 重复
|
|
||||||
**建议**: 复用 `constants.js` 的定义
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、前端代码质量分析
|
|
||||||
|
|
||||||
### 文件大小统计
|
|
||||||
| 文件 | 行数 | 评级 |
|
|
||||||
|------|------|------|
|
|
||||||
| FileSystem.vue | 4266 | 🔴 过大 |
|
|
||||||
| CodeEditor.vue | 334 | 🟢 合理 |
|
|
||||||
| constants.js | 318 | 🟢 合理 |
|
|
||||||
| fileHelpers.js | 41 | 🟢 合理 |
|
|
||||||
|
|
||||||
### 代码规范问题
|
|
||||||
|
|
||||||
#### 命名规范
|
|
||||||
✅ **好的例子**:
|
|
||||||
- `getExt()` - 清晰简洁
|
|
||||||
- `currentFileExtension` - 语义明确
|
|
||||||
|
|
||||||
⚠️ **需改进**:
|
|
||||||
- `imageWidth`/`imageHeight` vs `imageSize` (已删除) - 命名不一致
|
|
||||||
|
|
||||||
#### 函数复杂度
|
|
||||||
🔴 **高复杂度函数**:
|
|
||||||
1. `readFile()` - 200+ 行,嵌套深度 5+
|
|
||||||
2. `previewHtml()` - 150+ 行
|
|
||||||
3. `extractHtmlStyles()` - 100+ 行
|
|
||||||
|
|
||||||
#### DRY 原则违反
|
|
||||||
1. **扩展名获取**: `currentFileExtension` vs `getExt()`
|
|
||||||
2. **路径分隔符处理**: 多处重复 `/[/\\]/` 正则
|
|
||||||
3. **文件类型检查**: `isHtmlFile` vs `isHtml()` 函数重复
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、后端代码质量分析
|
|
||||||
|
|
||||||
### Go 代码检查
|
|
||||||
|
|
||||||
#### config.go
|
|
||||||
✅ **好的方面**:
|
|
||||||
- 清晰的配置结构
|
|
||||||
- 良好的默认值处理
|
|
||||||
- 安全的路径验证
|
|
||||||
|
|
||||||
⚠️ **需改进**:
|
|
||||||
```go
|
|
||||||
// config.go:256-289 - getAllowedExtensions
|
|
||||||
func getAllowedExtensions() map[string]bool {
|
|
||||||
return map[string]bool{
|
|
||||||
".jpg": true,
|
|
||||||
// 30+ 个硬编码扩展名
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**建议**: 考虑从配置文件加载,或使用更紧凑的表示方式
|
|
||||||
|
|
||||||
#### asset_handler.go
|
|
||||||
✅ **好的方面**:
|
|
||||||
- 良好的安全检查(路径遍历防护)
|
|
||||||
- 清晰的错误处理
|
|
||||||
|
|
||||||
⚠️ **需改进**:
|
|
||||||
```go
|
|
||||||
// asset_handler.go:66-165 - handleLocalFileRequest 函数过长
|
|
||||||
// 建议拆分为多个小函数
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、具体优化建议
|
|
||||||
|
|
||||||
### 优先级 1: 立即修复
|
|
||||||
|
|
||||||
#### 1. 移除 FileSystem.vue 中的调试代码
|
|
||||||
```javascript
|
|
||||||
// 删除所有 debugLog 调用(58 个)
|
|
||||||
// 或使用环境变量控制
|
|
||||||
const DEBUG = import.meta.env.DEV
|
|
||||||
const debugLog = DEBUG ? console.log : () => {}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 删除重复计算属性
|
|
||||||
```javascript
|
|
||||||
// 删除 FileSystem.vue:3202
|
|
||||||
- const isEditableFile = computed(() => isEditableView.value)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 统一使用 getExt
|
|
||||||
```javascript
|
|
||||||
// FileSystem.vue:3129-3171
|
|
||||||
// 简化 currentFileExtension,复用 getExt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 优先级 2: 短期优化
|
|
||||||
|
|
||||||
#### 4. 提取 Composables
|
|
||||||
```javascript
|
|
||||||
// 创建 src/composables/useFileExtension.js
|
|
||||||
export function useFileExtension() {
|
|
||||||
const getExtension = (path) => {
|
|
||||||
// 统一的扩展名获取逻辑
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSpecialFile = (fileName) => {
|
|
||||||
// 特殊文件名判断
|
|
||||||
}
|
|
||||||
|
|
||||||
return { getExtension, isSpecialFile }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. 拆分 FileSystem.vue
|
|
||||||
```
|
|
||||||
components/FileSystem/
|
|
||||||
├── index.vue (主组件,< 500 行)
|
|
||||||
├── useFileOperations.js (文件操作)
|
|
||||||
├── useFilePreview.js (预览逻辑)
|
|
||||||
├── useFileEdit.js (编辑逻辑)
|
|
||||||
└── usePathNavigation.js (路径导航)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6. 合并相似计算属性
|
|
||||||
```javascript
|
|
||||||
// 提取共享逻辑
|
|
||||||
const contentChanged = computed(() =>
|
|
||||||
fileContent.value !== '' &&
|
|
||||||
originalContent.value !== fileContent.value
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 优先级 3: 长期重构
|
|
||||||
|
|
||||||
#### 7. 统一文件类型定义
|
|
||||||
```javascript
|
|
||||||
// 将 LANGUAGE_MAP 迁移到 constants.js
|
|
||||||
// 与 FILE_EXTENSIONS 合并
|
|
||||||
export const FILE_CATEGORIES = {
|
|
||||||
CODE: { extensions: ['js', 'ts', /* ... */ }, syntaxHighlight: javascript },
|
|
||||||
MARKUP: { extensions: ['html', 'css', /* ... */ ], syntaxHighlight: html },
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 8. 类型安全
|
|
||||||
```typescript
|
|
||||||
// 添加 TypeScript 类型定义
|
|
||||||
interface FileExtension {
|
|
||||||
name: string
|
|
||||||
category: FileCategory
|
|
||||||
syntaxHighlight?: Language
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、代码质量指标
|
|
||||||
|
|
||||||
### 当前状态
|
|
||||||
| 指标 | 当前值 | 目标值 | 评级 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 单文件最大行数 | 4266 | < 500 | 🔴 |
|
|
||||||
| 函数平均行数 | ~50 | < 30 | 🟡 |
|
|
||||||
| 代码重复率 | ~5% | < 3% | 🟡 |
|
|
||||||
| 调试语句数量 | 58 | 0 (生产) | 🔴 |
|
|
||||||
| 圈复杂度 | 15+ | < 10 | 🟡 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、检查清单
|
|
||||||
|
|
||||||
### 前端代码
|
|
||||||
- [ ] 移除所有调试日志
|
|
||||||
- [ ] 删除重复计算属性
|
|
||||||
- [ ] 简化 currentFileExtension
|
|
||||||
- [ ] 提取 composables
|
|
||||||
- [ ] 拆分 FileSystem.vue
|
|
||||||
- [ ] 统一扩展名获取逻辑
|
|
||||||
- [ ] 复用 constants.js
|
|
||||||
|
|
||||||
### 后端代码
|
|
||||||
- [ ] 简化 handleLocalFileRequest
|
|
||||||
- [ ] 提取配置到独立文件
|
|
||||||
- [ ] 添加单元测试
|
|
||||||
- [ ] 统一错误处理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、后续行动
|
|
||||||
|
|
||||||
1. **立即执行** (1-2 天)
|
|
||||||
- 移除调试代码
|
|
||||||
- 删除重复代码
|
|
||||||
- 简化函数逻辑
|
|
||||||
|
|
||||||
2. **短期计划** (1 周)
|
|
||||||
- 拆分 FileSystem.vue
|
|
||||||
- 提取 composables
|
|
||||||
- 统一工具函数
|
|
||||||
|
|
||||||
3. **长期优化** (2-4 周)
|
|
||||||
- TypeScript 迁移
|
|
||||||
- 添加单元测试
|
|
||||||
- 性能优化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、参考资源
|
|
||||||
|
|
||||||
- [Vue 3 风格指南](https://vuejs.org/style-guide/)
|
|
||||||
- [代码整洁之道](https://www.oreilly.com/library/view/clean-code-a/9780136083238/)
|
|
||||||
- [重构:改善既有代码的设计](https://www.refactoring.com/)
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
# 深度代码优化完成报告
|
|
||||||
|
|
||||||
## 执行日期
|
|
||||||
2026-01-27
|
|
||||||
|
|
||||||
## 任务概述
|
|
||||||
在 P1-P3 级别优化完成后,继续进行深度优化,进一步提升代码质量和可维护性。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 新增完成的优化
|
|
||||||
|
|
||||||
### 1. 统一超时配置管理 ✅
|
|
||||||
**新增文件**:`internal/common/timeout.go`
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- 14处硬编码的超时时间散布在多个文件中
|
|
||||||
- 修改超时需要改动多处代码
|
|
||||||
- 不同操作的超时策略不清晰
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
创建统一的超时常量配置,提供分级超时策略:
|
|
||||||
|
|
||||||
```go
|
|
||||||
const (
|
|
||||||
TimeoutPing = 2 * time.Second // 连接测试
|
|
||||||
TimeoutConnect = 5 * time.Second // 初始连接
|
|
||||||
TimeoutFastQuery = 10 * time.Second // 元数据查询
|
|
||||||
TimeoutQuery = 30 * time.Second // 普通查询
|
|
||||||
TimeoutLongOp = 60 * time.Second // 长时间操作
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**修改文件**:
|
|
||||||
1. `internal/service/sql_exec_service.go` - 5处超时
|
|
||||||
2. `internal/dbclient/pool.go` - 2处超时
|
|
||||||
3. `internal/dbclient/redis.go` - 2处超时
|
|
||||||
4. `internal/dbclient/mongo.go` - 3处超时
|
|
||||||
|
|
||||||
**效果**:
|
|
||||||
- ✅ 消除14处硬编码超时
|
|
||||||
- ✅ 统一超时配置管理
|
|
||||||
- ✅ 支持环境差异化配置
|
|
||||||
- ✅ 提升代码可维护性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 完善文档注释 ✅
|
|
||||||
**修改文件**:
|
|
||||||
- `internal/common/utils.go`
|
|
||||||
- `internal/common/errors.go`
|
|
||||||
- `internal/common/timeout.go`
|
|
||||||
|
|
||||||
**改进内容**:
|
|
||||||
|
|
||||||
#### FormatBytes 函数
|
|
||||||
```go
|
|
||||||
// FormatBytes 格式化字节大小为人类可读格式
|
|
||||||
//
|
|
||||||
// 该函数将字节数转换为最合适的二进制单位(KiB, MiB, GiB 等),
|
|
||||||
// 并保留两位小数。使用 1024 进制(IEC 80000-13 标准)。
|
|
||||||
//
|
|
||||||
// 参数:
|
|
||||||
// bytes - 要格式化的字节数
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// 格式化后的字符串,例如:
|
|
||||||
// - 0 → "0 B"
|
|
||||||
// - 1024 → "1.00 KB"
|
|
||||||
// - 1048576 → "1.00 MB"
|
|
||||||
//
|
|
||||||
// 示例:
|
|
||||||
// fmt.Println(FormatBytes(1536)) // "1.50 KB"
|
|
||||||
//
|
|
||||||
// 注意:
|
|
||||||
// - 使用 1024 进制而非 1000 进制
|
|
||||||
// - 最大支持到 PB(Petabyte)级别
|
|
||||||
```
|
|
||||||
|
|
||||||
#### WrapError 函数
|
|
||||||
```go
|
|
||||||
// WrapError 统一的错误包装函数
|
|
||||||
//
|
|
||||||
// 将底层错误包装为带操作描述的错误信息,提供统一的错误消息格式。
|
|
||||||
//
|
|
||||||
// 参数:
|
|
||||||
// operation - 失败的操作名称,例如 "连接数据库"、"读取文件"
|
|
||||||
// err - 底层错误对象
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// 包装后的错误,格式为 "{operation}失败: {err.Error()}"
|
|
||||||
//
|
|
||||||
// 示例:
|
|
||||||
// if err := db.Connect(); err != nil {
|
|
||||||
// return nil, WrapError("连接数据库", err)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// 最佳实践:
|
|
||||||
// - 操作名称应简洁明了,使用动词开头
|
|
||||||
// - 避免在 operation 中重复"失败"、"错误"等词
|
|
||||||
```
|
|
||||||
|
|
||||||
**效果**:
|
|
||||||
- ✅ 所有公共函数都有详细注释
|
|
||||||
- ✅ 符合 Go Doc 标准格式
|
|
||||||
- ✅ 包含参数说明、返回值、示例、注意事项
|
|
||||||
- ✅ 便于 IDE 提示和文档生成
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 深度优化统计
|
|
||||||
|
|
||||||
| 优化项 | 修改前 | 修改后 | 提升 |
|
|
||||||
|--------|--------|--------|------|
|
|
||||||
| 硬编码超时 | 14处 | 0处 | ✅ 100% |
|
|
||||||
| 超时配置 | 分散 | 集中 | ✅ 统一管理 |
|
|
||||||
| 函数文档 | 简单 | 详细 | ✅ 完整规范 |
|
|
||||||
| 代码可维护性 | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 超时分级策略
|
|
||||||
|
|
||||||
### 设计理念
|
|
||||||
根据操作类型设置不同的超时时间,平衡用户体验和系统资源:
|
|
||||||
|
|
||||||
| 级别 | 超时时间 | 用途 | 示例 |
|
|
||||||
|------|---------|------|------|
|
|
||||||
| **快速** | 2秒 | Ping测试 | 检查连接是否有效 |
|
|
||||||
| **中等** | 5秒 | 建立连接 | 数据库握手 |
|
|
||||||
| **正常** | 10秒 | 元数据查询 | 获取数据库列表 |
|
|
||||||
| **标准** | 30秒 | 普通查询 | SELECT、表结构 |
|
|
||||||
| **长时** | 60秒 | 复杂操作 | 表结构变更、预览 |
|
|
||||||
|
|
||||||
### 使用场景
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 场景1: 连接测试 - 快速失败
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// 场景2: 元数据查询 - 快速响应
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// 场景3: 普通查询 - 平衡超时
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// 场景4: 复杂操作 - 充足时间
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
|
||||||
defer cancel()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自定义配置
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 生产环境:使用较长超时
|
|
||||||
prodTimeouts := common.TimeoutConfig{
|
|
||||||
Query: 60 * time.Second,
|
|
||||||
LongOp: 120 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开发环境:快速发现问题
|
|
||||||
devTimeouts := common.TimeoutConfig{
|
|
||||||
Query: 10 * time.Second,
|
|
||||||
LongOp: 30 * time.Second,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 使用指南
|
|
||||||
|
|
||||||
### 1. 使用统一超时常量
|
|
||||||
|
|
||||||
```go
|
|
||||||
import "go-desk/internal/common"
|
|
||||||
|
|
||||||
// ✅ 推荐:使用常量
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// ❌ 避免:硬编码
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 选择合适的超时级别
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 快速操作(连接测试)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutPing)
|
|
||||||
|
|
||||||
// 元数据查询(获取列表)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutFastQuery)
|
|
||||||
|
|
||||||
// 普通查询
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutQuery)
|
|
||||||
|
|
||||||
// 复杂操作
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), common.TimeoutLongOp)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 查看函数文档
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 生成文档
|
|
||||||
go doc go-desk/internal/common.FormatBytes
|
|
||||||
|
|
||||||
# 在浏览器中查看
|
|
||||||
godoc -http=:6060
|
|
||||||
# 访问 http://localhost:6060/pkg/go-desk/internal/common/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 文件清单
|
|
||||||
|
|
||||||
### 新增文件(3个)
|
|
||||||
1. ✅ `internal/common/timeout.go` - 超时配置常量
|
|
||||||
2. ✅ `internal/common/utils.go` - 格式化工具(已有,增强文档)
|
|
||||||
3. ✅ `internal/common/errors.go` - 错误处理(已有,增强文档)
|
|
||||||
|
|
||||||
### 修改文件(4个)
|
|
||||||
1. ✅ `internal/service/sql_exec_service.go` - 使用统一超时 + 导入 common
|
|
||||||
2. ✅ `internal/dbclient/pool.go` - 使用统一超时 + 移除未使用导入
|
|
||||||
3. ✅ `internal/dbclient/redis.go` - 使用统一超时 + 移除未使用导入
|
|
||||||
4. ✅ `internal/dbclient/mongo.go` - 使用统一超时 + 移除未使用导入
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 代码质量对比
|
|
||||||
|
|
||||||
| 维度 | 优化前 | 优化后 | 提升 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| **配置管理** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐⭐ | +3星 |
|
|
||||||
| **文档完整性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
|
||||||
| **代码一致性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
|
||||||
| **可维护性** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +1星 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 验证状态
|
|
||||||
|
|
||||||
- ✅ Go 代码编译通过
|
|
||||||
- ✅ 无语法错误
|
|
||||||
- ✅ 无未使用导入
|
|
||||||
- ✅ 无破坏性修改
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 后续建议
|
|
||||||
|
|
||||||
### 短期(可选)
|
|
||||||
1. 为其他包的公共函数添加详细文档
|
|
||||||
2. 考虑添加超时监控和告警
|
|
||||||
3. 建立超时配置的性能基准测试
|
|
||||||
|
|
||||||
### 中期(可选)
|
|
||||||
1. 支持从配置文件读取超时设置
|
|
||||||
2. 添加超时动态调整机制
|
|
||||||
3. 记录超时发生的频率和原因
|
|
||||||
|
|
||||||
### 长期(可选)
|
|
||||||
1. 实现自适应超时算法
|
|
||||||
2. 建立超时最佳实践文档
|
|
||||||
3. 考虑超时熔断机制
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 整体进度总结
|
|
||||||
|
|
||||||
### 已完成的所有优化
|
|
||||||
|
|
||||||
#### P0 级别
|
|
||||||
- ✅ 无严重问题
|
|
||||||
|
|
||||||
#### P1 级别
|
|
||||||
1. ✅ 重复的 formatBytes 函数
|
|
||||||
2. ✅ 前端文件类型判断硬编码
|
|
||||||
3. ✅ ZIP 路径验证重复
|
|
||||||
|
|
||||||
#### P2 级别
|
|
||||||
4. ✅ ZIP 文件过度日志
|
|
||||||
5. ✅ 重复的错误处理模式
|
|
||||||
6. ✅ ZIP 路径验证重复
|
|
||||||
|
|
||||||
#### P3 级别
|
|
||||||
7. ✅ 错误处理辅助函数
|
|
||||||
8. ✅ 超时配置统一管理 ⭐ 新增
|
|
||||||
9. ✅ 函数文档完善 ⭐ 新增
|
|
||||||
|
|
||||||
### 最终质量评分
|
|
||||||
|
|
||||||
| 评分维度 | 初始 | P1+P2 | P3 | 深度优化 | 总提升 |
|
|
||||||
|---------|------|------|-----|----------|--------|
|
|
||||||
| **整体质量** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
|
||||||
| **DRY 原则** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +2星 |
|
|
||||||
| **配置管理** | ⭐⭐☆☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +3星 |
|
|
||||||
| **文档规范** | ⭐⭐⭐☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
|
||||||
| **可维护性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐⭐ | +2星 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 总结
|
|
||||||
|
|
||||||
### 本次深度优化成果
|
|
||||||
|
|
||||||
1. **统一超时配置** ✅
|
|
||||||
- 消除14处硬编码
|
|
||||||
- 建立分级超时策略
|
|
||||||
- 支持环境差异化
|
|
||||||
|
|
||||||
2. **完善文档注释** ✅
|
|
||||||
- 所有公共函数都有详细文档
|
|
||||||
- 符合 Go Doc 标准
|
|
||||||
- 便于 IDE 提示和自动生成
|
|
||||||
|
|
||||||
3. **清理未使用导入** ✅
|
|
||||||
- 移除 mongo.go 中未使用的 time 导入
|
|
||||||
- 移除 pool.go 中未使用的 time 导入
|
|
||||||
|
|
||||||
### 总体改进统计
|
|
||||||
|
|
||||||
| 指标 | 累计改进 |
|
|
||||||
|------|---------|
|
|
||||||
| 消除重复代码 | ~100行 |
|
|
||||||
| 消除硬编码配置 | 20+处 |
|
|
||||||
| 新增辅助函数 | 5个 |
|
|
||||||
| 完善文档注释 | 3个文件 |
|
|
||||||
| 新增配置文件 | 1个 |
|
|
||||||
|
|
||||||
### 最终状态
|
|
||||||
|
|
||||||
✅ **代码质量:优秀(5星)**
|
|
||||||
✅ **符合 Go 最佳实践**
|
|
||||||
✅ **完整的文档和注释**
|
|
||||||
✅ **统一的配置管理**
|
|
||||||
✅ **易于维护和扩展**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**报告生成时间**:2026-01-27
|
|
||||||
**优化阶段**:深度优化
|
|
||||||
**状态**:✅ 全部完成
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# P3 级别代码优化完成报告
|
|
||||||
|
|
||||||
## 执行日期
|
|
||||||
2026-01-27
|
|
||||||
|
|
||||||
## 任务概述
|
|
||||||
处理代码审查中识别的 P3 级别(轻微)问题,进一步优化代码质量。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 已完成的改进
|
|
||||||
|
|
||||||
### 1. 创建错误处理辅助函数 ✅
|
|
||||||
**新增文件**:`internal/common/errors.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// WrapError 统一的错误包装函数
|
|
||||||
func WrapError(operation string, err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s失败: %v", operation, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrapErrorf 带格式化的错误包装函数
|
|
||||||
func WrapErrorf(operation string, format string, args ...interface{}) error {
|
|
||||||
return fmt.Errorf("%s失败: "+format, append([]interface{}{operation}, args...)...)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
- 统一错误消息格式
|
|
||||||
- 减少重复的错误处理代码
|
|
||||||
- 提升代码可读性和一致性
|
|
||||||
- 便于后续国际化或日志标准化
|
|
||||||
|
|
||||||
**使用示例**:
|
|
||||||
```go
|
|
||||||
// 修改前
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("获取连接配置失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改后(推荐)
|
|
||||||
if err != nil {
|
|
||||||
return nil, common.WrapError("获取连接配置", err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 P3 改进统计
|
|
||||||
|
|
||||||
| 改进项 | 状态 | 效果 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 错误处理辅助函数 | ✅ 完成 | 统一错误格式,减少重复 |
|
|
||||||
| 变量命名一致性 | ⏸️ 保留 | 已评估,影响 API 兼容性 |
|
|
||||||
| 函数拆分优化 | ⏸️ 保留 | 需要更大重构,建议单独规划 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 关于变量命名统一的说明
|
|
||||||
|
|
||||||
### 发现的不一致
|
|
||||||
- `ExecuteSQL` 使用 `sqlStr`
|
|
||||||
- `SaveResult` 使用 `sql`
|
|
||||||
|
|
||||||
### 保留原因
|
|
||||||
1. **API 兼容性**:这些是公共 API 方法,修改会破坏前端调用
|
|
||||||
2. **语义清晰度**:当前命名都能清晰表达意图
|
|
||||||
3. **影响范围**:改动需要同步修改前端代码
|
|
||||||
|
|
||||||
### 建议
|
|
||||||
如果需要统一,建议:
|
|
||||||
1. 在下一个大版本升级时统一
|
|
||||||
2. 使用 `sqlStr` 作为标准(更明确)
|
|
||||||
3. 提供渐进式迁移路径(保留旧方法别名)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 关于函数拆分的说明
|
|
||||||
|
|
||||||
### 识别的长函数
|
|
||||||
- `FileSystem.vue:extractHtmlStyles` - 150行
|
|
||||||
- `FileSystem.vue:listZipDirectory` - 70行
|
|
||||||
|
|
||||||
### 保留原因
|
|
||||||
1. **组件重构复杂性**:FileSystem.vue 本身已有 2365 行
|
|
||||||
2. **需要架构级重构**:拆分函数需要拆分组件
|
|
||||||
3. **风险收益比**:当前可读性尚可,重构成本高
|
|
||||||
|
|
||||||
### 建议
|
|
||||||
建议单独进行"FileSystem 组件拆分"项目:
|
|
||||||
1. 提取 ZIP 处理逻辑到独立 composable
|
|
||||||
2. 提取 HTML 预处理逻辑到独立工具函数
|
|
||||||
3. 考虑使用 Vue 3 的 `<script setup>` 优化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 修改文件清单
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
1. ✅ `internal/common/errors.go` - 错误处理辅助函数
|
|
||||||
|
|
||||||
### 未修改文件(保留现状)
|
|
||||||
- `app.go` - 变量命名(API 兼容性考虑)
|
|
||||||
- `internal/api/sql_api.go` - 变量命名(API 兼容性考虑)
|
|
||||||
- `web/src/components/FileSystem.vue` - 函数拆分(需单独重构)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 使用建议
|
|
||||||
|
|
||||||
### 应用新的错误处理函数
|
|
||||||
|
|
||||||
```go
|
|
||||||
import "go-desk/internal/common"
|
|
||||||
|
|
||||||
// 场景1: 简单错误包装
|
|
||||||
if err != nil {
|
|
||||||
return nil, common.WrapError("打开文件", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 场景2: 带额外信息的错误包装
|
|
||||||
if err != nil {
|
|
||||||
return nil, common.WrapErrorf("连接数据库", "连接ID %d 超时", connectionID)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 逐步迁移现有代码
|
|
||||||
|
|
||||||
可以选择性地在以下场景应用新函数:
|
|
||||||
1. 新增代码
|
|
||||||
2. 修改已有代码时顺便优化
|
|
||||||
3. 发现错误消息格式不一致时统一
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 代码质量对比
|
|
||||||
|
|
||||||
| 维度 | P1+P2 修复后 | P3 优化后 | 提升 |
|
|
||||||
|------|-------------|----------|------|
|
|
||||||
| DRY原则 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | - |
|
|
||||||
| 错误处理 | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⬆️ |
|
|
||||||
| 代码一致性 | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ | ⬆️ |
|
|
||||||
| 可维护性 | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 最终总结
|
|
||||||
|
|
||||||
### 本次审查完成的工作
|
|
||||||
|
|
||||||
#### P0 级别
|
|
||||||
- ✅ 无严重问题
|
|
||||||
|
|
||||||
#### P1 级别(已完成)
|
|
||||||
1. ✅ 重复的 `formatBytes` 函数 - 已提取到共享包
|
|
||||||
2. ✅ 前端文件类型判断 - 已使用常量配置
|
|
||||||
3. ✅ ZIP 路径验证重复 - 已提取辅助函数
|
|
||||||
|
|
||||||
#### P2 级别(已完成)
|
|
||||||
4. ✅ ZIP 文件过度日志 - 已改为条件日志
|
|
||||||
5. ✅ 重复的错误处理模式 - 已创建辅助函数
|
|
||||||
6. ✅ ZIP 路径验证重复 - 已统一验证逻辑
|
|
||||||
|
|
||||||
#### P3 级别(已完成)
|
|
||||||
7. ✅ 错误处理辅助函数 - 已创建并提供使用指南
|
|
||||||
- ⏸️ 变量命名统一 - 已评估,建议大版本升级时处理
|
|
||||||
- ⏸️ 函数拆分 - 已评估,建议单独重构项目
|
|
||||||
|
|
||||||
### 整体改进成果
|
|
||||||
|
|
||||||
| 指标 | 改进前 | 改进后 | 提升 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 重复代码行数 | ~90行 | ~10行 | ✅ 89% |
|
|
||||||
| 硬编码配置 | 5处 | 0处 | ✅ 100% |
|
|
||||||
| 重复验证逻辑 | 4处 | 1处 | ✅ 75% |
|
|
||||||
| 无条件日志 | 18个 | 0个 | ✅ 100% |
|
|
||||||
| 错误处理模式 | 分散 | 统一 | ✅ 有框架 |
|
|
||||||
|
|
||||||
### 代码质量评分
|
|
||||||
|
|
||||||
| 评分维度 | 初始评分 | 最终评分 |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| **整体质量** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
|
||||||
| **DRY 原则** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐⭐ |
|
|
||||||
| **代码简洁性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
|
||||||
| **可维护性** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
|
||||||
| **日志管理** | ⭐⭐☆☆☆ | ⭐⭐⭐⭐☆ |
|
|
||||||
| **错误处理** | ⭐⭐⭐☆☆ | ⭐⭐⭐⭐☆ |
|
|
||||||
| **代码规范** | ⭐⭐⭐⭐☆ | ⭐⭐⭐⭐☆ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 后续建议
|
|
||||||
|
|
||||||
### 短期(1-2周内)
|
|
||||||
1. 在新代码中应用 `common.WrapError` 函数
|
|
||||||
2. 逐步迁移现有错误处理代码
|
|
||||||
3. 添加单元测试覆盖关键函数
|
|
||||||
|
|
||||||
### 中期(1个月内)
|
|
||||||
1. 评估并规划 FileSystem.vue 组件拆分
|
|
||||||
2. 考虑统一变量命名(如需大版本升级)
|
|
||||||
3. 添加更多工具函数到 `internal/common`
|
|
||||||
|
|
||||||
### 长期(3个月内)
|
|
||||||
1. 添加集成测试
|
|
||||||
2. 建立代码审查检查清单
|
|
||||||
3. 考虑引入代码质量分析工具
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 验证状态
|
|
||||||
|
|
||||||
- ✅ Go 代码编译通过
|
|
||||||
- ✅ 无语法错误
|
|
||||||
- ✅ 无破坏性修改
|
|
||||||
- ✅ 保持 API 兼容性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**报告生成时间**:2026-01-27
|
|
||||||
**审查者**:Claude Code
|
|
||||||
**状态**:✅ 已完成
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
# Composable 集成失败根因分析报告
|
|
||||||
**日期**: 2025-01-30
|
|
||||||
**目标**: 分析为什么 useFileEdit 和 useFilePreview 无法集成到 FileSystem.vue
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 执行摘要
|
|
||||||
|
|
||||||
集成尝试失败的根本原因:**Composables 和本地实现在设计哲学、API 契约、功能范围上存在系统性差异**。
|
|
||||||
|
|
||||||
- ❌ **useFileEdit**: 不兼容(状态变量不匹配:`isEditMode` vs `isEditableView`)
|
|
||||||
- ❌ **useFilePreview**: 不兼容(URL 格式、路径处理、ZIP 模式支持差异)
|
|
||||||
- ✅ **useNavigation**: 兼容(已成功集成)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、useFileEdit.js vs FileSystem.vue
|
|
||||||
|
|
||||||
### 1.1 状态变量差异
|
|
||||||
|
|
||||||
| 功能点 | useFileEdit.js | FileSystem.vue | 兼容性 |
|
|
||||||
|--------|----------------|----------------|--------|
|
|
||||||
| **编辑模式开关** | `isEditMode` (简单 ref) | `isEditableView` (复杂 computed) | ❌ 不兼容 |
|
|
||||||
| **路径来源** | `filePath` (单一) | `selectedFilePath` \| `filePath` (双重) | ❌ 不兼容 |
|
|
||||||
| **文件修改检测** | 简单比较 | 复杂逻辑(含新建文件) | ❌ 不兼容 |
|
|
||||||
|
|
||||||
### 1.2 致命差异:`canSaveFile` 的条件
|
|
||||||
|
|
||||||
**useFileEdit.js:87-89**
|
|
||||||
```javascript
|
|
||||||
const canSaveFile = computed(() => {
|
|
||||||
return isEditMode.value && contentChanged.value
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**FileSystem.vue:2997**
|
|
||||||
```javascript
|
|
||||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- `isEditMode`: 简单的布尔值 ref,来自 localStorage
|
|
||||||
- `isEditableView`: 复杂的 computed,依赖预览状态
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// FileSystem.vue:2968-2974
|
|
||||||
const isEditableView = computed(() => {
|
|
||||||
return !isImageView.value &&
|
|
||||||
!isVideoView.value &&
|
|
||||||
!isAudioView.value &&
|
|
||||||
!isPdfFile.value &&
|
|
||||||
!isBinaryFile.value
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- 使用 `isEditMode` → 保存按钮可能在图片预览时也显示(错误)
|
|
||||||
- 使用 `isEditableView` → 保存按钮只在文本编辑时显示(正确)
|
|
||||||
|
|
||||||
### 1.3 致命差异:`isFileModified` 的逻辑
|
|
||||||
|
|
||||||
**useFileEdit.js:71-74**
|
|
||||||
```javascript
|
|
||||||
const isFileModified = computed(() => {
|
|
||||||
return originalContent.value !== undefined &&
|
|
||||||
originalContent.value !== fileContent.value
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**FileSystem.vue:2977-2988**
|
|
||||||
```javascript
|
|
||||||
const isFileModified = computed(() => {
|
|
||||||
const hasContent = fileContent.value !== '' && fileContent.value.trim() !== ''
|
|
||||||
const hasModified = selectedFilePath.value && fileContent.value !== originalContent.value
|
|
||||||
const isNewFile = !selectedFilePath.value && hasContent // ← 新建文件检测
|
|
||||||
return isEditableView.value && (hasModified || isNewFile)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**缺失功能**:
|
|
||||||
- Composable 版本**不支持新建文件场景**
|
|
||||||
- FileSystem.vue 版本可以检测到"未选择文件路径但有内容"的新建文件状态
|
|
||||||
|
|
||||||
### 1.4 依赖图对比
|
|
||||||
|
|
||||||
**useFileEdit 依赖树**:
|
|
||||||
```
|
|
||||||
canSaveFile
|
|
||||||
├─ isEditMode (ref)
|
|
||||||
└─ contentChanged
|
|
||||||
├─ fileContent
|
|
||||||
└─ originalContent
|
|
||||||
```
|
|
||||||
|
|
||||||
**FileSystem.vue 依赖树**:
|
|
||||||
```
|
|
||||||
canSaveFile
|
|
||||||
├─ isEditableView (computed)
|
|
||||||
│ ├─ isImageView
|
|
||||||
│ ├─ isVideoView
|
|
||||||
│ ├─ isAudioView
|
|
||||||
│ ├─ isPdfFile
|
|
||||||
│ └─ isBinaryFile
|
|
||||||
└─ contentChanged
|
|
||||||
├─ fileContent
|
|
||||||
└─ originalContent
|
|
||||||
```
|
|
||||||
|
|
||||||
**结论**: FileSystem.vue 的依赖更复杂,Composable 过于简化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、useFilePreview.js vs FileSystem.vue
|
|
||||||
|
|
||||||
### 2.1 URL 构建差异(致命)
|
|
||||||
|
|
||||||
**useFilePreview.js:163**
|
|
||||||
```javascript
|
|
||||||
const encodedPath = encodeURIComponent(pathToPreview)
|
|
||||||
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
|
|
||||||
```
|
|
||||||
|
|
||||||
**FileSystem.vue:1503**
|
|
||||||
```javascript
|
|
||||||
previewUrl.value = `${fileServerURL.value}/localfs/${normalizeFilePath(pathToPreview, true)}`
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- Composable: `/file?path=xxx` (查询参数格式)
|
|
||||||
- FileSystem.vue: `/localfs/xxx` (路径格式,需要规范化)
|
|
||||||
|
|
||||||
**不兼容原因**:
|
|
||||||
- 后端可能只支持其中一种格式
|
|
||||||
- `normalizeFilePath()` 可能有特殊处理(如 Windows 路径转换)
|
|
||||||
|
|
||||||
### 2.2 路径参数优先级差异
|
|
||||||
|
|
||||||
**useFilePreview.js:148**
|
|
||||||
```javascript
|
|
||||||
const previewImage = async (targetPath) => {
|
|
||||||
const pathToPreview = targetPath || filePath.value // 只用 filePath
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**FileSystem.vue:1487**
|
|
||||||
```javascript
|
|
||||||
const previewImageLocal = async (targetPath) => {
|
|
||||||
const pathToPreview = targetPath || selectedFilePath.value || filePath.value // 三级优先级
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**三级优先级**:
|
|
||||||
1. `targetPath` (显式传入)
|
|
||||||
2. `selectedFilePath` (当前选中的文件)
|
|
||||||
3. `filePath` (当前目录)
|
|
||||||
|
|
||||||
**影响**:
|
|
||||||
- Composable 在"选中文件但未传参"时会失败
|
|
||||||
- FileSystem.vue 可以自动回退到 `selectedFilePath`
|
|
||||||
|
|
||||||
### 2.3 computed 属性功能差异
|
|
||||||
|
|
||||||
**currentFileName** 对比:
|
|
||||||
|
|
||||||
| 功能 | useFilePreview | FileSystem.vue | 差异 |
|
|
||||||
|------|----------------|----------------|------|
|
|
||||||
| **ZIP 模式支持** | ❌ 无 | ✅ 有 | 关键差异 |
|
|
||||||
| **目录检测** | ❌ 无 | ✅ 有 | UX 增强 |
|
|
||||||
| **路径截断** | ❌ 无 | ✅ 有 | UX 增强 |
|
|
||||||
| **错误处理** | ❌ 无 | ✅ try-catch | 健壮性 |
|
|
||||||
|
|
||||||
**FileSystem.vue:1437-1460** (23行,包含 ZIP 逻辑)
|
|
||||||
```javascript
|
|
||||||
const currentFileNameDisplay = computed(() => {
|
|
||||||
if (isBrowsingZip.value && selectedFilePath.value) {
|
|
||||||
// ZIP 模式:从 zip 内路径中提取文件名
|
|
||||||
const parts = selectedFilePath.value.split('/')
|
|
||||||
return parts[parts.length - 1] || parts[parts.length - 2] || ''
|
|
||||||
}
|
|
||||||
if (selectedFilePath.value) {
|
|
||||||
// 正常模式:如果文件在当前目录,只显示文件名;否则显示完整路径
|
|
||||||
try {
|
|
||||||
if (isFileInCurrentDirectory.value) {
|
|
||||||
return getFileName(selectedFilePath.value)
|
|
||||||
} else {
|
|
||||||
return selectedFilePath.value // 返回完整路径
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
debugWarn('[currentFileName] 计算失败,返回文件名:', error)
|
|
||||||
return getFileName(selectedFilePath.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**useFilePreview.js:122-126** (5行,无特殊逻辑)
|
|
||||||
```javascript
|
|
||||||
const currentFileName = computed(() => {
|
|
||||||
if (!filePath.value) return ''
|
|
||||||
const parts = filePath.value.split(/[/\\]/)
|
|
||||||
return parts[parts.length - 1]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 函数命名体系差异
|
|
||||||
|
|
||||||
| 功能 | useFilePreview | FileSystem.vue |
|
|
||||||
|------|----------------|----------------|
|
|
||||||
| 图片预览 | `previewImage` | `previewImageLocal` |
|
|
||||||
| 视频预览 | `previewVideo` | `previewVideoLocal` |
|
|
||||||
| 音频预览 | `previewAudio` | `previewAudioLocal` |
|
|
||||||
| PDF 预览 | `previewPdf` | `previewPdfLocal` |
|
|
||||||
| HTML 预览 | `previewHtml` | `previewHtmlLocal` |
|
|
||||||
| Markdown 预览 | `previewMarkdown` | `previewMarkdownLocal` |
|
|
||||||
|
|
||||||
**Local 后缀的意义**:
|
|
||||||
- 表明这是本地实现,避免与外部库或全局函数冲突
|
|
||||||
- 如果替换为 Composable,需要全局重命名模板中的所有调用点(30+ 处)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、useNavigation.js vs FileSystem.vue
|
|
||||||
|
|
||||||
### 3.1 集成状态
|
|
||||||
|
|
||||||
✅ **已成功集成** (FileSystem.vue:605-625)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const {
|
|
||||||
navHistory,
|
|
||||||
navIndex,
|
|
||||||
isNavigating,
|
|
||||||
canGoBack,
|
|
||||||
canGoForward,
|
|
||||||
addToHistory,
|
|
||||||
pushNav,
|
|
||||||
goBack,
|
|
||||||
goForward,
|
|
||||||
onPathSelect,
|
|
||||||
onPathEnter,
|
|
||||||
browseDirectory,
|
|
||||||
} = useNavigation({
|
|
||||||
filePath,
|
|
||||||
onListDirectory: async (path) => {
|
|
||||||
filePath.value = path
|
|
||||||
await listDirectory()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 为什么成功?
|
|
||||||
|
|
||||||
1. **清晰的回调接口**: `onListDirectory` 作为回调,连接到本地实现
|
|
||||||
2. **状态变量简单**: 只依赖 `filePath`,没有复杂的 computed 依赖
|
|
||||||
3. **无 API 假设**: 不涉及 URL 格式、网络请求等
|
|
||||||
4. **功能独立**: 导航逻辑不依赖预览、编辑等其他模块
|
|
||||||
|
|
||||||
### 3.3 集成模式
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ useNavigation │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
│ onListDirectory(path)
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ FileSystem.vue │
|
|
||||||
│ listDirectory()│
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
这种模式清晰、解耦、易于测试。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、根因总结
|
|
||||||
|
|
||||||
### 4.1 设计哲学差异
|
|
||||||
|
|
||||||
| 维度 | Composables | FileSystem.vue |
|
|
||||||
|------|-------------|----------------|
|
|
||||||
| **复杂度** | 追求简洁、纯粹 | 追求功能完整 |
|
|
||||||
| **假设** | 单一路径、标准API | 多路径源、自定义API |
|
|
||||||
| **范围** | 单一职责 | 全功能 |
|
|
||||||
| **演进** | 从头设计 | 增量演进(ZIP、新建文件等) |
|
|
||||||
|
|
||||||
### 4.2 API 契议不匹配
|
|
||||||
|
|
||||||
**Composable 隐式假设**:
|
|
||||||
```javascript
|
|
||||||
// 假设 1: URL 格式
|
|
||||||
`${fileServerURL}/file?path=${encodedPath}`
|
|
||||||
|
|
||||||
// 假设 2: 路径来源
|
|
||||||
const path = filePath.value // 单一来源
|
|
||||||
|
|
||||||
// 假设 3: 状态变量
|
|
||||||
const canSave = isEditMode && changed // 简单布尔值
|
|
||||||
```
|
|
||||||
|
|
||||||
**FileSystem.vue 实际**:
|
|
||||||
```javascript
|
|
||||||
// 实际 1: URL 格式
|
|
||||||
`${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
|
||||||
|
|
||||||
// 实际 2: 路径来源
|
|
||||||
const path = targetPath || selectedFilePath || filePath // 三级优先级
|
|
||||||
|
|
||||||
// 实际 3: 状态变量
|
|
||||||
const canSave = isEditableView && changed // 复杂 computed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 功能演进差距
|
|
||||||
|
|
||||||
**FileSystem.vue 独有功能**:
|
|
||||||
- ✅ ZIP 文件浏览模式
|
|
||||||
- ✅ 新建文件检测
|
|
||||||
- ✅ 目录感知显示
|
|
||||||
- ✅ 路径规范化
|
|
||||||
- ✅ 文件是否在当前目录检测
|
|
||||||
|
|
||||||
**useFileEdit/useFilePreview 创建时未考虑这些功能**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、集成失败的三个层次
|
|
||||||
|
|
||||||
### 层次 1: 语法层面(易于发现)
|
|
||||||
```
|
|
||||||
❌ ReferenceError: loadDraft is not defined
|
|
||||||
❌ Identifier 'previewImage' has already been declared
|
|
||||||
```
|
|
||||||
|
|
||||||
### 层次 2: 语义层面(运行时错误)
|
|
||||||
```
|
|
||||||
❌ 保存按钮在图片预览时也显示 (isEditMode vs isEditableView)
|
|
||||||
❌ URL 404 错误 (/file?path= vs /localfs/)
|
|
||||||
❌ 新建文件无法保存
|
|
||||||
```
|
|
||||||
|
|
||||||
### 层次 3: 设计层面(深层不兼容)
|
|
||||||
```
|
|
||||||
❌ 单一路径模型 vs 多路径源
|
|
||||||
❌ 简单布尔值 vs 复杂 computed
|
|
||||||
❌ 标准API vs 自定义API
|
|
||||||
❌ 静态功能 vs 增量演进
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、解决方案
|
|
||||||
|
|
||||||
### 方案 A: 保持现状 + 提取工具函数(推荐)
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- 功能完整性优先
|
|
||||||
- 避免破坏性重构
|
|
||||||
- 渐进式优化
|
|
||||||
|
|
||||||
**行动**:
|
|
||||||
1. 保留 `useNavigation` 集成
|
|
||||||
2. 删除 `useFileEdit` 和 `useFilePreview`(或作为参考文档)
|
|
||||||
3. 提取真正的通用工具函数:
|
|
||||||
```javascript
|
|
||||||
// utils/pathHelpers.js
|
|
||||||
export const splitPath = (path) => path.split(/[/\\]/)
|
|
||||||
export const getFileName = (path) => { /* ... */ }
|
|
||||||
export const getParentPath = (path) => { /* ... */ }
|
|
||||||
|
|
||||||
// utils/fileHelpers.js
|
|
||||||
export const isImageFile = (ext) => FILE_EXTENSIONS.IMAGE.includes(ext)
|
|
||||||
export const isVideoFile = (ext) => FILE_EXTENSIONS.VIDEO_BROWSER.includes(ext)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 减少调试日志(65 → 10)
|
|
||||||
|
|
||||||
### 方案 B: 重构 FileSystem.vue(激进)
|
|
||||||
|
|
||||||
**风险**: 高
|
|
||||||
**时间**: 2-3周
|
|
||||||
**收益**: 长期可维护性
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. 统一状态管理(单一 `filePath` vs `selectedFilePath`)
|
|
||||||
2. 标准化 API(统一 URL 格式)
|
|
||||||
3. 组件化拆分(子组件)
|
|
||||||
4. 然后重新集成 Composables
|
|
||||||
|
|
||||||
### 方案 C: 创建轻量级 Composables(折中)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// useFileEditMinimal.js
|
|
||||||
export function useFileEditMinimal({ fileContent, originalContent }) {
|
|
||||||
const contentChanged = computed(() =>
|
|
||||||
fileContent.value !== '' &&
|
|
||||||
fileContent.value !== originalContent.value
|
|
||||||
)
|
|
||||||
|
|
||||||
return { contentChanged }
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileSystem.vue
|
|
||||||
const { contentChanged } = useFileEditMinimal({ fileContent, originalContent })
|
|
||||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、检查清单
|
|
||||||
|
|
||||||
### 立即行动(本周)
|
|
||||||
|
|
||||||
- [x] 分析集成失败根因
|
|
||||||
- [ ] 修复 `loadDraft is not defined` 运行时错误
|
|
||||||
- [ ] 决定方案 A/B/C
|
|
||||||
- [ ] 执行决定
|
|
||||||
|
|
||||||
### 短期优化(2周)
|
|
||||||
|
|
||||||
- [ ] 提取路径工具函数
|
|
||||||
- [ ] 提取文件类型判断函数
|
|
||||||
- [ ] 统一 localStorage 键名
|
|
||||||
- [ ] 减少调试日志
|
|
||||||
|
|
||||||
### 长期重构(1个月)
|
|
||||||
|
|
||||||
- [ ] 组件化拆分(子组件)
|
|
||||||
- [ ] 状态管理优化
|
|
||||||
- [ ] TypeScript 迁移
|
|
||||||
- [ ] 单元测试覆盖
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、关键发现
|
|
||||||
|
|
||||||
### 发现 1: Composables 是"理想版本"
|
|
||||||
|
|
||||||
Composables 基于**理想假设**设计:
|
|
||||||
- 单一路径来源
|
|
||||||
- 标准 API
|
|
||||||
- 简单状态
|
|
||||||
- 纯净功能
|
|
||||||
|
|
||||||
但 FileSystem.vue 是**现实版本**:
|
|
||||||
- 多路径源(历史包袱)
|
|
||||||
- 自定义 API(性能优化)
|
|
||||||
- 复杂状态(功能完整)
|
|
||||||
- 增量演进(业务需求)
|
|
||||||
|
|
||||||
### 发现 2: 命名体系反映演进历史
|
|
||||||
|
|
||||||
所有预览函数都有 `Local` 后缀:
|
|
||||||
```javascript
|
|
||||||
previewImageLocal // 表明"本地实现"
|
|
||||||
previewVideoLocal // 避免"全局冲突"
|
|
||||||
```
|
|
||||||
|
|
||||||
这说明开发者在添加这些函数时,**已经意识到可能存在外部冲突**,因此添加后缀。
|
|
||||||
|
|
||||||
如果强行使用无后缀的 Composable 版本,会破坏这种防御性设计。
|
|
||||||
|
|
||||||
### 发现 3: useNavigation 成功的启示
|
|
||||||
|
|
||||||
useNavigation 成功的关键:
|
|
||||||
1. **清晰的边界**: 只负责导航历史
|
|
||||||
2. **回调接口**: 不直接操作文件系统
|
|
||||||
3. **状态简单**: 只依赖 `filePath`
|
|
||||||
4. **无副作用**: 不涉及 UI 状态
|
|
||||||
|
|
||||||
**教训**: 如果要提取 Composables,应该遵循同样的原则。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、最终建议
|
|
||||||
|
|
||||||
### 推荐:方案 A - 提取工具函数
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
1. **风险最低**: 不破坏现有功能
|
|
||||||
2. **收益明确**: 减少代码重复(路径处理、文件类型判断)
|
|
||||||
3. **时间可控**: 1周内完成
|
|
||||||
4. **渐进式**: 为未来重构铺路
|
|
||||||
|
|
||||||
**具体行动**:
|
|
||||||
```javascript
|
|
||||||
// 第1步:提取工具函数
|
|
||||||
// utils/pathHelpers.js
|
|
||||||
// utils/fileTypeHelpers.js
|
|
||||||
|
|
||||||
// 第2步:替换重复代码
|
|
||||||
// path.split(/[/\\/]/) → splitPath(path)
|
|
||||||
|
|
||||||
// 第3步:删除未使用的 Composables
|
|
||||||
// rm useFileEdit.js useFilePreview.js
|
|
||||||
|
|
||||||
// 第4步:减少调试日志
|
|
||||||
// 保留 10 个关键日志,删除 55 个
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- 代码减少 ~200 行
|
|
||||||
- DRY 评分改善 5%
|
|
||||||
- 维护成本降低
|
|
||||||
- 为长期重构打好基础
|
|
||||||
@@ -1,628 +0,0 @@
|
|||||||
# 重构缺漏检查报告
|
|
||||||
**日期**: 2025-01-30
|
|
||||||
**审查范围**: FileSystem.vue + 3个Composables
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、严重问题 🔴
|
|
||||||
|
|
||||||
### 1. **重构目标未达成 - FileSystem.vue 仍然过大**
|
|
||||||
|
|
||||||
| 文件 | 当前行数 | 目标行数 | 差距 | 状态 |
|
|
||||||
|------|----------|----------|------|------|
|
|
||||||
| FileSystem.vue | 4047 | < 500 | +3547 | 🔴 |
|
|
||||||
| useNavigation.js | 273 | - | - | ✅ |
|
|
||||||
| useFileEdit.js | 369 | - | - | ✅ |
|
|
||||||
| useFilePreview.js | 611 | - | - | ✅ |
|
|
||||||
| **总计** | 5300 | < 1500 | +3800 | 🔴 |
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- Composables已创建(1253行),但**未真正集成**
|
|
||||||
- FileSystem.vue仍然包含所有原始逻辑(4047行)
|
|
||||||
- **代码总量增加**:从4241行 → 5300行(+25%)
|
|
||||||
|
|
||||||
**根本原因**:
|
|
||||||
- 之前因20+个重复函数声明错误,撤销了composable集成
|
|
||||||
- 保留了所有本地实现,导致双重代码存在
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. **重复的计算属性(DRY违反)**
|
|
||||||
|
|
||||||
#### 问题1: `isFileModified` 重复定义
|
|
||||||
|
|
||||||
**FileSystem.vue:2977-2988**
|
|
||||||
```javascript
|
|
||||||
const isFileModified = computed(() => {
|
|
||||||
const hasContent = fileContent.value !== '' && fileContent.value.trim() !== ''
|
|
||||||
const hasModified = selectedFilePath.value && fileContent.value !== originalContent.value
|
|
||||||
const isNewFile = !selectedFilePath.value && hasContent
|
|
||||||
return isEditableView.value && (hasModified || isNewFile)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**useFileEdit.js:71-74** (未使用)
|
|
||||||
```javascript
|
|
||||||
const isFileModified = computed(() => {
|
|
||||||
return originalContent.value !== undefined &&
|
|
||||||
originalContent.value !== fileContent.value
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**差异**:FileSystem.vue版本包含"新建文件"逻辑,useFileEdit版本更简单
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题2: 文件名计算属性重复
|
|
||||||
|
|
||||||
**FileSystem.vue:1437-1460**
|
|
||||||
```javascript
|
|
||||||
const currentFileNameDisplay = computed(() => {
|
|
||||||
if (!selectedFilePath.value && !filePath.value) return '无文件'
|
|
||||||
|
|
||||||
const path = selectedFilePath.value || filePath.value
|
|
||||||
const parts = path.split(/[/\\]/)
|
|
||||||
const fileName = parts[parts.length - 1]
|
|
||||||
|
|
||||||
if (fileName.length > 30) {
|
|
||||||
return fileName.substring(0, 15) + '...' + fileName.substring(fileName.length - 10)
|
|
||||||
}
|
|
||||||
return fileName
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**useFilePreview.js:122-126** (未使用)
|
|
||||||
```javascript
|
|
||||||
const currentFileName = computed(() => {
|
|
||||||
if (!filePath.value) return ''
|
|
||||||
const parts = filePath.value.split(/[/\\]/)
|
|
||||||
return parts[parts.length - 1]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**重复**:都做路径分割取文件名,但Display版本有截断逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题3: 文件路径计算属性重复
|
|
||||||
|
|
||||||
**FileSystem.vue:1462-1485**
|
|
||||||
```javascript
|
|
||||||
const currentFileFullPathDisplay = computed(() => {
|
|
||||||
if (isBrowsingZip.value) {
|
|
||||||
return `ZIP: ${currentZipPath.value} → ${currentZipDirectory.value || '/'}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedFilePath.value) {
|
|
||||||
return filePath.value || '未选择文件'
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = selectedFilePath.value
|
|
||||||
if (path.length > 50) {
|
|
||||||
return '...' + path.substring(path.length - 50)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**useFilePreview.js:131** (未使用)
|
|
||||||
```javascript
|
|
||||||
const currentFileFullPath = computed(() => filePath.value || '')
|
|
||||||
```
|
|
||||||
|
|
||||||
**重复**:获取文件路径,但Display版本有ZIP模式和截断逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题4: 内容修改检测重复
|
|
||||||
|
|
||||||
**FileSystem.vue:2991-2994**
|
|
||||||
```javascript
|
|
||||||
const contentChanged = computed(() => {
|
|
||||||
return fileContent.value !== '' &&
|
|
||||||
fileContent.value !== originalContent.value
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**useFileEdit.js:79-82** (未使用)
|
|
||||||
```javascript
|
|
||||||
const contentChanged = computed(() => {
|
|
||||||
return fileContent.value !== '' &&
|
|
||||||
fileContent.value !== originalContent.value
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**完全相同**:100%重复代码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 问题5: 保存/重置按钮状态重复
|
|
||||||
|
|
||||||
**FileSystem.vue:2997-3004**
|
|
||||||
```javascript
|
|
||||||
const canSaveFile = computed(() => isEditableView.value && contentChanged.value)
|
|
||||||
const canResetContent = computed(() =>
|
|
||||||
isEditableView.value &&
|
|
||||||
contentChanged.value &&
|
|
||||||
originalContent.value !== undefined
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**useFileEdit.js:87-98** (未使用)
|
|
||||||
```javascript
|
|
||||||
const canSaveFile = computed(() => {
|
|
||||||
return isEditMode.value && contentChanged.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const canResetContent = computed(() => {
|
|
||||||
return isEditMode.value &&
|
|
||||||
contentChanged.value &&
|
|
||||||
originalContent.value !== undefined
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**差异**:FileSystem.vue用`isEditableView`,useFileEdit用`isEditMode`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. **调试日志仍然过多 - 65个**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ grep -c "debug(Log|Warn|Error|Info)" web/src/components/FileSystem.vue
|
|
||||||
65
|
|
||||||
```
|
|
||||||
|
|
||||||
**分布**:
|
|
||||||
- `debugLog`: ~45处
|
|
||||||
- `debugWarn`: ~12处
|
|
||||||
- `debugError`: ~8处
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- 已从raw console替换为debugLog,但**数量仍然过多**
|
|
||||||
- 过度防御性编程,每个分支都记录日志
|
|
||||||
- 影响代码可读性和运行时性能
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、中等问题 🟡
|
|
||||||
|
|
||||||
### 4. **currentFileExtension 逻辑嵌套过多**
|
|
||||||
|
|
||||||
**FileSystem.vue:2941-2960** (19行)
|
|
||||||
```javascript
|
|
||||||
const currentFileExtension = computed(() => {
|
|
||||||
const path = selectedFilePath.value || filePath.value
|
|
||||||
if (!path) return ''
|
|
||||||
|
|
||||||
const fileName = path.split(/[/\\]/).pop()?.toLowerCase() || ''
|
|
||||||
const specialFiles = {
|
|
||||||
'dockerfile': 'dockerfile',
|
|
||||||
'containerfile': 'dockerfile',
|
|
||||||
'makefile': 'makefile',
|
|
||||||
'cmakelists.txt': 'cmake',
|
|
||||||
'.gitignore': 'gitignore',
|
|
||||||
'.env': 'properties',
|
|
||||||
}
|
|
||||||
|
|
||||||
if (specialFiles[fileName]) return specialFiles[fileName]
|
|
||||||
return getExt(path)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**可以改进为**(使用fileHelpers.js中的函数):
|
|
||||||
```javascript
|
|
||||||
const currentFileExtension = computed(() => {
|
|
||||||
const path = selectedFilePath.value || filePath.value
|
|
||||||
return getExtensionForHighlight(path) // 复用现有工具函数
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. **函数命名不一致**
|
|
||||||
|
|
||||||
| FileSystem.vue | useFilePreview.js | 用途 |
|
|
||||||
|----------------|-------------------|------|
|
|
||||||
| `currentFileNameDisplay` | `currentFileName` | 获取文件名 |
|
|
||||||
| `currentFileFullPathDisplay` | `currentFileFullPath` | 获取完整路径 |
|
|
||||||
| `currentImageDimensionsLocal` | `currentImageDimensions` | 图片尺寸 |
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- 有的带`Display`后缀,有的不带
|
|
||||||
- 有的带`Local`后缀,含义不明
|
|
||||||
- 命名不一致导致维护困难
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. **Go代码配置函数重复**
|
|
||||||
|
|
||||||
**internal/filesystem/config.go:256-295**
|
|
||||||
```go
|
|
||||||
func getAllowedExtensions() map[string]bool {
|
|
||||||
return map[string]bool{
|
|
||||||
".jpg": true, ".jpeg": true, ".png": true,
|
|
||||||
// ... 30+ 个硬编码扩展名
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**web/src/utils/constants.js:27-73** (重复定义)
|
|
||||||
```javascript
|
|
||||||
export const FILE_EXTENSIONS = {
|
|
||||||
IMAGE: ['jpg', 'jpeg', 'png', /* ... */],
|
|
||||||
VIDEO_BROWSER: ['mp4', 'webm', /* ... */],
|
|
||||||
// ... 类似的30+个扩展名
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:前后端用不同格式重复定义相同的数据
|
|
||||||
|
|
||||||
**建议**:后端从配置文件加载,或生成JSON供前端使用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、代码规范问题 ⚠️
|
|
||||||
|
|
||||||
### 7. **路径分隔符正则重复**
|
|
||||||
|
|
||||||
**出现次数**: 15+
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// FileSystem.vue 多处
|
|
||||||
path.split(/[/\\]/) // 行 719, 798, 819, 833, 845, 2946, ...
|
|
||||||
|
|
||||||
// useFilePreview.js:124
|
|
||||||
path.split(/[/\\/]/)
|
|
||||||
|
|
||||||
// useNavigation.js:304
|
|
||||||
const parts = path.split(/[/\\]/)
|
|
||||||
```
|
|
||||||
|
|
||||||
**建议**:提取为共享常量
|
|
||||||
```javascript
|
|
||||||
// utils/pathConstants.js
|
|
||||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
|
||||||
export const splitPath = (path) => path.split(PATH_SEPARATOR_REGEX)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. **文件类型判断分散**
|
|
||||||
|
|
||||||
**FileSystem.vue:857-869**
|
|
||||||
```javascript
|
|
||||||
const previewableTypes = [
|
|
||||||
...FILE_EXTENSIONS.IMAGE,
|
|
||||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
|
||||||
...FILE_EXTENSIONS.AUDIO,
|
|
||||||
'pdf', 'html', 'htm', 'md', 'markdown'
|
|
||||||
]
|
|
||||||
|
|
||||||
const knownBinaryTypes = [
|
|
||||||
'exe', 'dll', 'so', 'bin',
|
|
||||||
'zip', 'rar', '7z', 'tar', 'gz', 'iso', 'img', 'dmg',
|
|
||||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- 内联定义在函数内部
|
|
||||||
- 应该定义在constants.js中复用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. **localStorage键名分散**
|
|
||||||
|
|
||||||
**多处重复定义**:
|
|
||||||
- FileSystem.vue: 使用`STORAGE_KEYS.FILESYSTEM.*`
|
|
||||||
- useFileEdit.js: 直接定义`DRAFT_STORAGE_KEY`
|
|
||||||
- useNavigation.js: 直接定义`STORAGE_KEY_PATH_HISTORY`
|
|
||||||
|
|
||||||
**应该统一使用**:`STORAGE_KEYS`常量对象
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、DRY原则违反统计
|
|
||||||
|
|
||||||
### 重复代码统计
|
|
||||||
|
|
||||||
| 类型 | 重复次数 | 总行数 | 浪费 |
|
|
||||||
|------|----------|--------|------|
|
|
||||||
| 计算属性 | 5组 | ~80行 | 40行 |
|
|
||||||
| 路径分割正则 | 15+次 | ~15行 | 14行 |
|
|
||||||
| 文件类型判断 | 8+次 | ~50行 | 40行 |
|
|
||||||
| localStorage键 | 6+处 | ~12行 | 8行 |
|
|
||||||
| **总计** | **34+处** | **~157行** | **102行** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、优化建议
|
|
||||||
|
|
||||||
### 优先级1: 立即修复 🔴
|
|
||||||
|
|
||||||
#### 1.1 移除未使用的Composables
|
|
||||||
```bash
|
|
||||||
# 由于composables未被实际使用,应该删除或文档化
|
|
||||||
rm web/src/composables/useNavigation.js
|
|
||||||
rm web/src/composables/useFileEdit.js
|
|
||||||
rm web/src/composables/useFilePreview.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:如果不用,就不应该存在,避免混淆
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 1.2 删除重复计算属性
|
|
||||||
|
|
||||||
**FileSystem.vue - 保留更完整的版本,删除useFileEdit/useFilePreview中的**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 保留 FileSystem.vue:1437 - currentFileNameDisplay (有截断逻辑)
|
|
||||||
// 保留 FileSystem.vue:1462 - currentFileFullPathDisplay (有ZIP模式)
|
|
||||||
// 保留 FileSystem.vue:2977 - isFileModified (有新建文件逻辑)
|
|
||||||
// 删除 useFileEdit.js:71, useFilePreview.js:122-126 等重复定义
|
|
||||||
```
|
|
||||||
|
|
||||||
**或者相反**:如果决定使用composables,则删除FileSystem.vue中的重复定义
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 1.3 大幅减少调试日志
|
|
||||||
|
|
||||||
**策略A: 环境变量控制**(已部分实现)
|
|
||||||
```javascript
|
|
||||||
// utils/debugLog.js
|
|
||||||
const ENABLE_DEBUG = import.meta.env.DEV
|
|
||||||
|
|
||||||
export const debugLog = ENABLE_DEBUG ? console.log : () => {}
|
|
||||||
export const debugWarn = ENABLE_DEBUG ? console.warn : () => {}
|
|
||||||
export const debugError = console.error // 始终保留错误日志
|
|
||||||
```
|
|
||||||
|
|
||||||
**策略B: 删除非关键日志**(推荐)
|
|
||||||
```javascript
|
|
||||||
// 删除这些类型的日志:
|
|
||||||
debugLog('[readFile] 开始读取文件') // 显而易见的操作
|
|
||||||
debugLog('[handleKeyDown] F2 pressed') // 用户操作
|
|
||||||
debugLog('[startResizeHorizontal] 开始拖拽') // UI交互
|
|
||||||
|
|
||||||
// 保留这些:
|
|
||||||
debugError('[readFile] 读取失败:', error) // 错误
|
|
||||||
debugWarn('[loadCommonPaths] Wails API未就绪') // 降级场景
|
|
||||||
```
|
|
||||||
|
|
||||||
**目标**: 从65个 → < 10个(只保留错误和关键警告)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 优先级2: 短期优化 🟡
|
|
||||||
|
|
||||||
#### 2.1 提取共享工具函数
|
|
||||||
|
|
||||||
**创建 web/src/utils/pathHelpers.js**:
|
|
||||||
```javascript
|
|
||||||
export const PATH_SEPARATOR_REGEX = /[/\\]/
|
|
||||||
|
|
||||||
export const splitPath = (path) => path.split(PATH_SEPARATOR_REGEX)
|
|
||||||
|
|
||||||
export const getFileName = (path) => {
|
|
||||||
if (!path) return ''
|
|
||||||
const parts = splitPath(path)
|
|
||||||
return parts[parts.length - 1] || path
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getParentPath = (path) => {
|
|
||||||
if (!path) return ''
|
|
||||||
const lastSep = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
||||||
return lastSep > 0 ? path.substring(0, lastSep) : path
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**替换所有** `path.split(/[/\\]/)` 为 `splitPath(path)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 2.2 统一文件类型常量
|
|
||||||
|
|
||||||
**创建 web/src/utils/fileTypeCategories.js**:
|
|
||||||
```javascript
|
|
||||||
import { FILE_EXTENSIONS } from './constants'
|
|
||||||
|
|
||||||
export const PREVIEWABLE_TYPES = [
|
|
||||||
...FILE_EXTENSIONS.IMAGE,
|
|
||||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
|
||||||
...FILE_EXTENSIONS.AUDIO,
|
|
||||||
'pdf', 'html', 'htm', 'md', 'markdown'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const KNOWN_BINARY_TYPES = [
|
|
||||||
'exe', 'dll', 'so', 'bin',
|
|
||||||
'zip', 'rar', '7z', 'tar', 'gz', 'iso', 'img', 'dmg',
|
|
||||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const TEXT_EDITABLE_TYPES = [
|
|
||||||
...FILE_EXTENSIONS.TEXT,
|
|
||||||
...FILE_EXTENSIONS.CODE
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**替换所有内联定义**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 2.3 统一localStorage键名
|
|
||||||
|
|
||||||
**只在 constants.js 中定义一次**:
|
|
||||||
```javascript
|
|
||||||
export const STORAGE_KEYS = {
|
|
||||||
FILESYSTEM: {
|
|
||||||
PATH_HISTORY: 'app-filesystem-path-history',
|
|
||||||
EDIT_MODE: 'app-filesystem-edit-mode',
|
|
||||||
PANEL_WIDTH: 'app-filesystem-panel-width',
|
|
||||||
DRAFT_CONTENT: 'filesystem-draft-content',
|
|
||||||
DRAFT_TIME: 'filesystem-draft-time',
|
|
||||||
FAVORITE_FILES: 'filesystem-favorite-files',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除所有其他文件中的重复定义
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 优先级3: 长期重构 🔵
|
|
||||||
|
|
||||||
#### 3.1 真正拆分FileSystem.vue
|
|
||||||
|
|
||||||
**目标**: 从4047行 → < 500行
|
|
||||||
|
|
||||||
**策略**:
|
|
||||||
1. **提取子组件** (~1500行)
|
|
||||||
- `FileListPanel.vue` (文件列表, ~300行)
|
|
||||||
- `CodeEditorPanel.vue` (编辑器面板, ~400行)
|
|
||||||
- `PreviewPanel.vue` (预览面板, ~300行)
|
|
||||||
- `FavoriteSidebar.vue` (收藏夹侧边栏, ~200行)
|
|
||||||
- `Toolbar.vue` (顶部工具栏, ~150行)
|
|
||||||
- `ContextMenu.vue` (右键菜单, ~150行)
|
|
||||||
|
|
||||||
2. **提取composables** (~1000行)
|
|
||||||
- `useFileSystem.js` (核心文件系统操作, ~300行)
|
|
||||||
- `useFileEditor.js` (编辑器逻辑, ~200行)
|
|
||||||
- `useFilePreview.js` (预览逻辑, ~250行)
|
|
||||||
- `useFavoriteFiles.js` (收藏夹管理, ~150行)
|
|
||||||
- `useKeyboardShortcuts.js` (快捷键, ~100行)
|
|
||||||
|
|
||||||
3. **主组件保留** (~500行)
|
|
||||||
- 布局和状态协调
|
|
||||||
- 子组件通信
|
|
||||||
- 生命周期管理
|
|
||||||
|
|
||||||
**时间估算**: 2-3周
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 3.2 TypeScript迁移
|
|
||||||
|
|
||||||
**目标**: 添加类型安全,减少运行时错误
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// types/file.ts
|
|
||||||
export interface FileItem {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
is_dir: boolean
|
|
||||||
size: number
|
|
||||||
modified: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreviewState {
|
|
||||||
isImageView: boolean
|
|
||||||
isVideoView: boolean
|
|
||||||
isAudioView: boolean
|
|
||||||
isPdfFile: boolean
|
|
||||||
isHtmlFile: boolean
|
|
||||||
isMarkdownFile: boolean
|
|
||||||
isBinaryFile: boolean
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 3.3 统一前后端文件类型定义
|
|
||||||
|
|
||||||
**方案A: 后端生成JSON**
|
|
||||||
```go
|
|
||||||
// internal/filesystem/export_types.go
|
|
||||||
func ExportFileTypes() string {
|
|
||||||
types := map[string][]string{
|
|
||||||
"image": getAllowedExtensions(),
|
|
||||||
"binary": getForbiddenExtensions(),
|
|
||||||
}
|
|
||||||
json, _ := json.Marshal(types)
|
|
||||||
return string(json)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**方案B: 独立配置文件**
|
|
||||||
```yaml
|
|
||||||
# config/file_types.yaml
|
|
||||||
image:
|
|
||||||
- jpg
|
|
||||||
- jpeg
|
|
||||||
- png
|
|
||||||
binary:
|
|
||||||
- exe
|
|
||||||
- dll
|
|
||||||
```
|
|
||||||
|
|
||||||
前后端都从同一配置读取
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、检查清单
|
|
||||||
|
|
||||||
### 立即执行(本周)
|
|
||||||
|
|
||||||
- [ ] **决定**: 删除还是使用composables
|
|
||||||
- [ ] **删除重复**: 移除5组重复计算属性(102行)
|
|
||||||
- [ ] **减少日志**: 从65个debugLog → < 10个
|
|
||||||
- [ ] **提取工具**: 创建pathHelpers.js
|
|
||||||
- [ ] **统一常量**: 合并文件类型定义
|
|
||||||
- [ ] **统一键名**: 只使用STORAGE_KEYS
|
|
||||||
|
|
||||||
### 短期计划(2周)
|
|
||||||
|
|
||||||
- [ ] **提取子组件**: FileListPanel, Toolbar, ContextMenu
|
|
||||||
- [ ] **提效composables**: 真正集成useFileSystem, useFilePreview
|
|
||||||
- [ ] **优化函数**: 简化currentFileExtension逻辑
|
|
||||||
- [ ] **命名统一**: 统一Display/Local后缀规则
|
|
||||||
|
|
||||||
### 长期优化(1个月)
|
|
||||||
|
|
||||||
- [ ] **组件化**: 完成所有子组件提取
|
|
||||||
- [ ] **TypeScript**: 添加类型定义
|
|
||||||
- [ ] **前后端统一**: 文件类型配置共享
|
|
||||||
- [ ] **单元测试**: 覆盖核心逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、代码质量指标(更新后)
|
|
||||||
|
|
||||||
| 指标 | 当前值 | 目标值 | 评级 |
|
|
||||||
|------|--------|--------|------|
|
|
||||||
| 单文件最大行数 | 4047 | < 500 | 🔴 |
|
|
||||||
| 函数平均行数 | ~50 | < 30 | 🟡 |
|
|
||||||
| 代码重复率 | ~8% | < 3% | 🔴 |
|
|
||||||
| 调试语句数量 | 65 | < 10 | 🔴 |
|
|
||||||
| 圈复杂度 | 15+ | < 10 | 🟡 |
|
|
||||||
| 未使用代码 | 1253行 | 0 | 🔴 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、总结
|
|
||||||
|
|
||||||
### 关键发现
|
|
||||||
|
|
||||||
1. **重构未完成**: Composables已创建但未使用,反而增加了总代码量
|
|
||||||
2. **重复代码严重**: 5组计算属性重复,102行浪费
|
|
||||||
3. **过度防御性编程**: 65个调试日志,远超必要数量
|
|
||||||
4. **命名不一致**: Display/Local后缀混乱
|
|
||||||
|
|
||||||
### 下一步行动
|
|
||||||
|
|
||||||
**推荐方案A: 激进重构**
|
|
||||||
- 删除3个未使用的composables
|
|
||||||
- 立即开始拆分子组件
|
|
||||||
- 1个月内完成组件化
|
|
||||||
|
|
||||||
**推荐方案B: 渐进优化(更稳妥)**
|
|
||||||
- 先清理重复代码和日志
|
|
||||||
- 提取共享工具函数
|
|
||||||
- 逐步拆分子组件
|
|
||||||
|
|
||||||
### 风险提示
|
|
||||||
|
|
||||||
⚠️ **当前状态**: 代码库处于"半重构"状态,既有旧实现又有新参考,容易造成混淆
|
|
||||||
|
|
||||||
**建议**: 尽快决定方向(彻底重构 vs 回滚到重构前),避免技术债务累积
|
|
||||||
13
go.mod
13
go.mod
@@ -3,24 +3,34 @@ module u-desk
|
|||||||
go 1.25.6
|
go 1.25.6
|
||||||
|
|
||||||
require (
|
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/glebarez/sqlite v1.11.0
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/redis/go-redis/v9 v9.17.3
|
github.com/redis/go-redis/v9 v9.17.3
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
|
github.com/yuin/goldmark v1.8.2
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
|
golang.org/x/sys v0.40.0
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // 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/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
@@ -61,7 +71,6 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
modernc.org/libc v1.67.7 // indirect
|
modernc.org/libc v1.67.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
26
go.sum
26
go.sum
@@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
@@ -8,6 +10,12 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f 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/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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
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.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
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 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@@ -57,6 +73,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/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 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
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 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
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=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
@@ -68,6 +86,8 @@ 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
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/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -107,8 +127,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
|
|||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
@@ -118,6 +138,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 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
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.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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
|||||||
@@ -136,40 +136,66 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateTabConfig 迁移旧配置
|
// MigrateTabConfig 迁移旧配置(device 移除 + openclaw-manager 重命名)
|
||||||
func (api *ConfigAPI) MigrateTabConfig() error {
|
func (api *ConfigAPI) MigrateTabConfig() error {
|
||||||
config, _ := api.configService.GetTabConfig()
|
config, _ := api.configService.GetTabConfig()
|
||||||
if config == nil {
|
if config == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否包含 device
|
needMigrate := false
|
||||||
hasDevice := false
|
|
||||||
|
// 检查是否包含需要迁移的旧 key
|
||||||
for _, tab := range config.AvailableTabs {
|
for _, tab := range config.AvailableTabs {
|
||||||
if tab.Key == "device" {
|
if tab.Key == "device" || tab.Key == "openclaw-manager" {
|
||||||
hasDevice = true
|
needMigrate = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasDevice {
|
if !needMigrate {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤掉 device
|
// 映射:旧 key → 新 key(不需要的移除)
|
||||||
|
keyMap := map[string]string{
|
||||||
|
"openclaw-manager": "version",
|
||||||
|
// "device": "" // 直接过滤
|
||||||
|
}
|
||||||
|
|
||||||
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
||||||
newVisible := make([]string, 0, len(config.VisibleTabs))
|
newVisible := make([]string, 0, len(config.VisibleTabs))
|
||||||
|
seenKeys := map[string]bool{}
|
||||||
|
|
||||||
for _, tab := range config.AvailableTabs {
|
for _, tab := range config.AvailableTabs {
|
||||||
if tab.Key != "device" {
|
newKey, shouldRename := keyMap[tab.Key]
|
||||||
|
if shouldRename {
|
||||||
|
if newKey == "" {
|
||||||
|
continue // 移除(如 device)
|
||||||
|
}
|
||||||
|
if seenKeys[newKey] {
|
||||||
|
continue // 避免重复
|
||||||
|
}
|
||||||
|
seenKeys[newKey] = true
|
||||||
|
newTabs = append(newTabs, service.TabDefinition{Key: newKey, Title: tab.Title, Enabled: tab.Enabled})
|
||||||
|
} else {
|
||||||
newTabs = append(newTabs, tab)
|
newTabs = append(newTabs, tab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, key := range config.VisibleTabs {
|
for _, key := range config.VisibleTabs {
|
||||||
if key != "device" {
|
if newKey, ok := keyMap[key]; ok {
|
||||||
|
if newKey != "" && !seenKeys[newKey] {
|
||||||
|
newVisible = append(newVisible, newKey)
|
||||||
|
}
|
||||||
|
// newKey == "" 时跳过(如 device)
|
||||||
|
} else {
|
||||||
newVisible = append(newVisible, key)
|
newVisible = append(newVisible, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultTab := config.DefaultTab
|
defaultTab := config.DefaultTab
|
||||||
|
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
|
||||||
|
defaultTab = newKey
|
||||||
|
}
|
||||||
if defaultTab == "device" {
|
if defaultTab == "device" {
|
||||||
defaultTab = "file-system"
|
defaultTab = "file-system"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,29 +21,31 @@ func NewConnectionAPI() (*ConnectionAPI, error) {
|
|||||||
|
|
||||||
// SaveConnectionRequest 保存连接请求结构体
|
// SaveConnectionRequest 保存连接请求结构体
|
||||||
type SaveConnectionRequest struct {
|
type SaveConnectionRequest struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
Options string `json:"options"`
|
Options string `json:"options"`
|
||||||
|
VisibleDatabases string `json:"visible_databases"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveDbConnection 保存数据库连接配置
|
// SaveDbConnection 保存数据库连接配置
|
||||||
func (api *ConnectionAPI) SaveDbConnection(req SaveConnectionRequest) error {
|
func (api *ConnectionAPI) SaveDbConnection(req SaveConnectionRequest) error {
|
||||||
conn := &models.DbConnection{
|
conn := &models.DbConnection{
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Host: req.Host,
|
Host: req.Host,
|
||||||
Port: req.Port,
|
Port: req.Port,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
Database: req.Database,
|
Database: req.Database,
|
||||||
Options: req.Options,
|
Options: req.Options,
|
||||||
|
VisibleDatabases: req.VisibleDatabases,
|
||||||
}
|
}
|
||||||
return api.connService.SaveConnection(conn)
|
return api.connService.SaveConnection(conn)
|
||||||
}
|
}
|
||||||
@@ -59,16 +61,17 @@ func (api *ConnectionAPI) ListDbConnections() ([]map[string]interface{}, error)
|
|||||||
timeFormat := "2006-01-02 15:04:05"
|
timeFormat := "2006-01-02 15:04:05"
|
||||||
for i, conn := range connections {
|
for i, conn := range connections {
|
||||||
result[i] = map[string]interface{}{
|
result[i] = map[string]interface{}{
|
||||||
"id": conn.ID,
|
"id": conn.ID,
|
||||||
"name": conn.Name,
|
"name": conn.Name,
|
||||||
"type": conn.Type,
|
"type": conn.Type,
|
||||||
"host": conn.Host,
|
"host": conn.Host,
|
||||||
"port": conn.Port,
|
"port": conn.Port,
|
||||||
"username": conn.Username,
|
"username": conn.Username,
|
||||||
"database": conn.Database,
|
"database": conn.Database,
|
||||||
"options": conn.Options,
|
"options": conn.Options,
|
||||||
"created_at": conn.CreatedAt.Format(timeFormat),
|
"visible_databases": conn.VisibleDatabases,
|
||||||
"updated_at": conn.UpdatedAt.Format(timeFormat),
|
"created_at": conn.CreatedAt.Format(timeFormat),
|
||||||
|
"updated_at": conn.UpdatedAt.Format(timeFormat),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -97,13 +100,29 @@ type TestConnectionRequest struct {
|
|||||||
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
|
// TestDbConnectionWithParams 测试数据库连接(直接传入参数,不保存数据)
|
||||||
func (api *ConnectionAPI) TestDbConnectionWithParams(req TestConnectionRequest) error {
|
func (api *ConnectionAPI) TestDbConnectionWithParams(req TestConnectionRequest) error {
|
||||||
return api.connService.TestConnectionWithParams(
|
return api.connService.TestConnectionWithParams(
|
||||||
req.Type,
|
req.Type, req.Host, req.Port,
|
||||||
req.Host,
|
req.Username, req.Password, req.Database,
|
||||||
req.Port,
|
req.Options, req.ID,
|
||||||
req.Username,
|
)
|
||||||
req.Password,
|
}
|
||||||
req.Database,
|
|
||||||
req.Options,
|
// LoadAllDatabasesRequest 加载全部数据库请求结构体
|
||||||
req.ID,
|
type LoadAllDatabasesRequest struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Options string `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAllDatabases 加载全部数据库列表
|
||||||
|
func (api *ConnectionAPI) LoadAllDatabases(req LoadAllDatabasesRequest) ([]string, error) {
|
||||||
|
return api.connService.LoadAllDatabases(
|
||||||
|
req.Type, req.Host, req.Port,
|
||||||
|
req.Username, req.Password, req.Database,
|
||||||
|
req.Options, req.ID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -12,6 +12,3 @@ const (
|
|||||||
|
|
||||||
// DefaultVisibleTabs 默认可见的 Tabs
|
// DefaultVisibleTabs 默认可见的 Tabs
|
||||||
var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}
|
var DefaultVisibleTabs = []string{TabDatabase, TabFileSystem, TabDevice}
|
||||||
|
|
||||||
// DefaultTab 默认打开的 Tab
|
|
||||||
const DefaultTab = TabDatabase
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
|
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
|
||||||
@@ -54,3 +55,9 @@ func Difference[T comparable](a, b []T) []T {
|
|||||||
}
|
}
|
||||||
return diff
|
return diff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsWindows 判断是否为Windows系统
|
||||||
|
func IsWindows() bool {
|
||||||
|
return runtime.GOOS == "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,106 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 旧版硬编码密钥(用于兼容迁移已有加密数据)
|
||||||
|
var legacyKey = []byte("go-desk-db-cli-key-32bytes123456")
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// 默认密钥(实际应用中应该从配置文件或环境变量读取)
|
encryptionKey []byte
|
||||||
// AES-256 需要 32 字节密钥
|
keyOnce sync.Once
|
||||||
// "go-desk-db-cli-key-32bytes123456" = 32 bytes
|
keyInitErr error
|
||||||
defaultKey = []byte("go-desk-db-cli-key-32bytes123456") // 32 bytes for AES-256
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
// getKey 获取或创建机器唯一密钥
|
||||||
// 验证密钥长度
|
// 首次启动时生成并持久化到用户配置目录,后续直接读取
|
||||||
if len(defaultKey) != 32 {
|
func getKey() ([]byte, error) {
|
||||||
panic(fmt.Sprintf("AES-256 密钥长度必须为 32 字节,当前为 %d 字节", len(defaultKey)))
|
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 加密密码
|
// EncryptPassword 加密密码
|
||||||
@@ -29,7 +115,12 @@ func EncryptPassword(password string) (string, error) {
|
|||||||
return "", nil
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("创建加密器失败: %v", err)
|
return "", fmt.Errorf("创建加密器失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -53,47 +144,32 @@ func EncryptPassword(password string) (string, error) {
|
|||||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptPassword 解密密码
|
// DecryptPassword 解密密码(自动回退旧密钥兼容旧数据)
|
||||||
func DecryptPassword(encryptedPassword string) (string, error) {
|
func DecryptPassword(encryptedPassword string) (string, error) {
|
||||||
if encryptedPassword == "" {
|
if encryptedPassword == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果加密字符串为空或格式不正确,返回空字符串
|
|
||||||
if len(encryptedPassword) < 10 {
|
if len(encryptedPassword) < 10 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base64 解码
|
key, err := getKey()
|
||||||
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("解码失败: %v", err)
|
return "", fmt.Errorf("获取解密密钥失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
block, err := aes.NewCipher(defaultKey)
|
// 先用新密钥尝试解密
|
||||||
if err != nil {
|
result, err := DecryptPasswordV2(encryptedPassword, key)
|
||||||
return "", fmt.Errorf("创建解密器失败: %v", err)
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 GCM 模式
|
// 新密钥失败,尝试旧密钥(兼容已迁移的旧数据)
|
||||||
aesGCM, err := cipher.NewGCM(block)
|
result, err = DecryptPasswordV2(encryptedPassword, legacyKey)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return "", fmt.Errorf("创建 GCM 失败: %v", err)
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取 nonce
|
// 两种密钥都失败
|
||||||
nonceSize := aesGCM.NonceSize()
|
return "", fmt.Errorf("解密失败: %v", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"u-desk/internal/common"
|
"u-desk/internal/common"
|
||||||
"u-desk/internal/crypto"
|
"u-desk/internal/crypto"
|
||||||
@@ -16,6 +17,13 @@ type ConnectionPool struct {
|
|||||||
mysqlClients map[uint]*MySQLClient
|
mysqlClients map[uint]*MySQLClient
|
||||||
redisClients map[uint]*RedisClient
|
redisClients map[uint]*RedisClient
|
||||||
mongoClients map[uint]*MongoClient
|
mongoClients map[uint]*MongoClient
|
||||||
|
|
||||||
|
// 新增:MySQL 真连接池
|
||||||
|
mysqlPool *MySQLConnectionPool
|
||||||
|
|
||||||
|
// 查询优化器
|
||||||
|
queryOptimizer *QueryOptimizer
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,20 +35,74 @@ var (
|
|||||||
// GetPool 获取全局连接池实例
|
// GetPool 获取全局连接池实例
|
||||||
func GetPool() *ConnectionPool {
|
func GetPool() *ConnectionPool {
|
||||||
poolOnce.Do(func() {
|
poolOnce.Do(func() {
|
||||||
|
// 创建 MySQL 连接池
|
||||||
|
poolConfig := DefaultPoolConfig()
|
||||||
|
|
||||||
|
mysqlPool := NewMySQLConnectionPool(poolConfig)
|
||||||
|
// 启动维护协程
|
||||||
|
mysqlPool.StartMaintenance()
|
||||||
|
|
||||||
|
// 创建查询优化器
|
||||||
|
queryOptimizer := NewQueryOptimizer(nil)
|
||||||
|
|
||||||
globalPool = &ConnectionPool{
|
globalPool = &ConnectionPool{
|
||||||
mysqlClients: make(map[uint]*MySQLClient),
|
mysqlClients: make(map[uint]*MySQLClient),
|
||||||
redisClients: make(map[uint]*RedisClient),
|
redisClients: make(map[uint]*RedisClient),
|
||||||
mongoClients: make(map[uint]*MongoClient),
|
mongoClients: make(map[uint]*MongoClient),
|
||||||
|
mysqlPool: mysqlPool,
|
||||||
|
queryOptimizer: queryOptimizer,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalPool
|
return globalPool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMySQLClient 获取或创建 MySQL 客户端
|
// PooledClient 带释放语义的客户端包装
|
||||||
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) (*MySQLClient, error) {
|
type PooledClient struct {
|
||||||
|
Client *MySQLClient
|
||||||
|
entry *MySQLPoolEntry
|
||||||
|
pool *MySQLConnectionPool
|
||||||
|
fromPool bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release 释放连接回连接池
|
||||||
|
func (pc *PooledClient) Release() {
|
||||||
|
if pc.fromPool && pc.pool != nil && pc.entry != nil {
|
||||||
|
pc.pool.Release(pc.entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMySQLClient 获取或创建 MySQL 客户端(使用连接池)
|
||||||
|
func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) *PooledClient {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// 尝试从连接池获取连接
|
||||||
|
if p.mysqlPool != nil {
|
||||||
|
entry, err := p.mysqlPool.Acquire(conn)
|
||||||
|
if err == nil {
|
||||||
|
return &PooledClient{Client: entry.Client, entry: entry, pool: p.mysqlPool, fromPool: true}
|
||||||
|
}
|
||||||
|
p.logPoolError("Acquire failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到原有逻辑
|
||||||
|
client, err := p.getMySQLClientLegacy(conn)
|
||||||
|
if err != nil {
|
||||||
|
return &PooledClient{Client: nil, fromPool: false}
|
||||||
|
}
|
||||||
|
return &PooledClient{Client: client, fromPool: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logPoolError 记录连接池错误
|
||||||
|
func (p *ConnectionPool) logPoolError(operation string, err error) {
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
// 通过查询优化器记录错误
|
||||||
|
p.queryOptimizer.RecordPoolError(operation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMySQLClientLegacy 原有的 MySQL 客户端获取逻辑(向后兼容)
|
||||||
|
func (p *ConnectionPool) getMySQLClientLegacy(conn *models.DbConnection) (*MySQLClient, error) {
|
||||||
// 检查是否已存在
|
// 检查是否已存在
|
||||||
if client, ok := p.mysqlClients[conn.ID]; ok {
|
if client, ok := p.mysqlClients[conn.ID]; ok {
|
||||||
// 测试连接是否有效
|
// 测试连接是否有效
|
||||||
@@ -76,6 +138,101 @@ func (p *ConnectionPool) GetMySQLClient(conn *models.DbConnection) (*MySQLClient
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMySQLPoolStats 获取 MySQL 连接池统计信息
|
||||||
|
func (p *ConnectionPool) GetMySQLPoolStats() *PoolStats {
|
||||||
|
if p.mysqlPool != nil {
|
||||||
|
stats := p.mysqlPool.Stats()
|
||||||
|
return &stats
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeQuery 优化查询执行
|
||||||
|
func (p *ConnectionPool) OptimizeQuery(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (*QueryResult, time.Duration, error) {
|
||||||
|
pc := p.GetMySQLClient(conn)
|
||||||
|
if pc.Client == nil {
|
||||||
|
return nil, 0, fmt.Errorf("获取 MySQL 连接失败")
|
||||||
|
}
|
||||||
|
defer pc.Release()
|
||||||
|
|
||||||
|
// 使用查询优化器
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
return p.queryOptimizer.OptimizeQuery(ctx, pc.Client, sqlStr, database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到普通查询
|
||||||
|
startTime := time.Now()
|
||||||
|
result, err := pc.Client.ExecuteQuery(ctx, sqlStr, database)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
return result, duration, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteOptimizedUpdate 执行优化的更新操作
|
||||||
|
func (p *ConnectionPool) ExecuteOptimizedUpdate(ctx context.Context, conn *models.DbConnection, sqlStr string, database string) (int64, time.Duration, error) {
|
||||||
|
pc := p.GetMySQLClient(conn)
|
||||||
|
if pc.Client == nil {
|
||||||
|
return 0, 0, fmt.Errorf("获取 MySQL 连接失败")
|
||||||
|
}
|
||||||
|
defer pc.Release()
|
||||||
|
|
||||||
|
// 使用查询优化器
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
return p.queryOptimizer.ExecuteOptimizedUpdate(ctx, pc.Client, sqlStr, database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到普通更新
|
||||||
|
startTime := time.Now()
|
||||||
|
result, err := pc.Client.ExecuteUpdate(ctx, sqlStr, database)
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
return result, duration, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueryStats 获取查询统计信息
|
||||||
|
func (p *ConnectionPool) GetQueryStats() QueryStats {
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
return p.queryOptimizer.GetQueryStats()
|
||||||
|
}
|
||||||
|
return QueryStats{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSlowQueries 获取慢查询记录
|
||||||
|
func (p *ConnectionPool) GetSlowQueries(limit int) []SlowQuery {
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
return p.queryOptimizer.GetSlowQueries(limit)
|
||||||
|
}
|
||||||
|
return []SlowQuery{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIndexSuggestions 获取索引建议
|
||||||
|
func (p *ConnectionPool) GetIndexSuggestions(table string) []IndexSuggestion {
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
return p.queryOptimizer.GetIndexSuggestions(table)
|
||||||
|
}
|
||||||
|
return []IndexSuggestion{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateIndexSuggestions 为表生成索引建议
|
||||||
|
func (p *ConnectionPool) GenerateIndexSuggestions(ctx context.Context, conn *models.DbConnection, database, table string) error {
|
||||||
|
pc := p.GetMySQLClient(conn)
|
||||||
|
if pc.Client == nil {
|
||||||
|
return fmt.Errorf("获取 MySQL 连接失败")
|
||||||
|
}
|
||||||
|
defer pc.Release()
|
||||||
|
|
||||||
|
// 使用查询优化器
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
return p.queryOptimizer.GenerateIndexSuggestions(ctx, pc.Client, database, table)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearQueryCache 清空查询缓存
|
||||||
|
func (p *ConnectionPool) ClearQueryCache() {
|
||||||
|
if p.queryOptimizer != nil {
|
||||||
|
p.queryOptimizer.ClearCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetRedisClient 获取或创建 Redis 客户端
|
// GetRedisClient 获取或创建 Redis 客户端
|
||||||
func (p *ConnectionPool) GetRedisClient(conn *models.DbConnection) (*RedisClient, error) {
|
func (p *ConnectionPool) GetRedisClient(conn *models.DbConnection) (*RedisClient, error) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
|
|||||||
679
internal/dbclient/pool_config.go
Normal file
679
internal/dbclient/pool_config.go
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
package dbclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"u-desk/internal/crypto"
|
||||||
|
"u-desk/internal/storage/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoolConfig 连接池配置
|
||||||
|
type PoolConfig struct {
|
||||||
|
// 最大打开连接数(硬上限)
|
||||||
|
MaxOpenConns int
|
||||||
|
// 最大空闲连接数(超过此数量的空闲连接会被关闭)
|
||||||
|
MaxIdleConns int
|
||||||
|
// 连接最大生命周期(超过此时间的连接会被关闭)
|
||||||
|
ConnMaxLifetime time.Duration
|
||||||
|
// 连接最大空闲时间(超过此时间未使用的连接会被关闭)
|
||||||
|
ConnMaxIdleTime time.Duration
|
||||||
|
// 最小空闲连接数(保持此数量的空闲连接以快速响应)
|
||||||
|
MinIdleConns int
|
||||||
|
// 连接超时时间(建立连接的最长时间)
|
||||||
|
ConnTimeout time.Duration
|
||||||
|
// 健康检查间隔(定期 Ping 连接检查有效性)
|
||||||
|
HealthCheckInterval time.Duration
|
||||||
|
// 是否启用连接预热(启动时建立最小连接)
|
||||||
|
EnableWarmup bool
|
||||||
|
// 是否启用慢连接日志(记录建立时间超过阈值的连接)
|
||||||
|
EnableSlowConnLog bool
|
||||||
|
// 慢连接阈值(超过此时间记录为慢连接)
|
||||||
|
SlowConnThreshold time.Duration
|
||||||
|
// 连接池最大容量(防止资源耗尽)
|
||||||
|
MaxPoolCapacity int
|
||||||
|
|
||||||
|
// 动态连接池配置
|
||||||
|
EnableDynamicScaling bool // 是否启用动态连接池调整
|
||||||
|
DynamicScaleFactor float64 // 动态调整因子(0.5-2.0)
|
||||||
|
ScaleUpThreshold float64 // 扩容阈值(0-1.0,当使用率超过此值时扩容)
|
||||||
|
ScaleDownThreshold float64 // 缩容阈值(0-1.0,当使用率低于此值时缩容)
|
||||||
|
MinScaleUpInterval time.Duration // 最小扩容间隔(防止频繁调整)
|
||||||
|
MinScaleDownInterval time.Duration // 最小缩容间隔
|
||||||
|
MaxIdleTimeForScale time.Duration // 用于动态调整的最大空闲时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPoolConfig 返回默认连接池配置
|
||||||
|
func DefaultPoolConfig() *PoolConfig {
|
||||||
|
return &PoolConfig{
|
||||||
|
MaxOpenConns: 50, // 最大50个连接(提高并发)
|
||||||
|
MaxIdleConns: 20, // 最大20个空闲(提高响应速度)
|
||||||
|
ConnMaxLifetime: 60 * time.Minute, // 连接最长60分钟(延长连接生命周期)
|
||||||
|
ConnMaxIdleTime: 15 * time.Minute, // 空闲15分钟关闭(更长的空闲时间)
|
||||||
|
MinIdleConns: 5, // 保持5个最小空闲(更好的响应性能)
|
||||||
|
ConnTimeout: 3 * time.Second, // 连接超时3秒(更快失败)
|
||||||
|
HealthCheckInterval: 20 * time.Second, // 20秒健康检查一次(更频繁的健康检查)
|
||||||
|
EnableWarmup: true, // 启用预热
|
||||||
|
EnableSlowConnLog: true, // 启用慢连接日志
|
||||||
|
SlowConnThreshold: 200 * time.Millisecond, // 超过200ms算慢连接(更严格的性能要求)
|
||||||
|
MaxPoolCapacity: 100, // 连接池最大容量(支持更高并发)
|
||||||
|
|
||||||
|
// 动态连接池配置(更智能的调整策略)
|
||||||
|
EnableDynamicScaling: true, // 启用动态调整
|
||||||
|
DynamicScaleFactor: 1.8, // 调整因子1.8倍(更激进的扩容)
|
||||||
|
ScaleUpThreshold: 0.7, // 使用率超过70%扩容(更早扩容)
|
||||||
|
ScaleDownThreshold: 0.4, // 使用率低于40%缩容(避免频繁调整)
|
||||||
|
MinScaleUpInterval: 1 * time.Minute, // 最小扩容间隔1分钟(更快的响应)
|
||||||
|
MinScaleDownInterval: 3 * time.Minute, // 最小缩容间隔3分钟(稳定缩容)
|
||||||
|
MaxIdleTimeForScale: 20 * time.Minute, // 用于调整的最大空闲时间
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySQLPoolEntry MySQL 连接池条目
|
||||||
|
type MySQLPoolEntry struct {
|
||||||
|
Client *MySQLClient
|
||||||
|
LastUsed time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
InUse bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcquireResult 连接获取结果
|
||||||
|
type AcquireResult struct {
|
||||||
|
Entry *MySQLPoolEntry
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseResult 连接释放结果
|
||||||
|
type ReleaseResult struct {
|
||||||
|
Success bool
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 连接池统计信息
|
||||||
|
type PoolStats struct {
|
||||||
|
TotalConns int // 总连接数
|
||||||
|
ActiveConns int // 使用中的连接数
|
||||||
|
IdleConns int // 空闲连接数
|
||||||
|
WaitCount int64 // 等待连接的次数
|
||||||
|
WaitDuration time.Duration // 总等待时间
|
||||||
|
SlowConnCount int64 // 慢连接数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySQLConnectionPool MySQL 连接池(真正的连接池)
|
||||||
|
type MySQLConnectionPool struct {
|
||||||
|
config *PoolConfig
|
||||||
|
configHash string // 配置哈希,用于检测配置变更
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries []*MySQLPoolEntry // 连接池条目
|
||||||
|
connMap map[uint]*MySQLClient // 连接ID -> 客户端映射(兼容现有代码)
|
||||||
|
stats PoolStats
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// 动态调整相关
|
||||||
|
lastScaleUpTime time.Time // 上次扩容时间
|
||||||
|
lastScaleDownTime time.Time // 上次缩容时间
|
||||||
|
currentTargetSize int // 当前目标连接数
|
||||||
|
usageHistory []float64 // 使用率历史记录(用于智能调整)
|
||||||
|
adaptiveWeights map[uint]float64 // 连接权重(基于性能表现)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMySQLConnectionPool 创建新的 MySQL 连接池
|
||||||
|
func NewMySQLConnectionPool(config *PoolConfig) *MySQLConnectionPool {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultPoolConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := &MySQLConnectionPool{
|
||||||
|
config: config,
|
||||||
|
entries: make([]*MySQLPoolEntry, 0, config.MaxPoolCapacity),
|
||||||
|
connMap: make(map[uint]*MySQLClient),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
currentTargetSize: config.MinIdleConns,
|
||||||
|
usageHistory: make([]float64, 0, 100), // 保留最近100个使用率记录
|
||||||
|
adaptiveWeights: make(map[uint]float64),
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire 获取一个连接(阻塞等待直到有可用连接)
|
||||||
|
func (p *MySQLConnectionPool) Acquire(conn *models.DbConnection) (*MySQLPoolEntry, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// 尝试获取最优连接(启用动态调整时)
|
||||||
|
if p.config.EnableDynamicScaling {
|
||||||
|
if entry, err := p.getOptimalConnection(); err == nil {
|
||||||
|
p.updateWaitStats(startTime)
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到标准逻辑 - 查找空闲连接
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
if !entry.InUse {
|
||||||
|
entry.InUse = true
|
||||||
|
entry.LastUsed = time.Now()
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
// 更新统计
|
||||||
|
p.updateWaitStats(startTime)
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有可用连接,创建新连接
|
||||||
|
if len(p.entries) >= p.config.MaxOpenConns {
|
||||||
|
// 已达到最大连接数,等待
|
||||||
|
return p.waitForAvailableConnection(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新连接(使用传入的连接配置)
|
||||||
|
newEntry, err := p.createNewEntry(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建连接失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.entries = append(p.entries, newEntry)
|
||||||
|
p.updateStats()
|
||||||
|
p.updateWaitStats(startTime)
|
||||||
|
|
||||||
|
return newEntry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release 释放连接回池中
|
||||||
|
func (p *MySQLConnectionPool) Release(entry *MySQLPoolEntry) error {
|
||||||
|
if entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
entry.mu.Lock()
|
||||||
|
entry.InUse = false
|
||||||
|
entry.LastUsed = time.Now()
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
p.updateStats()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭连接池
|
||||||
|
func (p *MySQLConnectionPool) Close() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// 发送停止信号
|
||||||
|
close(p.stopCh)
|
||||||
|
|
||||||
|
// 等待所有 goroutine 完成
|
||||||
|
p.wg.Wait()
|
||||||
|
|
||||||
|
// 关闭所有连接
|
||||||
|
var lastErr error
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
if err := entry.Client.Close(); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
entry.InUse = false
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
p.entries = make([]*MySQLPoolEntry, 0, p.config.MaxPoolCapacity)
|
||||||
|
p.connMap = make(map[uint]*MySQLClient)
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 获取连接池统计信息
|
||||||
|
func (p *MySQLConnectionPool) Stats() PoolStats {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return p.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupIdleConnections 清理空闲连接
|
||||||
|
func (p *MySQLConnectionPool) cleanupIdleConnections() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
keepEntries := make([]*MySQLPoolEntry, 0, len(p.entries))
|
||||||
|
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
isIdle := !entry.InUse
|
||||||
|
idleDuration := now.Sub(entry.LastUsed)
|
||||||
|
entry.mu.Unlock()
|
||||||
|
|
||||||
|
// 保留条件:正在使用 或 空闲时间未超过阈值 或 数量少于最小空闲数
|
||||||
|
keep := !isIdle ||
|
||||||
|
idleDuration < p.config.ConnMaxIdleTime ||
|
||||||
|
len(keepEntries) < p.config.MinIdleConns
|
||||||
|
|
||||||
|
if keep {
|
||||||
|
keepEntries = append(keepEntries, entry)
|
||||||
|
} else {
|
||||||
|
// 关闭连接
|
||||||
|
entry.Client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.entries = keepEntries
|
||||||
|
p.updateStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthCheck 健康检查(增强版本)
|
||||||
|
func (p *MySQLConnectionPool) healthCheck() {
|
||||||
|
p.enhancedHealthCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartMaintenance 启动维护协程(清理和健康检查)
|
||||||
|
func (p *MySQLConnectionPool) StartMaintenance() {
|
||||||
|
p.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer p.wg.Done()
|
||||||
|
|
||||||
|
// 健康检查Ticker
|
||||||
|
healthTicker := time.NewTicker(p.config.HealthCheckInterval)
|
||||||
|
defer healthTicker.Stop()
|
||||||
|
|
||||||
|
// 动态调整Ticker(较短间隔)
|
||||||
|
scaleTicker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer scaleTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-healthTicker.C:
|
||||||
|
// 清理空闲连接
|
||||||
|
p.cleanupIdleConnections()
|
||||||
|
// 健康检查
|
||||||
|
p.healthCheck()
|
||||||
|
|
||||||
|
case <-scaleTicker.C:
|
||||||
|
// 动态连接池调整
|
||||||
|
if p.config.EnableDynamicScaling {
|
||||||
|
p.adaptiveScaling()
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNewEntry 创建新的连接池条目
|
||||||
|
func (p *MySQLConnectionPool) createNewEntry(conn *models.DbConnection) (*MySQLPoolEntry, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
client, err := createMySQLClient(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
|
||||||
|
// 慢连接日志
|
||||||
|
if p.config.EnableSlowConnLog && elapsed > p.config.SlowConnThreshold {
|
||||||
|
// 记录慢连接
|
||||||
|
p.mu.Lock()
|
||||||
|
p.stats.SlowConnCount++
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &MySQLPoolEntry{
|
||||||
|
Client: client,
|
||||||
|
LastUsed: time.Now(),
|
||||||
|
CreatedAt: startTime,
|
||||||
|
InUse: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForAvailableConnection 等待可用连接并获取它
|
||||||
|
func (p *MySQLConnectionPool) waitForAvailableConnection(conn *models.DbConnection) (*MySQLPoolEntry, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ErrPoolExhausted
|
||||||
|
case <-ticker.C:
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
if !entry.InUse {
|
||||||
|
entry.InUse = true
|
||||||
|
entry.LastUsed = time.Now()
|
||||||
|
entry.mu.Unlock()
|
||||||
|
p.mu.Unlock()
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateWaitStats 更新等待统计(调用方必须持有 p.mu)
|
||||||
|
func (p *MySQLConnectionPool) updateWaitStats(startTime time.Time) {
|
||||||
|
p.stats.WaitCount++
|
||||||
|
p.stats.WaitDuration += time.Since(startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateStats 更新连接池统计
|
||||||
|
func (p *MySQLConnectionPool) updateStats() {
|
||||||
|
total := len(p.entries)
|
||||||
|
active := 0
|
||||||
|
idle := 0
|
||||||
|
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
if entry.InUse {
|
||||||
|
active++
|
||||||
|
} else {
|
||||||
|
idle++
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
p.stats.TotalConns = total
|
||||||
|
p.stats.ActiveConns = active
|
||||||
|
p.stats.IdleConns = idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptiveScaling 自适应连接池调整
|
||||||
|
func (p *MySQLConnectionPool) adaptiveScaling() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// 计算当前使用率
|
||||||
|
if len(p.entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usageRate := float64(p.stats.ActiveConns) / float64(len(p.entries))
|
||||||
|
|
||||||
|
// 记录使用率历史
|
||||||
|
p.usageHistory = append(p.usageHistory, usageRate)
|
||||||
|
if len(p.usageHistory) > 100 {
|
||||||
|
p.usageHistory = p.usageHistory[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要调整
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 扩容逻辑
|
||||||
|
if usageRate >= p.config.ScaleUpThreshold {
|
||||||
|
if now.Sub(p.lastScaleUpTime) >= p.config.MinScaleUpInterval {
|
||||||
|
p.scaleUp()
|
||||||
|
p.lastScaleUpTime = now
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩容逻辑
|
||||||
|
if usageRate <= p.config.ScaleDownThreshold && len(p.entries) > p.config.MinIdleConns {
|
||||||
|
if now.Sub(p.lastScaleDownTime) >= p.config.MinScaleDownInterval {
|
||||||
|
p.scaleDown()
|
||||||
|
p.lastScaleDownTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleUp 扩容
|
||||||
|
func (p *MySQLConnectionPool) scaleUp() {
|
||||||
|
// scaleUp 仅更新目标大小,实际连接在 Acquire 时按需创建
|
||||||
|
// 移除了创建无效虚拟连接的逻辑
|
||||||
|
currentSize := len(p.entries)
|
||||||
|
scaleFactor := p.config.DynamicScaleFactor
|
||||||
|
|
||||||
|
newSize := int(float64(currentSize) * scaleFactor)
|
||||||
|
newSize = min(newSize, p.config.MaxOpenConns)
|
||||||
|
newSize = max(newSize, currentSize+1)
|
||||||
|
|
||||||
|
p.currentTargetSize = newSize
|
||||||
|
p.updateStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleDown 缩容
|
||||||
|
func (p *MySQLConnectionPool) scaleDown() {
|
||||||
|
// 计算新目标大小
|
||||||
|
currentSize := len(p.entries)
|
||||||
|
scaleFactor := 1.0 / p.config.DynamicScaleFactor
|
||||||
|
|
||||||
|
newSize := int(float64(currentSize) * scaleFactor)
|
||||||
|
newSize = max(newSize, p.config.MinIdleConns)
|
||||||
|
newSize = min(newSize, currentSize-1) // 至少减少1个连接
|
||||||
|
|
||||||
|
if newSize < currentSize {
|
||||||
|
// 关闭多余的空闲连接
|
||||||
|
p.closeIdleConnections(currentSize - newSize)
|
||||||
|
p.currentTargetSize = newSize
|
||||||
|
p.updateStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeIdleConnections 关闭指定数量的空闲连接
|
||||||
|
func (p *MySQLConnectionPool) closeIdleConnections(count int) {
|
||||||
|
// 收集空闲连接
|
||||||
|
idleEntries := make([]*MySQLPoolEntry, 0)
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
if !entry.InUse {
|
||||||
|
idleEntries = append(idleEntries, entry)
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭指定数量的空闲连接
|
||||||
|
closedEntries := make(map[*MySQLPoolEntry]bool)
|
||||||
|
for i := 0; i < min(count, len(idleEntries)); i++ {
|
||||||
|
entry := idleEntries[i]
|
||||||
|
entry.mu.Lock()
|
||||||
|
entry.Client.Close()
|
||||||
|
entry.mu.Unlock()
|
||||||
|
closedEntries[entry] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新构建连接池
|
||||||
|
remainingEntries := make([]*MySQLPoolEntry, 0, len(p.entries))
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
if closedEntries[entry] {
|
||||||
|
continue // 跳过已关闭的连接
|
||||||
|
}
|
||||||
|
remainingEntries = append(remainingEntries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.entries = remainingEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// enhancedHealthCheck 增强的健康检查
|
||||||
|
func (p *MySQLConnectionPool) enhancedHealthCheck() {
|
||||||
|
p.mu.RLock()
|
||||||
|
entriesCopy := make([]*MySQLPoolEntry, len(p.entries))
|
||||||
|
copy(entriesCopy, p.entries)
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
var healthyEntries []*MySQLPoolEntry
|
||||||
|
var performanceWeights []float64
|
||||||
|
|
||||||
|
for _, entry := range entriesCopy {
|
||||||
|
entry.mu.Lock()
|
||||||
|
isIdle := !entry.InUse
|
||||||
|
|
||||||
|
// 测试连接有效性
|
||||||
|
isHealthy := true
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
if isIdle {
|
||||||
|
// 空闲连接:简单Ping测试
|
||||||
|
if err := entry.Client.sqlDB.Ping(); err != nil {
|
||||||
|
isHealthy = false
|
||||||
|
// 关闭失效连接
|
||||||
|
entry.Client.Close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 使用中的连接:快速测试(避免影响正常查询)
|
||||||
|
func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := entry.Client.sqlDB.PingContext(ctx); err != nil {
|
||||||
|
isHealthy = false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算连接性能权重
|
||||||
|
if isHealthy {
|
||||||
|
healthyEntries = append(healthyEntries, entry)
|
||||||
|
|
||||||
|
// 基于连接性能计算权重
|
||||||
|
responseTime := time.Since(startTime).Microseconds()
|
||||||
|
weight := 1.0 / max(float64(responseTime)/1000.0, 1.0) // 转换为毫秒,避免除零
|
||||||
|
|
||||||
|
performanceWeights = append(performanceWeights, weight)
|
||||||
|
} else {
|
||||||
|
// 不健康的连接
|
||||||
|
if isIdle {
|
||||||
|
entry.Client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新连接池
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.entries = healthyEntries
|
||||||
|
|
||||||
|
// 更新自适应权重
|
||||||
|
if len(healthyEntries) > 0 {
|
||||||
|
for i := range healthyEntries {
|
||||||
|
if i < len(performanceWeights) {
|
||||||
|
p.adaptiveWeights[uint(i)] = performanceWeights[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.updateStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// warmUp 连接池预热
|
||||||
|
func (p *MySQLConnectionPool) warmUp() {
|
||||||
|
if !p.config.EnableWarmup {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
currentIdle := 0
|
||||||
|
for _, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
if !entry.InUse {
|
||||||
|
currentIdle++
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIdle := p.config.MinIdleConns
|
||||||
|
needed := targetIdle - currentIdle
|
||||||
|
|
||||||
|
// warmUp 仅记录目标大小,不在无连接配置的情况下创建无效虚拟连接
|
||||||
|
// 实际连接在 Acquire 时按需创建
|
||||||
|
_ = needed
|
||||||
|
|
||||||
|
p.updateStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOptimalConnection 获取最优连接(基于性能权重)
|
||||||
|
// 注意:调用方必须已持有 p.mu
|
||||||
|
func (p *MySQLConnectionPool) getOptimalConnection() (*MySQLPoolEntry, error) {
|
||||||
|
var bestEntry *MySQLPoolEntry
|
||||||
|
var bestWeight float64
|
||||||
|
|
||||||
|
for i, entry := range p.entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
if !entry.InUse {
|
||||||
|
weight := 1.0 // 默认权重
|
||||||
|
if w, ok := p.adaptiveWeights[uint(i)]; ok {
|
||||||
|
weight = w
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestEntry == nil || weight > bestWeight {
|
||||||
|
bestEntry = entry
|
||||||
|
bestWeight = weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestEntry == nil {
|
||||||
|
return nil, ErrPoolExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
bestEntry.InUse = true
|
||||||
|
bestEntry.LastUsed = time.Now()
|
||||||
|
return bestEntry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMySQLClient 创建 MySQL 客户端的辅助函数
|
||||||
|
func createMySQLClient(conn *models.DbConnection) (*MySQLClient, error) {
|
||||||
|
// 解密密码
|
||||||
|
password, err := crypto.DecryptPassword(conn.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("密码解密失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &MySQLConfig{
|
||||||
|
Host: conn.Host,
|
||||||
|
Port: conn.Port,
|
||||||
|
Username: conn.Username,
|
||||||
|
Password: password,
|
||||||
|
Database: conn.Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewMySQLClient(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误定义
|
||||||
|
var (
|
||||||
|
ErrPoolExhausted = &PoolError{Message: "连接池已耗尽"}
|
||||||
|
ErrPoolClosed = &PoolError{Message: "连接池已关闭"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoolError 连接池错误
|
||||||
|
type PoolError struct {
|
||||||
|
Message string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PoolError) Error() string {
|
||||||
|
if e.Err != nil {
|
||||||
|
return e.Message + ": " + e.Err.Error()
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,18 +2,82 @@ package filesystem
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 预编译正则表达式(避免每次调用重复编译)
|
||||||
|
var (
|
||||||
|
// CSS 相关
|
||||||
|
cssImportRegex = regexp.MustCompile(`@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?\s*;`)
|
||||||
|
cssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||||||
|
|
||||||
|
// HTML 标签
|
||||||
|
htmlLinkTagRegex = regexp.MustCompile(`<link\s+([^>]*)>`)
|
||||||
|
htmlScriptTagRegex = regexp.MustCompile(`<script\s+([^>]*)>`)
|
||||||
|
htmlImgTagRegex = regexp.MustCompile(`<img\s+([^>]*)>`)
|
||||||
|
htmlVideoTagRegex = regexp.MustCompile(`<video\s+([^>]*)>`)
|
||||||
|
htmlSourceTagRegex = regexp.MustCompile(`<source\s+([^>]*)>`)
|
||||||
|
htmlAudioTagRegex = regexp.MustCompile(`<audio\s+([^>]*)>`)
|
||||||
|
htmlIframeTagRegex = regexp.MustCompile(`<iframe\s+([^>]*)>`)
|
||||||
|
htmlObjectTagRegex = regexp.MustCompile(`<object\s+([^>]*)>`)
|
||||||
|
htmlEmbedTagRegex = regexp.MustCompile(`<embed\s+([^>]*)>`)
|
||||||
|
|
||||||
|
// HTML 属性
|
||||||
|
htmlSrcsetRegex = regexp.MustCompile(`srcset=["']([^"']+)["']`)
|
||||||
|
htmlStyleAttrRegex = regexp.MustCompile(`style=["']([^"']+)["']`)
|
||||||
|
htmlStyleTagRegex = regexp.MustCompile(`<style([^>]*)>([\s\S]*?)</style>`)
|
||||||
|
|
||||||
|
// ES6 模块语句
|
||||||
|
es6ImportFromRegex = regexp.MustCompile(`import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']`)
|
||||||
|
es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`)
|
||||||
|
es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`)
|
||||||
|
|
||||||
|
// HTML 预览路径修复
|
||||||
|
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||||
|
var attrRegexCache sync.Map // map[string]*regexp.Regexp
|
||||||
|
|
||||||
|
// 路径校验 sentinel error(用 errors.Is 匹配,不依赖字符串)
|
||||||
|
var (
|
||||||
|
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
|
||||||
|
ErrPathTraversal = fmt.Errorf("path traversal detected")
|
||||||
|
ErrPathUnsafe = fmt.Errorf("unsafe path")
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateFilePath 校验文件路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||||
|
// 返回清理后的绝对路径,或 sentinel error
|
||||||
|
func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
||||||
|
decodedPath, err := url.QueryUnescape(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrPathInvalidEncoding
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(decodedPath, "..") {
|
||||||
|
return "", ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
||||||
|
filePath = filepath.Clean(filePath)
|
||||||
|
|
||||||
|
if !isSafePath(filePath) {
|
||||||
|
return "", ErrPathUnsafe
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
|
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
|
||||||
type LocalFileServer struct {
|
type LocalFileServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
@@ -36,9 +100,12 @@ func StartLocalFileServer() (string, error) {
|
|||||||
// 注册 /localfs/ 路由
|
// 注册 /localfs/ 路由
|
||||||
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
mux.HandleFunc("/localfs/", handleLocalFileRequest)
|
||||||
|
|
||||||
|
// 注册 HTML 预览专用路由
|
||||||
|
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
|
||||||
|
|
||||||
// 创建服务器(固定端口)
|
// 创建服务器(固定端口)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: "localhost:18765",
|
Addr: "localhost:8073",
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +120,7 @@ func StartLocalFileServer() (string, error) {
|
|||||||
|
|
||||||
localFileServer = &LocalFileServer{
|
localFileServer = &LocalFileServer{
|
||||||
server: server,
|
server: server,
|
||||||
addr: "localhost:18765",
|
addr: "localhost:8073",
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
||||||
@@ -67,6 +134,17 @@ func StartLocalFileServer() (string, error) {
|
|||||||
|
|
||||||
// handleLocalFileRequest 处理本地文件请求
|
// handleLocalFileRequest 处理本地文件请求
|
||||||
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// CORS 头:允许所有源访问(因为这是本地文件服务器)
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
|
||||||
|
// 处理 OPTIONS 预检请求
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 只处理 GET 请求
|
// 只处理 GET 请求
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -77,7 +155,6 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
||||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
||||||
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
|
|
||||||
|
|
||||||
if pathPart == "" || pathPart == r.URL.Path {
|
if pathPart == "" || pathPart == r.URL.Path {
|
||||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||||
@@ -85,35 +162,25 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔒 修复:先进行URL解码,防止路径遍历攻击
|
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||||
decodedPath, err := url.QueryUnescape(pathPart)
|
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[LocalFileHandler] URL解码失败: %v", err)
|
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
|
||||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
switch {
|
||||||
|
case errors.Is(err, ErrPathInvalidEncoding):
|
||||||
|
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||||
|
case errors.Is(err, ErrPathTraversal):
|
||||||
|
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||||
|
case errors.Is(err, ErrPathUnsafe):
|
||||||
|
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
|
|
||||||
|
|
||||||
// 🔒 修复:在路径转换前检查是否包含危险字符
|
|
||||||
if strings.Contains(decodedPath, "..") {
|
|
||||||
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
|
|
||||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路径转换(统一使用反斜杠)
|
|
||||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
|
||||||
filePath = filepath.Clean(filePath)
|
|
||||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||||
|
|
||||||
// 安全检查
|
// 🔒 文件类型白名单检查
|
||||||
if !isSafePath(filePath) {
|
|
||||||
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
|
|
||||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔒 修复:文件类型白名单检查
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
if !isAllowedFileType(ext) {
|
if !isAllowedFileType(ext) {
|
||||||
log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext)
|
log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext)
|
||||||
@@ -142,6 +209,50 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSS 文件特殊处理:转换内容中的相对路径
|
||||||
|
if ext == ".css" {
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[LocalFileHandler] 读取CSS文件失败: %v", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to read CSS file: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 CSS 文件所在目录作为 basePath
|
||||||
|
basePath := filepath.Dir(filePath)
|
||||||
|
|
||||||
|
// 转换内容中的相对路径
|
||||||
|
transformedContent := transformCssContent(string(content), basePath)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
w.Write([]byte(transformedContent))
|
||||||
|
log.Printf("[LocalFileHandler] CSS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript 文件特殊处理:转换动态 import 路径
|
||||||
|
if ext == ".js" || ext == ".mjs" {
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[LocalFileHandler] 读取JS文件失败: %v", err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to read JS file: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 JS 文件所在目录作为 basePath
|
||||||
|
basePath := filepath.Dir(filePath)
|
||||||
|
|
||||||
|
// 转换动态 import 路径
|
||||||
|
transformedContent := transformJsDynamicImports(string(content), basePath)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
w.Write([]byte(transformedContent))
|
||||||
|
log.Printf("[LocalFileHandler] JS文件转换完成: %s (原始=%d, 转换后=%d)", filePath, len(content), len(transformedContent))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 打开文件
|
// 打开文件
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -210,54 +321,7 @@ func getContentType(ext string) string {
|
|||||||
return defaultFileTypeManager.GetMIMEType(ext)
|
return defaultFileTypeManager.GetMIMEType(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadFileAsBase64 读取文件并返回 base64 编码的字符串
|
|
||||||
// 用于读取从 ZIP 提取的临时图片文件
|
|
||||||
func ReadFileAsBase64(filePath string) (string, error) {
|
|
||||||
log.Printf("[ReadFileAsBase64] 读取文件: %s", filePath)
|
|
||||||
|
|
||||||
if !isSafePath(filePath) {
|
|
||||||
return "", fmt.Errorf("路径不安全")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查文件是否存在
|
|
||||||
fileInfo, err := os.Stat(filePath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return "", fmt.Errorf("文件不存在: %s", filePath)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("无法访问文件: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[ReadFileAsBase64] 文件大小: %d bytes", fileInfo.Size())
|
|
||||||
|
|
||||||
// 读取文件
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("读取文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编码为 base64
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
|
||||||
log.Printf("[ReadFileAsBase64] 编码成功: 原始=%d, base64=%d", len(data), len(encoded))
|
|
||||||
|
|
||||||
// 获取文件扩展名并确定 MIME 类型
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
|
||||||
mimeType := getContentType(ext)
|
|
||||||
|
|
||||||
// 返回 data URI 格式: data:image/png;base64,iVBORw0KG...
|
|
||||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLocalFile 处理 /localfs/ 路由的 HTTP 请求
|
|
||||||
// 前端可以请求 http://localhost:18765/localfs/C:/path/to/image.jpg
|
|
||||||
// 注意:此函数与 ServeHTTP 功能重复,建议统一使用 ServeHTTP
|
|
||||||
func HandleLocalFile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
handler := NewLocalFileHandler()
|
|
||||||
handler.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAllowedFileType 检查文件类型是否在白名单中
|
// isAllowedFileType 检查文件类型是否在白名单中
|
||||||
// 使用统一的文件类型管理器
|
|
||||||
func isAllowedFileType(ext string) bool {
|
func isAllowedFileType(ext string) bool {
|
||||||
return defaultFileTypeManager.IsAllowed(ext)
|
return defaultFileTypeManager.IsAllowed(ext)
|
||||||
}
|
}
|
||||||
@@ -293,3 +357,477 @@ func ShutdownLocalFileServer() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transformCssContent 转换 CSS 内容中的相对路径
|
||||||
|
// basePath: CSS 文件所在目录的绝对路径
|
||||||
|
func transformCssContent(content string, basePath string) string {
|
||||||
|
// 1. 处理 @import 语句
|
||||||
|
content = cssImportRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
|
submatches := cssImportRegex.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
relativePath := submatches[1]
|
||||||
|
|
||||||
|
// 跳过绝对 URL 和数据 URI
|
||||||
|
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
absolutePath := resolveCssRelativePath(basePath, relativePath)
|
||||||
|
if absolutePath == "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`@import url("%s");`, toLocalServerUrl(absolutePath))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 处理 url() 语句
|
||||||
|
content = cssUrlRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
|
submatches := cssUrlRegex.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
relativePath := strings.TrimSpace(submatches[1])
|
||||||
|
|
||||||
|
// 跳过绝对 URL、数据 URI 和绝对路径
|
||||||
|
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") || strings.HasPrefix(relativePath, "/") || relativePath == "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
absolutePath := resolveCssRelativePath(basePath, relativePath)
|
||||||
|
if absolutePath == "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`url("%s")`, toLocalServerUrl(absolutePath))
|
||||||
|
})
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCssRelativePath 解析 CSS 中的相对路径为绝对路径
|
||||||
|
func resolveCssRelativePath(basePath, relativePath string) string {
|
||||||
|
// 清理路径
|
||||||
|
relativePath = strings.TrimSpace(relativePath)
|
||||||
|
if relativePath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 ./ 前缀
|
||||||
|
relativePath = strings.TrimPrefix(relativePath, "./")
|
||||||
|
|
||||||
|
// 使用 filepath.Join 处理 ../ 等
|
||||||
|
// 注意:需要先将 / 转换为 \ 以便 Windows 路径正确处理
|
||||||
|
relativePath = strings.ReplaceAll(relativePath, "/", string(filepath.Separator))
|
||||||
|
absolutePath := filepath.Join(basePath, relativePath)
|
||||||
|
|
||||||
|
// 清理路径并转换回 /
|
||||||
|
absolutePath = filepath.Clean(absolutePath)
|
||||||
|
absolutePath = strings.ReplaceAll(absolutePath, "\\", "/")
|
||||||
|
|
||||||
|
return absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLocalServerUrl 将绝对路径转换为 /localfs/ URL(带 URL 编码)
|
||||||
|
func toLocalServerUrl(absolutePath string) string {
|
||||||
|
// 确保路径使用 /
|
||||||
|
absolutePath = strings.ReplaceAll(absolutePath, "\\", "/")
|
||||||
|
// 对路径进行 URL 编码(分段编码,保留 /)
|
||||||
|
parts := strings.Split(absolutePath, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
// Windows 驱动器字母(如 E:)需要特殊处理,冒号必须编码
|
||||||
|
if len(part) == 2 && part[1] == ':' {
|
||||||
|
// 将 "E:" 转换为 "E%3A"
|
||||||
|
parts[i] = string(part[0]) + "%3A"
|
||||||
|
} else {
|
||||||
|
parts[i] = url.PathEscape(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/localfs/" + strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAbsoluteURL 检查是否为绝对 URL(http://, https://, //)
|
||||||
|
func isAbsoluteURL(path string) bool {
|
||||||
|
path = strings.ToLower(path)
|
||||||
|
return strings.HasPrefix(path, "http://") ||
|
||||||
|
strings.HasPrefix(path, "https://") ||
|
||||||
|
strings.HasPrefix(path, "//")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHtmlPreviewRequest 处理 HTML 预览请求
|
||||||
|
// 参数:
|
||||||
|
// - path: HTML 文件绝对路径(URL 编码)
|
||||||
|
// - theme: 主题(light / dark)
|
||||||
|
func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// CORS
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
|
||||||
|
// 处理 OPTIONS 预检请求
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只处理 GET 请求
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析参数
|
||||||
|
rawPath := r.URL.Query().Get("path")
|
||||||
|
theme := r.URL.Query().Get("theme")
|
||||||
|
if theme == "" {
|
||||||
|
theme = "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||||
|
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrPathInvalidEncoding):
|
||||||
|
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||||
|
case errors.Is(err, ErrPathTraversal):
|
||||||
|
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||||
|
case errors.Is(err, ErrPathUnsafe):
|
||||||
|
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||||
|
default:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||||
|
|
||||||
|
// 读取文件
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HtmlPreview] 读取文件失败: %v", err)
|
||||||
|
http.Error(w, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件所在目录(用于解析相对路径)
|
||||||
|
baseDir := filepath.Dir(filePath)
|
||||||
|
|
||||||
|
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
|
||||||
|
processedContent := transformHtmlResourcePaths(string(content), baseDir)
|
||||||
|
|
||||||
|
// 修复 JS 中基于 location.pathname 的相对路径计算
|
||||||
|
// 预览模式下 location.pathname = "/localfs/html-preview",与实际文件路径不一致
|
||||||
|
// ⚠️ 会替换所有出现位置(含JS字符串内),HTML预览场景下可接受
|
||||||
|
correctPathname := `"/localfs/` + strings.ReplaceAll(baseDir, "\\", "/") + `/`
|
||||||
|
processedContent = locationPathRegex.ReplaceAllString(processedContent, correctPathname)
|
||||||
|
|
||||||
|
// 注入链接点击拦截脚本
|
||||||
|
finalContent := injectLinkInterceptor(processedContent)
|
||||||
|
|
||||||
|
// 返回处理后的 HTML
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Write([]byte(finalContent))
|
||||||
|
|
||||||
|
log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL
|
||||||
|
func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
|
||||||
|
if baseDir == "" {
|
||||||
|
return htmlContent
|
||||||
|
}
|
||||||
|
|
||||||
|
result := htmlContent
|
||||||
|
|
||||||
|
// 1. 处理 HTML 标签资源
|
||||||
|
tagConfigs := []struct {
|
||||||
|
pattern *regexp.Regexp
|
||||||
|
attr string
|
||||||
|
}{
|
||||||
|
{htmlLinkTagRegex, "href"},
|
||||||
|
{htmlScriptTagRegex, "src"},
|
||||||
|
{htmlImgTagRegex, "src"},
|
||||||
|
{htmlVideoTagRegex, "src"},
|
||||||
|
{htmlVideoTagRegex, "poster"},
|
||||||
|
{htmlSourceTagRegex, "src"},
|
||||||
|
{htmlAudioTagRegex, "src"},
|
||||||
|
{htmlIframeTagRegex, "src"},
|
||||||
|
{htmlObjectTagRegex, "data"},
|
||||||
|
{htmlEmbedTagRegex, "src"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, config := range tagConfigs {
|
||||||
|
result = replaceHtmlTagAttribute(result, config.pattern, config.attr, baseDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 处理 srcset 属性
|
||||||
|
result = replaceHtmlSrcset(result, baseDir)
|
||||||
|
|
||||||
|
// 3. 处理内联 style 属性中的 url()
|
||||||
|
result = replaceHtmlInlineStyleUrls(result, baseDir)
|
||||||
|
|
||||||
|
// 4. 处理 <style> 标签内的内容
|
||||||
|
result = replaceHtmlStyleTagContent(result, baseDir)
|
||||||
|
|
||||||
|
// 5. 处理 ES6 模块语句(使用预编译正则)
|
||||||
|
result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
submatches := es6ImportFromRegex.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
modulePath := submatches[2]
|
||||||
|
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`import %s from "%s"`, submatches[1], resolveHtmlPathToUrl(baseDir, modulePath))
|
||||||
|
})
|
||||||
|
|
||||||
|
result = es6DynamicImport.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
submatches := es6DynamicImport.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
modulePath := submatches[1]
|
||||||
|
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceHtmlTagAttribute 替换 HTML 标签中的属性路径
|
||||||
|
func replaceHtmlTagAttribute(html string, pattern *regexp.Regexp, attrName string, baseDir string) string {
|
||||||
|
return pattern.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
// 提取属性值(使用缓存的正则)
|
||||||
|
var attrRegex *regexp.Regexp
|
||||||
|
if v, ok := attrRegexCache.Load(attrName); ok {
|
||||||
|
attrRegex = v.(*regexp.Regexp)
|
||||||
|
} else {
|
||||||
|
attrRegex = regexp.MustCompile(fmt.Sprintf(`%s=["']([^"']+)["']`, attrName))
|
||||||
|
attrRegexCache.Store(attrName, attrRegex)
|
||||||
|
}
|
||||||
|
attrMatch := attrRegex.FindStringSubmatch(match)
|
||||||
|
if attrMatch == nil {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
relativePath := attrMatch[1]
|
||||||
|
if isAbsoluteURL(relativePath) || strings.HasPrefix(relativePath, "data:") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
newUrl := resolveHtmlPathToUrl(baseDir, relativePath)
|
||||||
|
return strings.Replace(match, attrMatch[0], fmt.Sprintf(`%s="%s"`, attrName, newUrl), 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceHtmlSrcset 处理 srcset 属性
|
||||||
|
func replaceHtmlSrcset(html string, baseDir string) string {
|
||||||
|
return htmlSrcsetRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
submatches := htmlSrcsetRegex.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
srcset := submatches[1]
|
||||||
|
var newItems []string
|
||||||
|
for _, item := range strings.Split(srcset, ",") {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(item))
|
||||||
|
if len(parts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := parts[0]
|
||||||
|
descriptor := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
descriptor = " " + strings.Join(parts[1:], " ")
|
||||||
|
}
|
||||||
|
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||||
|
newItems = append(newItems, item)
|
||||||
|
} else {
|
||||||
|
newItems = append(newItems, resolveHtmlPathToUrl(baseDir, url)+descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`srcset="%s"`, strings.Join(newItems, ", "))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceHtmlInlineStyleUrls 处理内联 style 属性中的 url()
|
||||||
|
func replaceHtmlInlineStyleUrls(html string, baseDir string) string {
|
||||||
|
return htmlStyleAttrRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
submatches := htmlStyleAttrRegex.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
styleContent := submatches[1]
|
||||||
|
newStyle := cssUrlRegex.ReplaceAllStringFunc(styleContent, func(urlMatch string) string {
|
||||||
|
urlSubmatches := cssUrlRegex.FindStringSubmatch(urlMatch)
|
||||||
|
if len(urlSubmatches) < 2 {
|
||||||
|
return urlMatch
|
||||||
|
}
|
||||||
|
url := strings.TrimSpace(urlSubmatches[1])
|
||||||
|
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||||
|
return urlMatch
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
|
||||||
|
})
|
||||||
|
return fmt.Sprintf(`style="%s"`, newStyle)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceHtmlStyleTagContent 处理 <style> 标签内的内容
|
||||||
|
func replaceHtmlStyleTagContent(html string, baseDir string) string {
|
||||||
|
return htmlStyleTagRegex.ReplaceAllStringFunc(html, func(match string) string {
|
||||||
|
submatches := htmlStyleTagRegex.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := submatches[1]
|
||||||
|
content := submatches[2]
|
||||||
|
|
||||||
|
// 处理 @import(使用预编译正则)
|
||||||
|
content = cssImportRegex.ReplaceAllStringFunc(content, func(m string) string {
|
||||||
|
sm := cssImportRegex.FindStringSubmatch(m)
|
||||||
|
if len(sm) < 2 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
url := sm[1]
|
||||||
|
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`@import url("%s");`, resolveHtmlPathToUrl(baseDir, url))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理 url()(使用预编译正则)
|
||||||
|
content = cssUrlRegex.ReplaceAllStringFunc(content, func(m string) string {
|
||||||
|
sm := cssUrlRegex.FindStringSubmatch(m)
|
||||||
|
if len(sm) < 2 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
url := strings.TrimSpace(sm[1])
|
||||||
|
if isAbsoluteURL(url) || strings.HasPrefix(url, "data:") {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`url("%s")`, resolveHtmlPathToUrl(baseDir, url))
|
||||||
|
})
|
||||||
|
|
||||||
|
return fmt.Sprintf(`<style%s>%s</style>`, attrs, content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHtmlPathToUrl 解析路径并转换为本地文件服务器 URL
|
||||||
|
// 支持:相对路径 (./xxx, ../xxx)、绝对路径 (/xxx)
|
||||||
|
func resolveHtmlPathToUrl(baseDir string, path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一使用 / 作为分隔符
|
||||||
|
baseDir = strings.ReplaceAll(baseDir, "\\", "/")
|
||||||
|
|
||||||
|
// 处理以 / 开头的绝对路径(相对于网站根目录)
|
||||||
|
// 对于 dist/index.html,/assets/... 应该被解析为 dist/assets/...
|
||||||
|
if strings.HasPrefix(path, "/") {
|
||||||
|
path = path[1:] // 移除开头的 /
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 ./ 前缀
|
||||||
|
path = strings.TrimPrefix(path, "./")
|
||||||
|
|
||||||
|
// 使用 filepath.Join 进行路径拼接(它会自动处理 ../)
|
||||||
|
pathBackslash := strings.ReplaceAll(path, "/", string(filepath.Separator))
|
||||||
|
absolutePath := filepath.Join(baseDir, pathBackslash)
|
||||||
|
|
||||||
|
// 清理路径并转换回 /
|
||||||
|
absolutePath = filepath.Clean(absolutePath)
|
||||||
|
absolutePath = strings.ReplaceAll(absolutePath, "\\", "/")
|
||||||
|
|
||||||
|
return toLocalServerUrl(absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformJsDynamicImports 转换 JavaScript 文件中的 import 路径
|
||||||
|
func transformJsDynamicImports(jsContent string, baseDir string) string {
|
||||||
|
if baseDir == "" {
|
||||||
|
return jsContent
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsContent
|
||||||
|
|
||||||
|
// 处理动态 import: import("...")(使用预编译正则)
|
||||||
|
result = es6DynamicImport.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
submatches := es6DynamicImport.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
modulePath := submatches[1]
|
||||||
|
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`import("%s")`, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理静态 import: import ... from "..."(使用预编译正则)
|
||||||
|
result = es6ImportFromRegex.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
submatches := es6ImportFromRegex.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
importClause := submatches[1]
|
||||||
|
modulePath := submatches[2]
|
||||||
|
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`import %s from "%s"`, importClause, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理裸 import: import "..."(使用预编译正则)
|
||||||
|
result = es6BareImport.ReplaceAllStringFunc(result, func(match string) string {
|
||||||
|
submatches := es6BareImport.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
modulePath := submatches[1]
|
||||||
|
if isAbsoluteURL(modulePath) || strings.HasPrefix(modulePath, "data:") || strings.HasPrefix(modulePath, "/localfs/") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`import "%s"`, resolveHtmlPathToUrl(baseDir, modulePath))
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectLinkInterceptor 注入链接点击拦截脚本
|
||||||
|
func injectLinkInterceptor(htmlContent string) string {
|
||||||
|
script := `
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var target = e.target;
|
||||||
|
while (target && target.tagName !== 'A') {
|
||||||
|
target = target.parentElement;
|
||||||
|
}
|
||||||
|
if (target && target.tagName === 'A') {
|
||||||
|
var href = target.getAttribute('href');
|
||||||
|
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.parent.postMessage({ type: 'openLocalFile', path: href }, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
|
||||||
|
// 在 </body> 前插入
|
||||||
|
if strings.Contains(htmlContent, "</body>") {
|
||||||
|
return strings.Replace(htmlContent, "</body>", script+"</body>", 1)
|
||||||
|
}
|
||||||
|
// 没有 body 标签,在末尾插入
|
||||||
|
return htmlContent + script
|
||||||
|
}
|
||||||
|
|||||||
@@ -220,37 +220,6 @@ func (a *AuditLogger) Close() error {
|
|||||||
return a.logFile.Close()
|
return a.logFile.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotateLog 日志轮转(每天创建新文件)
|
|
||||||
func (a *AuditLogger) RotateLog() error {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
|
|
||||||
// 刷新缓冲区
|
|
||||||
if err := a.flush(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭当前文件
|
|
||||||
if err := a.logFile.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成新的日志文件名
|
|
||||||
timestamp := time.Now().Format("2006-01-02")
|
|
||||||
logPath := filepath.Join(filepath.Dir(a.logPath), fmt.Sprintf("audit_%s.log", timestamp))
|
|
||||||
|
|
||||||
// 打开新文件
|
|
||||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
a.logFile = logFile
|
|
||||||
a.logPath = logPath
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecentLogs 获取最近的审计日志
|
// GetRecentLogs 获取最近的审计日志
|
||||||
func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
|
func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
|
||||||
// 读取今天的日志文件
|
// 读取今天的日志文件
|
||||||
@@ -309,22 +278,8 @@ func parseLines(text string) []string {
|
|||||||
var globalAuditLogger *AuditLogger
|
var globalAuditLogger *AuditLogger
|
||||||
var auditLoggerOnce sync.Once
|
var auditLoggerOnce sync.Once
|
||||||
|
|
||||||
// InitAuditLogger 初始化全局审计日志记录器
|
|
||||||
func InitAuditLogger(logDir string) error {
|
|
||||||
var err error
|
|
||||||
globalAuditLogger, err = NewAuditLogger(logDir)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAuditLogger 获取全局审计日志记录器
|
// GetAuditLogger 获取全局审计日志记录器
|
||||||
func GetAuditLogger() *AuditLogger {
|
func GetAuditLogger() *AuditLogger {
|
||||||
return globalAuditLogger
|
return globalAuditLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseAuditLogger 关闭全局审计日志记录器
|
|
||||||
func CloseAuditLogger() error {
|
|
||||||
if globalAuditLogger != nil {
|
|
||||||
return globalAuditLogger.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -284,13 +284,16 @@ func getAllowedExtensions() map[string]bool {
|
|||||||
".ppt": true,
|
".ppt": true,
|
||||||
".pptx": true,
|
".pptx": true,
|
||||||
// 文本
|
// 文本
|
||||||
".txt": true,
|
".txt": true,
|
||||||
".md": true,
|
".md": true,
|
||||||
".json": true,
|
".json": true,
|
||||||
".xml": true,
|
".xml": true,
|
||||||
".html": true,
|
".html": true,
|
||||||
".css": true,
|
".css": true,
|
||||||
".js": true,
|
".js": true,
|
||||||
|
// 表格
|
||||||
|
".csv": true,
|
||||||
|
".tsv": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,5 +370,8 @@ func getMIMETypeMapping() map[string]string {
|
|||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
".xml": "application/xml",
|
".xml": "application/xml",
|
||||||
".md": "text/markdown",
|
".md": "text/markdown",
|
||||||
|
// 表格
|
||||||
|
".csv": "text/csv; charset=utf-8",
|
||||||
|
".tsv": "text/tab-separated-values; charset=utf-8",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,13 @@ const (
|
|||||||
|
|
||||||
// HTTP 文件服务大小限制
|
// HTTP 文件服务大小限制
|
||||||
MaxHTTPFileSize = 500 * 1024 * 1024 // 500MB - HTTP 访问文件最大大小
|
MaxHTTPFileSize = 500 * 1024 * 1024 // 500MB - HTTP 访问文件最大大小
|
||||||
|
|
||||||
// 删除操作限制
|
|
||||||
MaxDeleteSizeGB = 1 * 1024 * 1024 * 1024 // 1GB - 单个文件删除大小限制
|
|
||||||
MaxDeleteDirSizeGB = 1 * 1024 * 1024 * 1024 // 1GB - 目录删除大小限制
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 时间相关常量
|
// 时间相关常量
|
||||||
const (
|
const (
|
||||||
// 审计日志
|
// 审计日志
|
||||||
AuditFlushInterval = 5 * time.Second // 审计日志刷新间隔
|
|
||||||
AuditLogBufferSize = 100 // 审计日志缓冲区大小
|
AuditLogBufferSize = 100 // 审计日志缓冲区大小
|
||||||
|
|
||||||
// 回收站
|
|
||||||
RecycleBinRetentionDays = 30 // 回收站文件保留天数(天)
|
|
||||||
RecycleBinRetentionPeriod = 30 * 24 * time.Hour // 回收站文件保留期
|
|
||||||
|
|
||||||
// 临时文件
|
// 临时文件
|
||||||
TempFileCleanupAge = 24 * time.Hour // 临时文件清理周期
|
TempFileCleanupAge = 24 * time.Hour // 临时文件清理周期
|
||||||
TempFileDir = "u-desk-zip" // 临时文件目录名
|
TempFileDir = "u-desk-zip" // 临时文件目录名
|
||||||
@@ -36,7 +27,6 @@ const (
|
|||||||
|
|
||||||
// 数量限制常量
|
// 数量限制常量
|
||||||
const (
|
const (
|
||||||
MaxDirectoryDepth = 15 // 最大目录深度
|
|
||||||
MaxFileCount = 1000 // 最大文件数量(目录)
|
MaxFileCount = 1000 // 最大文件数量(目录)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,15 +38,9 @@ const (
|
|||||||
|
|
||||||
// 随机字符串相关常量
|
// 随机字符串相关常量
|
||||||
const (
|
const (
|
||||||
RandomStringCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
RandomStringDefaultLength = 6 // 回收站文件名随机后缀长度
|
RandomStringDefaultLength = 6 // 回收站文件名随机后缀长度
|
||||||
)
|
)
|
||||||
|
|
||||||
// 文件路径相关常量
|
|
||||||
const (
|
|
||||||
WindowsDriveLength = 2 // Windows 盘符长度 (C:)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 路径遍历检测字符串
|
// 路径遍历检测字符串
|
||||||
const (
|
const (
|
||||||
PathTraversalPattern = ".." // 路径遍历特征字符串
|
PathTraversalPattern = ".." // 路径遍历特征字符串
|
||||||
@@ -69,17 +53,5 @@ const (
|
|||||||
FileTypeAudio = "audio"
|
FileTypeAudio = "audio"
|
||||||
FileTypeDocument = "document"
|
FileTypeDocument = "document"
|
||||||
FileTypeText = "text"
|
FileTypeText = "text"
|
||||||
FileTypeArchive = "archive"
|
|
||||||
FileTypeApplication = "application"
|
FileTypeApplication = "application"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 安全相关常量
|
|
||||||
const (
|
|
||||||
// ZIP 安全
|
|
||||||
MinValidZipSize = 22 // ZIP 文件最小有效大小(文件头)
|
|
||||||
ZipFileHeaderSignature = 0x504B // "PK" - ZIP 文件头签名
|
|
||||||
|
|
||||||
// 文件锁
|
|
||||||
LockCheckMaxRetries = 3 // 文件锁检查最大重试次数
|
|
||||||
LockCheckRetryInterval = 100 * time.Millisecond // 文件锁检查重试间隔
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -6,130 +6,6 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrorCode 错误码类型
|
|
||||||
type ErrorCode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// 通用错误
|
|
||||||
ErrCodeGeneral ErrorCode = "GENERAL_ERROR"
|
|
||||||
ErrCodeInvalid ErrorCode = "INVALID_ARGUMENT"
|
|
||||||
ErrCodeNotFound ErrorCode = "NOT_FOUND"
|
|
||||||
ErrCodePermission ErrorCode = "PERMISSION_DENIED"
|
|
||||||
ErrCodeIO ErrorCode = "IO_ERROR"
|
|
||||||
|
|
||||||
// 路径相关错误
|
|
||||||
ErrCodePathTraversal ErrorCode = "PATH_TRAVERSAL"
|
|
||||||
ErrCodeInvalidPath ErrorCode = "INVALID_PATH"
|
|
||||||
ErrCodeSensitivePath ErrorCode = "SENSITIVE_PATH"
|
|
||||||
|
|
||||||
// 文件操作错误
|
|
||||||
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
|
|
||||||
ErrCodeFileExists ErrorCode = "FILE_EXISTS"
|
|
||||||
ErrCodeDirectoryNotEmpty ErrorCode = "DIRECTORY_NOT_EMPTY"
|
|
||||||
|
|
||||||
// 安全相关错误
|
|
||||||
ErrCodeSecurityViolation ErrorCode = "SECURITY_VIOLATION"
|
|
||||||
ErrCodeSizeLimit ErrorCode = "SIZE_LIMIT_EXCEEDED"
|
|
||||||
ErrCodeFileLocked ErrorCode = "FILE_LOCKED"
|
|
||||||
|
|
||||||
// ZIP相关错误
|
|
||||||
ErrCodeZipInvalid ErrorCode = "ZIP_INVALID"
|
|
||||||
ErrCodeZipBomb ErrorCode = "ZIP_BOMB"
|
|
||||||
ErrCodeZipExtract ErrorCode = "ZIP_EXTRACT_FAILED"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileError 文件系统专用错误类型
|
|
||||||
// 包含详细的错误上下文信息,便于调试和用户提示
|
|
||||||
type FileNotFoundError struct {
|
|
||||||
Path string
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *FileNotFoundError) Error() string {
|
|
||||||
return fmt.Sprintf("文件不存在: %s", e.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *FileNotFoundError) Unwrap() error {
|
|
||||||
return e.Err
|
|
||||||
}
|
|
||||||
|
|
||||||
// PathValidationError 路径验证错误
|
|
||||||
type PathValidationError struct {
|
|
||||||
Path string
|
|
||||||
Reason string
|
|
||||||
IsSensitive bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PathValidationError) Error() string {
|
|
||||||
return fmt.Sprintf("路径验证失败: %s - %s", e.Path, e.Reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecurityViolationError 安全违规错误
|
|
||||||
type SecurityViolationError struct {
|
|
||||||
Path string
|
|
||||||
Violation string
|
|
||||||
Suggestion string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SecurityViolationError) Error() string {
|
|
||||||
msg := fmt.Sprintf("安全违规: %s - %s", e.Path, e.Violation)
|
|
||||||
if e.Suggestion != "" {
|
|
||||||
msg += fmt.Sprintf("\n建议: %s", e.Suggestion)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// SizeLimitError 大小限制错误
|
|
||||||
type SizeLimitError struct {
|
|
||||||
Path string
|
|
||||||
ActualSize int64
|
|
||||||
MaxSize int64
|
|
||||||
SizeType string // "file" or "directory"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SizeLimitError) Error() string {
|
|
||||||
return fmt.Sprintf("%s大小超限: %s (实际: %.2f GB, 限制: %.2f GB)",
|
|
||||||
e.SizeType, e.Path,
|
|
||||||
float64(e.ActualSize)/(1024*1024*1024),
|
|
||||||
float64(e.MaxSize)/(1024*1024*1024),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileLockedError 文件锁定错误
|
|
||||||
type FileLockedError struct {
|
|
||||||
Path string
|
|
||||||
ProcessInfo string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *FileLockedError) Error() string {
|
|
||||||
msg := fmt.Sprintf("文件被占用: %s", e.Path)
|
|
||||||
if e.ProcessInfo != "" {
|
|
||||||
msg += fmt.Sprintf("\n占用程序: %s", e.ProcessInfo)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrapError 错误包装函数
|
|
||||||
// 添加上下文信息到错误中
|
|
||||||
func WrapError(operation string, path string, err error) error {
|
|
||||||
return fmt.Errorf("%s 失败: %s - %w", operation, path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrapErrorf 格式化错误包装
|
|
||||||
func WrapErrorf(format string, args ...interface{}) error {
|
|
||||||
return fmt.Errorf(format, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStackTrace 获取堆栈跟踪(用于调试)
|
|
||||||
func GetStackTrace(skip int) string {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
n := runtime.Stack(buf, false)
|
|
||||||
if n > 0 {
|
|
||||||
return string(buf[:n])
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteRestrictionWarning 删除限制警告
|
// DeleteRestrictionWarning 删除限制警告
|
||||||
// 用于在删除受限文件时提供详细的警告信息
|
// 用于在删除受限文件时提供详细的警告信息
|
||||||
type DeleteRestrictionWarning struct {
|
type DeleteRestrictionWarning struct {
|
||||||
@@ -141,3 +17,13 @@ type DeleteRestrictionWarning struct {
|
|||||||
func (w *DeleteRestrictionWarning) Error() string {
|
func (w *DeleteRestrictionWarning) Error() string {
|
||||||
return fmt.Sprintf("删除限制警告: %s\n%s", w.Path, w.Details)
|
return fmt.Sprintf("删除限制警告: %s\n%s", w.Path, w.Details)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStackTrace 获取堆栈跟踪(用于调试)
|
||||||
|
func GetStackTrace(skip int) string {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
n := runtime.Stack(buf, false)
|
||||||
|
if n > 0 {
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Windows API 锁相关函数和常量
|
|
||||||
var (
|
|
||||||
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
|
|
||||||
procGetLastError = modkernel32.NewProc("GetLastError")
|
|
||||||
procGetProcessId = modkernel32.NewProc("GetProcessId")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileLockChecker 文件锁检查器
|
// FileLockChecker 文件锁检查器
|
||||||
@@ -102,37 +94,6 @@ func (c *FileLockChecker) getProcessInfo(path string) (string, error) {
|
|||||||
return "文件正被其他程序使用", nil
|
return "文件正被其他程序使用", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFileWithRetry 带重试的文件锁检查
|
|
||||||
func (c *FileLockChecker) CheckFileWithRetry(path string, maxRetries int, retryInterval time.Duration) error {
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
|
||||||
locked, processInfo, err := c.IsFileLocked(path)
|
|
||||||
if err != nil && !locked {
|
|
||||||
// 非锁相关的错误,直接返回
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !locked {
|
|
||||||
// 文件未被锁定,可以操作
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件被锁定
|
|
||||||
if i < maxRetries-1 {
|
|
||||||
// 还有重试机会,等待后重试
|
|
||||||
time.Sleep(retryInterval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最后一次重试失败,返回错误
|
|
||||||
if processInfo != "" {
|
|
||||||
return fmt.Errorf("文件被占用: %s", processInfo)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("文件被其他程序占用,请关闭相关程序后重试")
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("文件检查超时")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SafeDeleteWithLockCheck 带锁检查的安全删除
|
// SafeDeleteWithLockCheck 带锁检查的安全删除
|
||||||
func (c *FileLockChecker) SafeDeleteWithLockCheck(path string) error {
|
func (c *FileLockChecker) SafeDeleteWithLockCheck(path string) error {
|
||||||
// 检查文件是否被锁定
|
// 检查文件是否被锁定
|
||||||
@@ -158,20 +119,6 @@ const (
|
|||||||
ERROR_SHARING_VIOLATION = 32 // syscall.Errno(32)
|
ERROR_SHARING_VIOLATION = 32 // syscall.Errno(32)
|
||||||
)
|
)
|
||||||
|
|
||||||
// BY_HANDLE_FILE_INFORMATION 文件信息结构体
|
|
||||||
type BY_HANDLE_FILE_INFORMATION struct {
|
|
||||||
FileAttributes uint32
|
|
||||||
CreationTime syscall.Filetime
|
|
||||||
LastAccessTime syscall.Filetime
|
|
||||||
LastWriteTime syscall.Filetime
|
|
||||||
VolumeSerialNumber uint32
|
|
||||||
FileSizeHigh uint32
|
|
||||||
FileSizeLow uint32
|
|
||||||
NumberOfLinks uint32
|
|
||||||
FileIndexHigh uint32
|
|
||||||
FileIndexLow uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// contains 检查字符串是否包含子串(不区分大小写)
|
// contains 检查字符串是否包含子串(不区分大小写)
|
||||||
func contains(str, substr string) bool {
|
func contains(str, substr string) bool {
|
||||||
return len(str) >= len(substr) && (str == substr || len(substr) == 0 ||
|
return len(str) >= len(substr) && (str == substr || len(substr) == 0 ||
|
||||||
@@ -203,18 +150,3 @@ func containsIgnoreCase(str, substr string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局文件锁检查器
|
|
||||||
var globalLockChecker *FileLockChecker
|
|
||||||
|
|
||||||
// InitFileLockChecker 初始化全局文件锁检查器
|
|
||||||
func InitFileLockChecker() {
|
|
||||||
globalLockChecker = NewFileLockChecker()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFileLockChecker 获取全局文件锁检查器
|
|
||||||
func GetFileLockChecker() *FileLockChecker {
|
|
||||||
if globalLockChecker == nil {
|
|
||||||
globalLockChecker = NewFileLockChecker()
|
|
||||||
}
|
|
||||||
return globalLockChecker
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ func (l *Logger) Info(format string, args ...interface{}) {
|
|||||||
l.log(LogLevelInfo, "INFO", format, args...)
|
l.log(LogLevelInfo, "INFO", format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn 记录警告日志
|
|
||||||
func (l *Logger) Warn(format string, args ...interface{}) {
|
|
||||||
l.log(LogLevelWarn, "WARN", format, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error 记录错误日志
|
// Error 记录错误日志
|
||||||
func (l *Logger) Error(format string, args ...interface{}) {
|
func (l *Logger) Error(format string, args ...interface{}) {
|
||||||
l.log(LogLevelError, "ERROR", format, args...)
|
l.log(LogLevelError, "ERROR", format, args...)
|
||||||
@@ -141,37 +136,10 @@ func LogError(operation string, path string, err error) {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
globalLogger *Logger
|
globalLogger *Logger
|
||||||
loggerOnce sync.Once
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitLogger 初始化全局日志记录器
|
|
||||||
func InitLogger(logDir string, minLevel LogLevel) error {
|
|
||||||
var initErr error
|
|
||||||
loggerOnce.Do(func() {
|
|
||||||
timestamp := time.Now().Format("2006-01-02")
|
|
||||||
logPath := filepath.Join(logDir, fmt.Sprintf("filesystem_%s.log", timestamp))
|
|
||||||
|
|
||||||
logger, err := NewLogger(logPath, minLevel)
|
|
||||||
if err != nil {
|
|
||||||
initErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalLogger = logger
|
|
||||||
log.Printf("[日志系统] 已启动,日志文件: %s", logPath)
|
|
||||||
})
|
|
||||||
return initErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGlobalLogger 获取全局日志记录器
|
// GetGlobalLogger 获取全局日志记录器
|
||||||
func GetGlobalLogger() *Logger {
|
func GetGlobalLogger() *Logger {
|
||||||
return globalLogger
|
return globalLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseLogger 关闭全局日志记录器
|
|
||||||
func CloseLogger() error {
|
|
||||||
if globalLogger != nil {
|
|
||||||
return globalLogger.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PathValidator 路径验证器接口
|
// PathValidator 路径验证器接口
|
||||||
@@ -180,16 +181,25 @@ func (v *DefaultPathValidator) isSensitivePath(path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 默认路径验证器(缓存,避免每次调用重复初始化)
|
||||||
|
var (
|
||||||
|
defaultValidatorOnce sync.Once
|
||||||
|
defaultValidator PathValidator
|
||||||
|
)
|
||||||
|
|
||||||
|
func getDefaultValidator() PathValidator {
|
||||||
|
defaultValidatorOnce.Do(func() {
|
||||||
|
defaultValidator = NewPathValidator(DefaultConfig())
|
||||||
|
})
|
||||||
|
return defaultValidator
|
||||||
|
}
|
||||||
|
|
||||||
// isSafePath 兼容函数:保持向后兼容
|
// isSafePath 兼容函数:保持向后兼容
|
||||||
// 使用默认配置的路径验证器
|
|
||||||
func isSafePath(path string) bool {
|
func isSafePath(path string) bool {
|
||||||
validator := NewPathValidator(DefaultConfig())
|
return getDefaultValidator().IsSafe(path)
|
||||||
return validator.IsSafe(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSensitivePath 兼容函数:保持向后兼容
|
// isSensitivePath 兼容函数:保持向后兼容
|
||||||
// 使用默认配置检查敏感路径
|
|
||||||
func isSensitivePath(path string) bool {
|
func isSensitivePath(path string) bool {
|
||||||
validator := NewPathValidator(DefaultConfig())
|
return getDefaultValidator().IsSensitive(path)
|
||||||
return validator.IsSensitive(path)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,16 +376,6 @@ func generateRandomString(length int) string {
|
|||||||
// 全局回收站实例
|
// 全局回收站实例
|
||||||
var globalRecycleBin *RecycleBin
|
var globalRecycleBin *RecycleBin
|
||||||
|
|
||||||
// InitRecycleBin 初始化全局回收站
|
|
||||||
func InitRecycleBin(binPath string) error {
|
|
||||||
bin, err := NewRecycleBin(binPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
globalRecycleBin = bin
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecycleBin 获取全局回收站实例
|
// GetRecycleBin 获取全局回收站实例
|
||||||
func GetRecycleBin() *RecycleBin {
|
func GetRecycleBin() *RecycleBin {
|
||||||
return globalRecycleBin
|
return globalRecycleBin
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ package filesystem
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"u-desk/internal/common"
|
"u-desk/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxReadWriteSize = 10 * 1024 * 1024 // 10MB 读写上限
|
||||||
|
|
||||||
// FileOperationResult 文件操作结果
|
// FileOperationResult 文件操作结果
|
||||||
type FileOperationResult struct {
|
type FileOperationResult struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -114,18 +119,22 @@ func (s *FileSystemService) initRecycleBin() error {
|
|||||||
|
|
||||||
// ========== 核心文件操作 ==========
|
// ========== 核心文件操作 ==========
|
||||||
|
|
||||||
// Read 读取文件内容(实现 FileService 接口)
|
// ReadFile 读取文件内容(限制最大 10MB)
|
||||||
func (s *FileSystemService) Read(path string) (string, error) {
|
|
||||||
return s.ReadFile(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadFile 读取文件内容
|
|
||||||
func (s *FileSystemService) ReadFile(path string) (string, error) {
|
func (s *FileSystemService) ReadFile(path string) (string, error) {
|
||||||
// 路径验证
|
// 路径验证
|
||||||
if err := s.validatePath(path); err != nil {
|
if err := s.validatePath(path); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查文件大小,避免读取超大文件导致内存问题
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取文件信息失败: %v", err)
|
||||||
|
}
|
||||||
|
if info.Size() > maxReadWriteSize {
|
||||||
|
return "", fmt.Errorf("文件过大 (%.1f MB),超过读取上限 (%d MB)", float64(info.Size())/1024/1024, maxReadWriteSize/1024/1024)
|
||||||
|
}
|
||||||
|
|
||||||
// 读取文件
|
// 读取文件
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,58 +145,42 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
|
|||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write 写入文件内容(实现 FileService 接口)
|
// Write 写入文件内容(实现 FileService 接口)
|
||||||
func (s *FileSystemService) Write(path, content string) error {
|
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
|
||||||
return s.WriteFile(path, content)
|
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
|
||||||
}
|
|
||||||
|
|
||||||
// WriteFile 写入文件
|
|
||||||
func (s *FileSystemService) WriteFile(path, content string) error {
|
|
||||||
// 路径验证
|
|
||||||
if err := s.validatePath(path); err != nil {
|
if err := s.validatePath(path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建目录
|
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, DefaultDirPermissions); err != nil {
|
if err := os.MkdirAll(dir, DefaultDirPermissions); err != nil {
|
||||||
return fmt.Errorf("创建目录失败: %v", err)
|
return fmt.Errorf("创建目录失败: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(data) > maxReadWriteSize {
|
||||||
// 写入文件
|
return fmt.Errorf("文件过大 (%.1f MB),超过写入上限 (%d MB)", float64(len(data))/1024/1024, maxReadWriteSize/1024/1024)
|
||||||
data := []byte(content)
|
}
|
||||||
if err := os.WriteFile(path, data, DefaultFilePermissions); err != nil {
|
if err := os.WriteFile(path, data, DefaultFilePermissions); err != nil {
|
||||||
s.logWrite(path, int64(len(data)), err)
|
s.logWrite(path, int64(len(data)), err)
|
||||||
return fmt.Errorf("写入文件失败: %v", err)
|
return fmt.Errorf("写入文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logWrite(path, int64(len(data)), nil)
|
s.logWrite(path, int64(len(data)), nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 列出目录内容(实现 FileService 接口)
|
// WriteFile 写入文件
|
||||||
func (s *FileSystemService) List(path string) ([]map[string]interface{}, error) {
|
func (s *FileSystemService) WriteFile(path, content string) error {
|
||||||
return s.ListDir(path)
|
return s.writeFileWithLog(path, []byte(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open 打开文件(实现 FileService 接口)
|
// SaveBase64File 将 base64 编码内容解码后写入二进制文件
|
||||||
func (s *FileSystemService) Open(path string) error {
|
func (s *FileSystemService) SaveBase64File(path, base64Content string) error {
|
||||||
// 使用系统默认程序打开文件
|
if strings.TrimSpace(base64Content) == "" {
|
||||||
var cmd *exec.Cmd
|
return errors.New("base64 内容不能为空")
|
||||||
switch runtime.GOOS {
|
|
||||||
case "windows":
|
|
||||||
cmd = exec.Command("cmd", "/c", "start", "", path)
|
|
||||||
case "darwin":
|
|
||||||
cmd = exec.Command("open", path)
|
|
||||||
default:
|
|
||||||
cmd = exec.Command("xdg-open", path)
|
|
||||||
}
|
}
|
||||||
return cmd.Start()
|
data, err := base64.StdEncoding.DecodeString(base64Content)
|
||||||
}
|
if err != nil {
|
||||||
|
return fmt.Errorf("base64 解码失败: %v", err)
|
||||||
// Delete 删除文件或目录(实现 FileService 接口)
|
}
|
||||||
func (s *FileSystemService) Delete(path string) (*FileOperationResult, error) {
|
return s.writeFileWithLog(path, data)
|
||||||
return s.DeletePathWithContext(context.Background(), path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeletePath 删除文件或目录
|
// DeletePath 删除文件或目录
|
||||||
@@ -403,11 +396,6 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInfo 获取文件信息(实现 FileService 接口)
|
|
||||||
func (s *FileSystemService) GetInfo(path string) (map[string]interface{}, error) {
|
|
||||||
return s.GetFileInfo(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFileInfo 获取文件信息
|
// GetFileInfo 获取文件信息
|
||||||
func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, error) {
|
func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, error) {
|
||||||
if err := s.validatePath(path); err != nil {
|
if err := s.validatePath(path); err != nil {
|
||||||
@@ -492,31 +480,16 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
|
|||||||
|
|
||||||
// ========== ZIP操作接口 ==========
|
// ========== ZIP操作接口 ==========
|
||||||
|
|
||||||
// ListZip 列出ZIP文件内容
|
|
||||||
func (s *FileSystemService) ListZip(zipPath string) ([]map[string]interface{}, error) {
|
|
||||||
return ListZipContents(zipPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
|
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
|
||||||
func (s *FileSystemService) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
|
func (s *FileSystemService) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
|
||||||
return ListZipContents(zipPath)
|
return ListZipContents(zipPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractZipFile 从ZIP提取文件内容
|
|
||||||
func (s *FileSystemService) ExtractZipFile(zipPath, filePath string) (string, error) {
|
|
||||||
return ExtractFileFromZip(zipPath, filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractFileFromZip 从ZIP提取文件内容(别名,保持向后兼容)
|
// ExtractFileFromZip 从ZIP提取文件内容(别名,保持向后兼容)
|
||||||
func (s *FileSystemService) ExtractFileFromZip(zipPath, filePath string) (string, error) {
|
func (s *FileSystemService) ExtractFileFromZip(zipPath, filePath string) (string, error) {
|
||||||
return ExtractFileFromZip(zipPath, filePath)
|
return ExtractFileFromZip(zipPath, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractZipFileToTemp 从ZIP提取文件到临时目录
|
|
||||||
func (s *FileSystemService) ExtractZipFileToTemp(zipPath, filePath string) (string, error) {
|
|
||||||
return ExtractFileFromZipToTemp(zipPath, filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExtractFileFromZipToTemp 从ZIP提取文件到临时目录(别名,保持向后兼容)
|
// ExtractFileFromZipToTemp 从ZIP提取文件到临时目录(别名,保持向后兼容)
|
||||||
func (s *FileSystemService) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
|
func (s *FileSystemService) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
|
||||||
return ExtractFileFromZipToTemp(zipPath, filePath)
|
return ExtractFileFromZipToTemp(zipPath, filePath)
|
||||||
@@ -537,7 +510,9 @@ func getCurrentTimestamp() time.Time {
|
|||||||
// isInRecycleBin 检查路径是否在回收站中
|
// isInRecycleBin 检查路径是否在回收站中
|
||||||
func isInRecycleBin(path string) bool {
|
func isInRecycleBin(path string) bool {
|
||||||
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
|
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
|
||||||
return filepath.HasPrefix(filepath.Clean(path), filepath.Clean(recycleBinPath))
|
cleanPath := filepath.Clean(path)
|
||||||
|
cleanBinPath := filepath.Clean(recycleBinPath)
|
||||||
|
return len(cleanPath) >= len(cleanBinPath) && cleanPath[:len(cleanBinPath)] == cleanBinPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 辅助方法 ==========
|
// ========== 辅助方法 ==========
|
||||||
@@ -760,16 +735,3 @@ func GetGlobalService() (*FileSystemService, error) {
|
|||||||
return globalService, initErr
|
return globalService, initErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitGlobalFileSystem 初始化全局文件系统(兼容旧代码)
|
|
||||||
func InitGlobalFileSystem() error {
|
|
||||||
_, err := GetGlobalService()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseGlobalFileSystem 关闭全局文件系统
|
|
||||||
func CloseGlobalFileSystem(ctx context.Context) error {
|
|
||||||
if globalService != nil {
|
|
||||||
return globalService.Close(ctx)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -346,46 +346,4 @@ func GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
|
|||||||
return result.(map[string]interface{}), nil
|
return result.(map[string]interface{}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateZipFileBasic 验证ZIP文件的基本信息(提取自ListZipContents)
|
|
||||||
func validateZipFileBasic(zipPath string) error {
|
|
||||||
if err := validateZipPath(zipPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInfo, err := os.Stat(zipPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法访问文件: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileInfo.Size() < MinValidZipSize {
|
|
||||||
return fmt.Errorf("文件太小 (%d bytes)", fileInfo.Size())
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileInfo.Size() > MaxZipSize {
|
|
||||||
return fmt.Errorf("ZIP文件过大 (%d bytes)", fileInfo.Size())
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkZipFileHeader(zipPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkZipFileHeader 检查ZIP文件头签名
|
|
||||||
func checkZipFileHeader(zipPath string) error {
|
|
||||||
file, err := os.Open(zipPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法打开文件: %v", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
header := make([]byte, 4)
|
|
||||||
n, err := file.Read(header)
|
|
||||||
if err != nil || n != 4 {
|
|
||||||
return fmt.Errorf("无法读取文件头")
|
|
||||||
}
|
|
||||||
|
|
||||||
if header[0] != 0x50 || header[1] != 0x4B {
|
|
||||||
return fmt.Errorf("不是有效的 ZIP 文件")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,25 +65,6 @@ func isMatchFile(file *zip.File, targetPath string) bool {
|
|||||||
filepath.Clean(file.Name) == filepath.Clean(targetPath)
|
filepath.Clean(file.Name) == filepath.Clean(targetPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openZipFileInReader 在ZIP reader中打开指定文件
|
|
||||||
// 用于读取文件内容的辅助函数
|
|
||||||
func openZipFileInReader(reader *zip.ReadCloser, filePath string) (io.ReadCloser, *zip.File, error) {
|
|
||||||
for _, file := range reader.File {
|
|
||||||
if isMatchFile(file, filePath) {
|
|
||||||
if file.Mode().IsDir() {
|
|
||||||
return nil, nil, fmt.Errorf("不能读取目录")
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("打开 zip 中的文件失败: %v", err)
|
|
||||||
}
|
|
||||||
return rc, file, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil, fmt.Errorf("文件在 zip 中不存在: %s", filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readAllFromFile 从文件读取所有内容
|
// readAllFromFile 从文件读取所有内容
|
||||||
// 辅助函数,避免重复的 io.ReadAll 调用
|
// 辅助函数,避免重复的 io.ReadAll 调用
|
||||||
func readAllFromFile(rc io.ReadCloser) ([]byte, error) {
|
func readAllFromFile(rc io.ReadCloser) ([]byte, error) {
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ var defaultTabConfig = TabConfig{
|
|||||||
AvailableTabs: []TabDefinition{
|
AvailableTabs: []TabDefinition{
|
||||||
{Key: "file-system", Title: "文件管理", Enabled: true},
|
{Key: "file-system", Title: "文件管理", Enabled: true},
|
||||||
{Key: "db-cli", Title: "数据库", Enabled: true},
|
{Key: "db-cli", Title: "数据库", Enabled: true},
|
||||||
|
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
|
||||||
|
{Key: "version", Title: "版本历史", Enabled: true},
|
||||||
},
|
},
|
||||||
VisibleTabs: []string{"file-system", "db-cli"},
|
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "version"},
|
||||||
DefaultTab: "file-system",
|
DefaultTab: "file-system",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"u-desk/internal/crypto"
|
"u-desk/internal/crypto"
|
||||||
"u-desk/internal/dbclient"
|
"u-desk/internal/dbclient"
|
||||||
"u-desk/internal/storage/models"
|
"u-desk/internal/storage/models"
|
||||||
"u-desk/internal/storage/repository"
|
"u-desk/internal/storage/repository"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionService 连接管理服务
|
// ConnectionService 连接管理服务
|
||||||
@@ -90,8 +94,20 @@ func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error)
|
|||||||
return s.repo.FindByID(id)
|
return s.repo.FindByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteConnection 删除连接配置
|
// DeleteConnection 删除连接配置(含关联数据和连接池清理)
|
||||||
func (s *ConnectionService) DeleteConnection(id uint) error {
|
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)
|
return s.repo.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,3 +201,68 @@ func (s *ConnectionService) TestConnectionWithParams(connType, host string, port
|
|||||||
return fmt.Errorf("不支持的数据库类型: %s", connType)
|
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
|
// executeMySQL 执行MySQL SQL
|
||||||
func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnection, sqlStr string, database string, startTime time.Time) (*SqlResult, error) {
|
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)
|
pc := s.pool.GetMySQLClient(conn)
|
||||||
if err != nil {
|
if pc.Client == nil {
|
||||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||||
}
|
}
|
||||||
|
defer pc.Release()
|
||||||
|
|
||||||
sqlStr = strings.TrimSpace(sqlStr)
|
sqlStr = strings.TrimSpace(sqlStr)
|
||||||
sqlUpper := strings.ToUpper(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, "DESCRIBE") || strings.HasPrefix(sqlUpper, "DESC") ||
|
||||||
strings.HasPrefix(sqlUpper, "EXPLAIN") {
|
strings.HasPrefix(sqlUpper, "EXPLAIN") {
|
||||||
// 查询语句
|
// 查询语句
|
||||||
queryResult, err := client.ExecuteQuery(ctx, sqlStr, dbName)
|
queryResult, err := pc.Client.ExecuteQuery(ctx, sqlStr, dbName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -99,7 +100,7 @@ func (s *SqlExecService) executeMySQL(ctx context.Context, conn *models.DbConnec
|
|||||||
result.RowsAffected = len(queryResult.Data)
|
result.RowsAffected = len(queryResult.Data)
|
||||||
} else {
|
} else {
|
||||||
// 更新语句
|
// 更新语句
|
||||||
rowsAffected, err := client.ExecuteUpdate(ctx, sqlStr, dbName)
|
rowsAffected, err := pc.Client.ExecuteUpdate(ctx, sqlStr, dbName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -220,11 +221,12 @@ func (s *SqlExecService) GetDatabases(connectionID uint) ([]string, error) {
|
|||||||
|
|
||||||
switch conn.Type {
|
switch conn.Type {
|
||||||
case "mysql":
|
case "mysql":
|
||||||
client, err := s.pool.GetMySQLClient(conn)
|
pc := s.pool.GetMySQLClient(conn)
|
||||||
if err != nil {
|
if pc.Client == nil {
|
||||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||||
}
|
}
|
||||||
return client.ListDatabases(ctx)
|
defer pc.Release()
|
||||||
|
return pc.Client.ListDatabases(ctx)
|
||||||
case "redis":
|
case "redis":
|
||||||
databases := make([]string, 16)
|
databases := make([]string, 16)
|
||||||
for i := 0; i < 16; i++ {
|
for i := 0; i < 16; i++ {
|
||||||
@@ -254,11 +256,12 @@ func (s *SqlExecService) GetTables(connectionID uint, database string) ([]string
|
|||||||
|
|
||||||
switch conn.Type {
|
switch conn.Type {
|
||||||
case "mysql":
|
case "mysql":
|
||||||
client, err := s.pool.GetMySQLClient(conn)
|
pc := s.pool.GetMySQLClient(conn)
|
||||||
if err != nil {
|
if pc.Client == nil {
|
||||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||||
}
|
}
|
||||||
return client.ListTables(ctx, database)
|
defer pc.Release()
|
||||||
|
return pc.Client.ListTables(ctx, database)
|
||||||
case "redis":
|
case "redis":
|
||||||
client, err := s.pool.GetRedisClient(conn)
|
client, err := s.pool.GetRedisClient(conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,7 +308,7 @@ func parseRedisCommand(cmd string) []string {
|
|||||||
} else {
|
} else {
|
||||||
if char == quoteChar {
|
if char == quoteChar {
|
||||||
inQuotes = false
|
inQuotes = false
|
||||||
quoteChar = 0
|
quoteChar = byte(0)
|
||||||
} else {
|
} else {
|
||||||
current.WriteByte(char)
|
current.WriteByte(char)
|
||||||
}
|
}
|
||||||
@@ -330,11 +333,12 @@ func (s *SqlExecService) GetTableStructure(connectionID uint, database, tableNam
|
|||||||
|
|
||||||
switch conn.Type {
|
switch conn.Type {
|
||||||
case "mysql":
|
case "mysql":
|
||||||
client, err := s.pool.GetMySQLClient(conn)
|
pc := s.pool.GetMySQLClient(conn)
|
||||||
if err != nil {
|
if pc.Client == nil {
|
||||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -393,11 +397,12 @@ func (s *SqlExecService) GetIndexes(connectionID uint, database, tableName strin
|
|||||||
|
|
||||||
switch conn.Type {
|
switch conn.Type {
|
||||||
case "mysql":
|
case "mysql":
|
||||||
client, err := s.pool.GetMySQLClient(conn)
|
pc := s.pool.GetMySQLClient(conn)
|
||||||
if err != nil {
|
if pc.Client == nil {
|
||||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
return nil, fmt.Errorf("获取 MySQL 客户端失败")
|
||||||
}
|
}
|
||||||
return client.GetIndexes(ctx, database, tableName)
|
defer pc.Release()
|
||||||
|
return pc.Client.GetIndexes(ctx, database, tableName)
|
||||||
|
|
||||||
case "mongo", "redis":
|
case "mongo", "redis":
|
||||||
return []map[string]interface{}{}, nil
|
return []map[string]interface{}{}, nil
|
||||||
@@ -419,11 +424,12 @@ func (s *SqlExecService) PreviewTableStructure(connectionID uint, database, tabl
|
|||||||
|
|
||||||
switch conn.Type {
|
switch conn.Type {
|
||||||
case "mysql":
|
case "mysql":
|
||||||
client, err := s.pool.GetMySQLClient(conn)
|
pc := s.pool.GetMySQLClient(conn)
|
||||||
if err != nil {
|
if pc.Client == nil {
|
||||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
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":
|
case "mongo":
|
||||||
client, err := s.pool.GetMongoClient(conn)
|
client, err := s.pool.GetMongoClient(conn)
|
||||||
@@ -449,11 +455,12 @@ func (s *SqlExecService) UpdateTableStructure(connectionID uint, database, table
|
|||||||
|
|
||||||
switch conn.Type {
|
switch conn.Type {
|
||||||
case "mysql":
|
case "mysql":
|
||||||
client, err := s.pool.GetMySQLClient(conn)
|
pc := s.pool.GetMySQLClient(conn)
|
||||||
if err != nil {
|
if pc.Client == nil {
|
||||||
return nil, fmt.Errorf("获取 MySQL 客户端失败: %v", err)
|
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":
|
case "mongo":
|
||||||
client, err := s.pool.GetMongoClient(conn)
|
client, err := s.pool.GetMongoClient(conn)
|
||||||
|
|||||||
@@ -29,8 +29,3 @@ func (s *TabService) SaveTabs(tabs []models.SqlTab) error {
|
|||||||
func (s *TabService) ListTabs() ([]models.SqlTab, error) {
|
func (s *TabService) ListTabs() ([]models.SqlTab, error) {
|
||||||
return s.repo.FindAll()
|
return s.repo.FindAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTab 删除标签页
|
|
||||||
func (s *TabService) DeleteTab(id uint) error {
|
|
||||||
return s.repo.Delete(id)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func LoadUpdateConfig() (*UpdateConfig, error) {
|
|||||||
LastCheckTime: time.Time{}, // 启动时会立即检查
|
LastCheckTime: time.Time{}, // 启动时会立即检查
|
||||||
AutoCheckEnabled: true,
|
AutoCheckEnabled: true,
|
||||||
CheckIntervalMinutes: 5, // 5分钟检查一次
|
CheckIntervalMinutes: 5, // 5分钟检查一次
|
||||||
CheckURL: "https://img.1216.top/u-desk/last-version.json",
|
CheckURL: "https://c.1216.top/last-version.json",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ func LoadUpdateConfig() (*UpdateConfig, error) {
|
|||||||
|
|
||||||
// 使用默认检查地址
|
// 使用默认检查地址
|
||||||
if config.CheckURL == "" {
|
if config.CheckURL == "" {
|
||||||
config.CheckURL = "https://img.1216.top/u-desk/last-version.json"
|
config.CheckURL = "https://c.1216.top/last-version.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保版本号不为空(使用缓存的版本号)
|
// 确保版本号不为空(使用缓存的版本号)
|
||||||
@@ -102,22 +102,6 @@ func SaveUpdateConfig(config *UpdateConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShouldCheckUpdate 判断是否应该检查更新
|
|
||||||
func (c *UpdateConfig) ShouldCheckUpdate() bool {
|
|
||||||
if !c.AutoCheckEnabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果从未检查过,应该检查
|
|
||||||
if c.LastCheckTime.IsZero() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否超过间隔分钟数
|
|
||||||
minutesSinceLastCheck := time.Since(c.LastCheckTime).Minutes()
|
|
||||||
return minutesSinceLastCheck >= float64(c.CheckIntervalMinutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateLastCheckTime 更新最后检查时间
|
// UpdateLastCheckTime 更新最后检查时间
|
||||||
func (c *UpdateConfig) UpdateLastCheckTime() error {
|
func (c *UpdateConfig) UpdateLastCheckTime() error {
|
||||||
c.LastCheckTime = time.Now()
|
c.LastCheckTime = time.Now()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
// ==================== 常量定义 ====================
|
// ==================== 常量定义 ====================
|
||||||
|
|
||||||
// AppVersion 应用版本号(发布时直接修改此处)
|
// AppVersion 应用版本号(发布时直接修改此处)
|
||||||
const AppVersion = "0.3.0"
|
const AppVersion = "0.3.3"
|
||||||
|
|
||||||
// 版本号缓存
|
// 版本号缓存
|
||||||
var (
|
var (
|
||||||
@@ -64,47 +65,26 @@ func ParseVersion(versionStr string) (*Version, error) {
|
|||||||
return &Version{Major: major, Minor: minor, Patch: patch}, nil
|
return &Version{Major: major, Minor: minor, Patch: patch}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// String 返回版本号字符串(格式:v1.0.0)
|
|
||||||
func (v *Version) String() string {
|
|
||||||
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare 比较版本号
|
// Compare 比较版本号
|
||||||
// 返回值:-1 表示当前版本小于目标版本,0 表示相等,1 表示大于
|
// 返回值:-1 表示当前版本小于目标版本,0 表示相等,1 表示大于
|
||||||
func (v *Version) Compare(other *Version) int {
|
func (v *Version) Compare(other *Version) int {
|
||||||
switch {
|
switch {
|
||||||
case v.Major != other.Major:
|
case v.Major != other.Major:
|
||||||
return compareInt(v.Major, other.Major)
|
return cmp.Compare(v.Major, other.Major)
|
||||||
case v.Minor != other.Minor:
|
case v.Minor != other.Minor:
|
||||||
return compareInt(v.Minor, other.Minor)
|
return cmp.Compare(v.Minor, other.Minor)
|
||||||
case v.Patch != other.Patch:
|
case v.Patch != other.Patch:
|
||||||
return compareInt(v.Patch, other.Patch)
|
return cmp.Compare(v.Patch, other.Patch)
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compareInt 比较两个整数
|
|
||||||
func compareInt(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if a > b {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNewerThan 判断是否比目标版本新
|
// IsNewerThan 判断是否比目标版本新
|
||||||
func (v *Version) IsNewerThan(other *Version) bool {
|
func (v *Version) IsNewerThan(other *Version) bool {
|
||||||
return v.Compare(other) > 0
|
return v.Compare(other) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsOlderThan 判断是否比目标版本旧
|
|
||||||
func (v *Version) IsOlderThan(other *Version) bool {
|
|
||||||
return v.Compare(other) < 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 版本号获取 ====================
|
// ==================== 版本号获取 ====================
|
||||||
|
|
||||||
// GetCurrentVersion 获取当前版本号(带缓存)
|
// GetCurrentVersion 获取当前版本号(带缓存)
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"u-desk/internal/crypto"
|
|
||||||
"u-desk/internal/dbclient"
|
|
||||||
"u-desk/internal/storage/models"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConnectionService 连接管理服务
|
|
||||||
type ConnectionService struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConnectionService 创建连接服务
|
|
||||||
func NewConnectionService() (*ConnectionService, error) {
|
|
||||||
db := GetDB()
|
|
||||||
if db == nil {
|
|
||||||
// 尝试重新初始化
|
|
||||||
var err error
|
|
||||||
db, err = Init()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("数据库初始化失败: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &ConnectionService{db: db}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveConnection 保存连接配置
|
|
||||||
func (s *ConnectionService) SaveConnection(conn *models.DbConnection) error {
|
|
||||||
if conn.Name == "" {
|
|
||||||
return fmt.Errorf("连接名称不能为空")
|
|
||||||
}
|
|
||||||
if conn.Type == "" {
|
|
||||||
return fmt.Errorf("数据库类型不能为空")
|
|
||||||
}
|
|
||||||
if conn.Host == "" {
|
|
||||||
return fmt.Errorf("主机地址不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查名称是否重复(排除当前记录)
|
|
||||||
var count int64
|
|
||||||
query := s.db.Model(&models.DbConnection{}).Where("name = ?", conn.Name)
|
|
||||||
if conn.ID > 0 {
|
|
||||||
query = query.Where("id != ?", conn.ID)
|
|
||||||
}
|
|
||||||
query.Count(&count)
|
|
||||||
if count > 0 {
|
|
||||||
return fmt.Errorf("连接名称已存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
if conn.ID > 0 {
|
|
||||||
// 更新模式
|
|
||||||
updateData := map[string]interface{}{
|
|
||||||
"name": conn.Name,
|
|
||||||
"type": conn.Type,
|
|
||||||
"host": conn.Host,
|
|
||||||
"port": conn.Port,
|
|
||||||
"username": conn.Username,
|
|
||||||
"database": conn.Database,
|
|
||||||
"options": conn.Options,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果提供了新密码,加密后更新
|
|
||||||
if conn.Password != "" {
|
|
||||||
encrypted, err := crypto.EncryptPassword(conn.Password)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("密码加密失败: %v", err)
|
|
||||||
}
|
|
||||||
updateData["password"] = encrypted
|
|
||||||
}
|
|
||||||
// 如果密码为空,不更新密码字段(保留原密码)
|
|
||||||
|
|
||||||
return s.db.Model(&models.DbConnection{}).Where("id = ?", conn.ID).Updates(updateData).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增模式 - 必须提供密码
|
|
||||||
if conn.Password == "" {
|
|
||||||
return fmt.Errorf("新增连接时密码不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加密密码
|
|
||||||
encrypted, err := crypto.EncryptPassword(conn.Password)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("密码加密失败: %v", err)
|
|
||||||
}
|
|
||||||
conn.Password = encrypted
|
|
||||||
|
|
||||||
return s.db.Create(conn).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListConnections 获取连接列表
|
|
||||||
func (s *ConnectionService) ListConnections() ([]models.DbConnection, error) {
|
|
||||||
var connections []models.DbConnection
|
|
||||||
err := s.db.Order("created_at DESC").Find(&connections).Error
|
|
||||||
return connections, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConnection 获取连接详情
|
|
||||||
func (s *ConnectionService) GetConnection(id uint) (*models.DbConnection, error) {
|
|
||||||
var conn models.DbConnection
|
|
||||||
err := s.db.First(&conn, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &conn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteConnection 删除连接配置
|
|
||||||
func (s *ConnectionService) DeleteConnection(id uint) error {
|
|
||||||
return s.db.Delete(&models.DbConnection{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConnection 测试连接(需要根据类型调用不同的测试方法)
|
|
||||||
func (s *ConnectionService) TestConnection(conn *models.DbConnection) error {
|
|
||||||
// 解密密码用于测试
|
|
||||||
password, err := crypto.DecryptPassword(conn.Password)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("密码解密失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据类型测试连接
|
|
||||||
switch conn.Type {
|
|
||||||
case "mysql":
|
|
||||||
return testMySQLConnection(conn.Host, conn.Port, conn.Username, password, conn.Database)
|
|
||||||
case "redis":
|
|
||||||
return testRedisConnection(conn.Host, conn.Port, password)
|
|
||||||
case "mongo":
|
|
||||||
// 解析 Options 获取 MongoDB 连接参数
|
|
||||||
authSource := ""
|
|
||||||
authMechanism := ""
|
|
||||||
if conn.Options != "" {
|
|
||||||
var opts map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(conn.Options), &opts); err == nil {
|
|
||||||
if as, ok := opts["authSource"].(string); ok && as != "" {
|
|
||||||
authSource = as
|
|
||||||
}
|
|
||||||
if am, ok := opts["authMechanism"].(string); ok && am != "" {
|
|
||||||
authMechanism = am
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return testMongoConnection(conn.Host, conn.Port, conn.Username, password, conn.Database, authSource, authMechanism)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("不支持的数据库类型: %s", conn.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testMySQLConnection 测试 MySQL 连接
|
|
||||||
func testMySQLConnection(host string, port int, username, password, database string) error {
|
|
||||||
return dbclient.TestMySQLConnection(host, port, username, password, database)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testRedisConnection 测试 Redis 连接
|
|
||||||
func testRedisConnection(host string, port int, password string) error {
|
|
||||||
return dbclient.TestRedisConnection(host, port, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testMongoConnection 测试 MongoDB 连接
|
|
||||||
func testMongoConnection(host string, port int, username, password, database, authSource, authMechanism string) error {
|
|
||||||
return dbclient.TestMongoConnectionWithOptions(host, port, username, password, database, authSource, authMechanism)
|
|
||||||
}
|
|
||||||
@@ -6,17 +6,18 @@ import (
|
|||||||
|
|
||||||
// DbConnection 数据库连接配置
|
// DbConnection 数据库连接配置
|
||||||
type DbConnection struct {
|
type DbConnection struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"type:varchar(100);not null" json:"name"` // 连接名称
|
Name string `gorm:"type:varchar(100);not null" json:"name"` // 连接名称
|
||||||
Type string `gorm:"type:varchar(20);not null" json:"type"` // 数据库类型: mysql/redis/mongo
|
Type string `gorm:"type:varchar(20);not null" json:"type"` // 数据库类型: mysql/redis/mongo
|
||||||
Host string `gorm:"type:varchar(255);not null" json:"host"` // 主机地址
|
Host string `gorm:"type:varchar(255);not null" json:"host"` // 主机地址
|
||||||
Port int `gorm:"not null" json:"port"` // 端口
|
Port int `gorm:"not null" json:"port"` // 端口
|
||||||
Username string `gorm:"type:varchar(100)" json:"username"` // 用户名
|
Username string `gorm:"type:varchar(100)" json:"username"` // 用户名
|
||||||
Password string `gorm:"type:varchar(500)" json:"-"` // 密码(加密存储,不返回)
|
Password string `gorm:"type:varchar(500)" json:"-"` // 密码(加密存储,不返回)
|
||||||
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名(MySQL/MongoDB)
|
Database string `gorm:"type:varchar(100)" json:"database"` // 数据库名(MySQL/MongoDB)
|
||||||
Options string `gorm:"type:text" json:"options"` // 额外选项(JSON格式)
|
Options string `gorm:"type:text" json:"options"` // 额外选项(JSON格式)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
VisibleDatabases string `gorm:"type:text" json:"visible_databases"` // 可见数据库列表(JSON数组,为空则全部可见)
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SqlFile SQL 文件记录
|
|
||||||
type SqlFile struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
Name string `gorm:"type:varchar(200);not null" json:"name"` // 文件名
|
|
||||||
Path string `gorm:"type:varchar(500);not null;uniqueIndex" json:"path"` // 文件路径
|
|
||||||
Content string `gorm:"type:text" json:"content"` // 文件内容
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (SqlFile) TableName() string {
|
|
||||||
return "sql_file"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Version 版本信息
|
|
||||||
type Version struct {
|
|
||||||
ID int `gorm:"primaryKey" json:"id"` // 主键ID
|
|
||||||
Version string `gorm:"type:varchar(20);not null;uniqueIndex" json:"version"` // 版本号(语义化版本,如1.0.0)
|
|
||||||
DownloadURL string `gorm:"type:varchar(500)" json:"download_url"` // 下载地址(更新包下载URL)
|
|
||||||
Changelog string `gorm:"type:text" json:"changelog"` // 更新日志(Markdown格式)
|
|
||||||
ForceUpdate int `gorm:"type:tinyint;not null;default:0" json:"force_update"` // 是否强制更新(1:是 0:否)
|
|
||||||
ReleaseDate *time.Time `gorm:"type:date" json:"release_date"` // 发布日期
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime:false" json:"created_at"` // 创建时间(由程序设置)
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime:false" json:"updated_at"` // 更新时间(由程序设置)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定表名
|
|
||||||
func (Version) TableName() string {
|
|
||||||
return "sys_version"
|
|
||||||
}
|
|
||||||
@@ -5,17 +5,13 @@ import (
|
|||||||
"u-desk/internal/storage"
|
"u-desk/internal/storage"
|
||||||
"u-desk/internal/storage/models"
|
"u-desk/internal/storage/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ResultRepository interface {
|
type ResultRepository interface {
|
||||||
Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error)
|
Save(connectionID uint, database, sql string, resultType string, data interface{}, columns []string, rowsAffected int, executionTime int64) (*models.SqlResultHistory, error)
|
||||||
FindByID(id uint) (*models.SqlResultHistory, error)
|
FindByID(id uint) (*models.SqlResultHistory, error)
|
||||||
FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error)
|
|
||||||
Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error)
|
Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error)
|
||||||
Delete(id uint) error
|
Delete(id uint) error
|
||||||
DeleteByConnection(connectionID uint) error
|
|
||||||
DeleteOld(keepDays int) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type resultRepository struct {
|
type resultRepository struct {
|
||||||
@@ -61,15 +57,6 @@ func (r *resultRepository) FindByID(id uint) (*models.SqlResultHistory, error) {
|
|||||||
return &history, err
|
return &history, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *resultRepository) FindByConnection(connectionID uint, limit int) ([]models.SqlResultHistory, error) {
|
|
||||||
var histories []models.SqlResultHistory
|
|
||||||
query := r.db.Where("connection_id = ?", connectionID).Order("created_at DESC")
|
|
||||||
if limit > 0 {
|
|
||||||
query = query.Limit(limit)
|
|
||||||
}
|
|
||||||
return histories, query.Find(&histories).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *resultRepository) Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error) {
|
func (r *resultRepository) Search(connectionID *uint, keyword string, limit, offset int) ([]models.SqlResultHistory, int64, error) {
|
||||||
query := r.db.Model(&models.SqlResultHistory{})
|
query := r.db.Model(&models.SqlResultHistory{})
|
||||||
|
|
||||||
@@ -101,10 +88,3 @@ func (r *resultRepository) Delete(id uint) error {
|
|||||||
return r.db.Delete(&models.SqlResultHistory{}, id).Error
|
return r.db.Delete(&models.SqlResultHistory{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *resultRepository) DeleteByConnection(connectionID uint) error {
|
|
||||||
return r.db.Where("connection_id = ?", connectionID).Delete(&models.SqlResultHistory{}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *resultRepository) DeleteOld(keepDays int) error {
|
|
||||||
return r.db.Where("created_at < ?", time.Now().AddDate(0, 0, -keepDays)).Delete(&models.SqlResultHistory{}).Error
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ type TabRepository interface {
|
|||||||
SaveAll(tabs []models.SqlTab) error
|
SaveAll(tabs []models.SqlTab) error
|
||||||
FindAll() ([]models.SqlTab, error)
|
FindAll() ([]models.SqlTab, error)
|
||||||
Delete(id uint) error
|
Delete(id uint) error
|
||||||
DeleteAll() error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type tabRepository struct {
|
type tabRepository struct {
|
||||||
@@ -50,6 +49,3 @@ func (r *tabRepository) Delete(id uint) error {
|
|||||||
return r.db.Delete(&models.SqlTab{}, id).Error
|
return r.db.Delete(&models.SqlTab{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *tabRepository) DeleteAll() error {
|
|
||||||
return r.db.Where("1=1").Delete(&models.SqlTab{}).Error
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "u-desk",
|
"name": "u-desk",
|
||||||
"outputfilename": "u-desk",
|
"outputfilename": "u-desk",
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"frontend:install": "npm install",
|
"frontend:install": "npm install",
|
||||||
"frontend:build": "npm run build",
|
"frontend:build": "npm run build",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
31
web/.gitignore
vendored
Normal file
31
web/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# 自动生成的类型声明文件
|
||||||
|
auto-imports.d.ts
|
||||||
|
components.d.ts
|
||||||
|
|
||||||
|
# 缓存
|
||||||
|
*.log
|
||||||
|
*.cache
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# 编辑器
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>U-Desk</title>
|
<title>U-Desk</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🖥️</text></svg>" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
438
web/package-lock.json
generated
438
web/package-lock.json
generated
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arco-design/web-vue": "^2.54.0",
|
"@arco-design/web-vue": "^2.54.0",
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/highlight": "^0.19.8",
|
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-go": "^6.0.1",
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
@@ -26,18 +25,21 @@
|
|||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.5.3",
|
"@codemirror/state": "^6.5.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.8",
|
"@codemirror/view": "^6.39.8",
|
||||||
"@types/highlight.js": "^9.12.4",
|
|
||||||
"@types/mermaid": "^9.1.0",
|
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.26"
|
"vue": "^3.5.26",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/highlight.js": "^9.12.4",
|
||||||
|
"@types/mermaid": "^9.1.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"unplugin-auto-import": "^0.18.3",
|
"unplugin-auto-import": "^0.18.3",
|
||||||
"unplugin-vue-components": "^0.27.4",
|
"unplugin-vue-components": "^0.27.4",
|
||||||
@@ -211,71 +213,6 @@
|
|||||||
"@lezer/common": "^1.1.0"
|
"@lezer/common": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/highlight": {
|
|
||||||
"version": "0.19.8",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/highlight/-/highlight-0.19.8.tgz",
|
|
||||||
"integrity": "sha512-v/lzuHjrYR8MN2mEJcUD6fHSTXXli9C1XGYpr+ElV6fLBIUhMTNKR3qThp611xuWfXfwDxeL7ppcbkM/MzPV3A==",
|
|
||||||
"deprecated": "As of 0.20.0, this package has been split between @lezer/highlight and @codemirror/language",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/language": "^0.19.0",
|
|
||||||
"@codemirror/rangeset": "^0.19.0",
|
|
||||||
"@codemirror/state": "^0.19.3",
|
|
||||||
"@codemirror/view": "^0.19.39",
|
|
||||||
"@lezer/common": "^0.15.0",
|
|
||||||
"style-mod": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/highlight/node_modules/@codemirror/language": {
|
|
||||||
"version": "0.19.10",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-0.19.10.tgz",
|
|
||||||
"integrity": "sha512-yA0DZ3RYn2CqAAGW62VrU8c4YxscMQn45y/I9sjBlqB1e2OTQLg4CCkMBuMSLXk4xaqjlsgazeOQWaJQOKfV8Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/state": "^0.19.0",
|
|
||||||
"@codemirror/text": "^0.19.0",
|
|
||||||
"@codemirror/view": "^0.19.0",
|
|
||||||
"@lezer/common": "^0.15.5",
|
|
||||||
"@lezer/lr": "^0.15.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/highlight/node_modules/@codemirror/state": {
|
|
||||||
"version": "0.19.9",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-0.19.9.tgz",
|
|
||||||
"integrity": "sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/text": "^0.19.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/highlight/node_modules/@codemirror/view": {
|
|
||||||
"version": "0.19.48",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-0.19.48.tgz",
|
|
||||||
"integrity": "sha512-0eg7D2Nz4S8/caetCTz61rK0tkHI17V/d15Jy0kLOT8dTLGGNJUponDnW28h2B6bERmPlVHKh8MJIr5OCp1nGw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/rangeset": "^0.19.5",
|
|
||||||
"@codemirror/state": "^0.19.3",
|
|
||||||
"@codemirror/text": "^0.19.0",
|
|
||||||
"style-mod": "^4.0.0",
|
|
||||||
"w3c-keyname": "^2.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/highlight/node_modules/@lezer/common": {
|
|
||||||
"version": "0.15.12",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-0.15.12.tgz",
|
|
||||||
"integrity": "sha512-edfwCxNLnzq5pBA/yaIhwJ3U3Kz8VAUOTRg0hhxaizaI1N+qxV7EXDv/kLCkLeq2RzSFvxexlaj5Mzfn2kY0Ig==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/highlight/node_modules/@lezer/lr": {
|
|
||||||
"version": "0.15.8",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-0.15.8.tgz",
|
|
||||||
"integrity": "sha512-bM6oE6VQZ6hIFxDNKk8bKPa14hqFrV07J/vHGOeiAbJReIaQXmkVb6xQu4MR+JBTLa5arGRyAAjJe1qaQt3Uvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@lezer/common": "^0.15.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/lang-cpp": {
|
"node_modules/@codemirror/lang-cpp": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
|
||||||
@@ -478,23 +415,15 @@
|
|||||||
"crelt": "^1.0.5"
|
"crelt": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/rangeset": {
|
"node_modules/@codemirror/search": {
|
||||||
"version": "0.19.9",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/rangeset/-/rangeset-0.19.9.tgz",
|
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz",
|
||||||
"integrity": "sha512-V8YUuOvK+ew87Xem+71nKcqu1SXd5QROMRLMS/ljT5/3MCxtgrRie1Cvild0G/Z2f1fpWxzX78V0U4jjXBorBQ==",
|
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||||
"deprecated": "As of 0.20.0, this package has been merged into @codemirror/state",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^0.19.0"
|
"@codemirror/state": "^6.0.0",
|
||||||
}
|
"@codemirror/view": "^6.37.0",
|
||||||
},
|
"crelt": "^1.0.5"
|
||||||
"node_modules/@codemirror/rangeset/node_modules/@codemirror/state": {
|
|
||||||
"version": "0.19.9",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-0.19.9.tgz",
|
|
||||||
"integrity": "sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/text": "^0.19.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/state": {
|
"node_modules/@codemirror/state": {
|
||||||
@@ -506,13 +435,6 @@
|
|||||||
"@marijn/find-cluster-break": "^1.0.0"
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/text": {
|
|
||||||
"version": "0.19.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/text/-/text-0.19.6.tgz",
|
|
||||||
"integrity": "sha512-T9jnREMIygx+TPC1bOuepz18maGq/92q2a+n4qTqObKwvNMg+8cMTslb8yxeEDEq7S3kpgGWxgO1UWbQRij0dA==",
|
|
||||||
"deprecated": "As of 0.20.0, this package has been merged into @codemirror/state",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@codemirror/theme-one-dark": {
|
"node_modules/@codemirror/theme-one-dark": {
|
||||||
"version": "6.1.3",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||||
@@ -1530,12 +1452,14 @@
|
|||||||
"version": "9.12.4",
|
"version": "9.12.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/highlight.js/-/highlight.js-9.12.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/highlight.js/-/highlight.js-9.12.4.tgz",
|
||||||
"integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
|
"integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/mermaid": {
|
"node_modules/@types/mermaid": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/mermaid/-/mermaid-9.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/mermaid/-/mermaid-9.1.0.tgz",
|
||||||
"integrity": "sha512-rc8QqhveKAY7PouzY/p8ljS+eBSNCv7o79L97RSub/Ic2SQ34ph1Ng3s8wFLWVjvaEt6RLOWtSCsgYWd95NY8A==",
|
"integrity": "sha512-rc8QqhveKAY7PouzY/p8ljS+eBSNCv7o79L97RSub/Ic2SQ34ph1Ng3s8wFLWVjvaEt6RLOWtSCsgYWd95NY8A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
@@ -1677,6 +1601,15 @@
|
|||||||
"version": "3.5.26",
|
"version": "3.5.26",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.11",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||||
|
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -1689,6 +1622,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -1716,6 +1658,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/b-tween": {
|
"node_modules/b-tween": {
|
||||||
"version": "0.3.3",
|
"version": "0.3.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -1731,6 +1682,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -1753,6 +1724,12 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.4.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
@@ -1776,6 +1753,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chevrotain": {
|
"node_modules/chevrotain": {
|
||||||
"version": "11.0.3",
|
"version": "11.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-11.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-11.0.3.tgz",
|
||||||
@@ -1833,6 +1823,15 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1894,6 +1893,12 @@
|
|||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cose-base": {
|
"node_modules/cose-base": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz",
|
||||||
@@ -1903,6 +1908,18 @@
|
|||||||
"layout-base": "^1.0.0"
|
"layout-base": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crelt": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
|
||||||
@@ -2445,6 +2462,12 @@
|
|||||||
"robust-predicates": "^3.0.2"
|
"robust-predicates": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dingbat-to-unicode": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
@@ -2454,6 +2477,15 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duck": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||||
|
"license": "BSD",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
@@ -2588,6 +2620,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2649,6 +2690,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internmap": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
|
||||||
@@ -2720,6 +2773,12 @@
|
|||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
@@ -2727,6 +2786,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/katex": {
|
"node_modules/katex": {
|
||||||
"version": "0.16.28",
|
"version": "0.16.28",
|
||||||
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.28.tgz",
|
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.28.tgz",
|
||||||
@@ -2779,6 +2850,15 @@
|
|||||||
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
|
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/local-pkg": {
|
"node_modules/local-pkg": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz",
|
||||||
@@ -2802,6 +2882,17 @@
|
|||||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lop": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"duck": "^0.1.12",
|
||||||
|
"option": "~0.2.1",
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2809,6 +2900,30 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mammoth": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.6",
|
||||||
|
"argparse": "~1.0.3",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
|
"bluebird": "~3.4.0",
|
||||||
|
"dingbat-to-unicode": "^1.0.1",
|
||||||
|
"jszip": "^3.7.1",
|
||||||
|
"lop": "^0.4.2",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"underscore": "^1.13.1",
|
||||||
|
"xmlbuilder": "^10.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mammoth": "bin/mammoth"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "17.0.1",
|
"version": "17.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz",
|
||||||
@@ -2969,18 +3084,39 @@
|
|||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/option": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-data-parser": {
|
"node_modules/path-data-parser": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz",
|
||||||
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
|
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -3084,6 +3220,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/quansync": {
|
"node_modules/quansync": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||||
@@ -3122,6 +3264,21 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -3258,6 +3415,12 @@
|
|||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -3278,6 +3441,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/simple-swizzle": {
|
"node_modules/simple-swizzle": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3301,6 +3470,33 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-literal": {
|
"node_modules/strip-literal": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz",
|
||||||
@@ -3392,6 +3588,12 @@
|
|||||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unimport": {
|
"node_modules/unimport": {
|
||||||
"version": "3.14.6",
|
"version": "3.14.6",
|
||||||
"resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",
|
"resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",
|
||||||
@@ -3549,6 +3751,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz",
|
||||||
@@ -3719,6 +3927,54 @@
|
|||||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arco-design/web-vue": "^2.54.0",
|
"@arco-design/web-vue": "^2.54.0",
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/highlight": "^0.19.8",
|
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-go": "^6.0.1",
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
@@ -26,21 +25,24 @@
|
|||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.5.3",
|
"@codemirror/state": "^6.5.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.8",
|
"@codemirror/view": "^6.39.8",
|
||||||
"@types/highlight.js": "^9.12.4",
|
|
||||||
"@types/mermaid": "^9.1.0",
|
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.26"
|
"vue": "^3.5.26",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/highlight.js": "^9.12.4",
|
||||||
|
"@types/mermaid": "^9.1.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"unplugin-vue-components": "^0.27.4",
|
|
||||||
"unplugin-auto-import": "^0.18.3",
|
"unplugin-auto-import": "^0.18.3",
|
||||||
|
"unplugin-vue-components": "^0.27.4",
|
||||||
"vite": "^7.3.0"
|
"vite": "^7.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
db157c3d15eff27c46a5fa33f3b95e47
|
c0e9e27e045c6118704c87fcf34a03de
|
||||||
@@ -20,6 +20,13 @@
|
|||||||
</template>
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</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/>
|
<ThemeToggle/>
|
||||||
|
|
||||||
<!-- 窗口控制按钮 -->
|
<!-- 窗口控制按钮 -->
|
||||||
@@ -60,8 +67,20 @@
|
|||||||
v-model="showSettings"
|
v-model="showSettings"
|
||||||
:config="configStore.appConfig"
|
:config="configStore.appConfig"
|
||||||
@save="handleSaveConfig"
|
@save="handleSaveConfig"
|
||||||
|
@open-version-history="showVersionHistory = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 版本历史抽屉 -->
|
||||||
|
<a-drawer
|
||||||
|
v-model:visible="showVersionHistory"
|
||||||
|
:width="720"
|
||||||
|
:footer="false"
|
||||||
|
:unmount-on-close="false"
|
||||||
|
title="版本历史"
|
||||||
|
>
|
||||||
|
<VersionHistory />
|
||||||
|
</a-drawer>
|
||||||
|
|
||||||
<!-- 升级提示弹窗 -->
|
<!-- 升级提示弹窗 -->
|
||||||
<UpdateNotification
|
<UpdateNotification
|
||||||
v-model="updateStore.showUpdate"
|
v-model="updateStore.showUpdate"
|
||||||
@@ -71,26 +90,30 @@
|
|||||||
</a-layout>
|
</a-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, watch, ref, onMounted, onUnmounted } from 'vue'
|
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 { Message } from '@arco-design/web-vue'
|
import MarkdownEditor from './views/markdown-editor/index.vue'
|
||||||
import DbCli from './views/db-cli/index.vue'
|
import DbCli from './views/db-cli/index.vue'
|
||||||
|
import VersionHistory from './views/version/index.vue'
|
||||||
import ThemeToggle from './components/ThemeToggle.vue'
|
import ThemeToggle from './components/ThemeToggle.vue'
|
||||||
import FileSystem from './components/FileSystem/index.vue'
|
import FileSystem from './components/FileSystem/index.vue'
|
||||||
import SettingsPanel from './components/SettingsPanel.vue'
|
import SettingsPanel from './components/SettingsPanel.vue'
|
||||||
import UpdateNotification from './components/UpdateNotification.vue'
|
import UpdateNotification from './components/UpdateNotification.vue'
|
||||||
import { useUpdateStore } from './stores/update'
|
import {useUpdateStore} from './stores/update'
|
||||||
import { useConfigStore } from './stores/config'
|
import {useConfigStore, type AppConfig} from './stores/config'
|
||||||
|
|
||||||
// 存储键
|
// 存储键
|
||||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||||
|
|
||||||
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
||||||
|
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移
|
||||||
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const showVersionHistory = ref(false)
|
||||||
const isMaximized = ref(false)
|
const isMaximized = ref(false)
|
||||||
|
const isPinned = ref(false)
|
||||||
|
|
||||||
// 使用 stores
|
// 使用 stores
|
||||||
const updateStore = useUpdateStore()
|
const updateStore = useUpdateStore()
|
||||||
@@ -103,7 +126,7 @@ const appConfig = computed(() => configStore.appConfig)
|
|||||||
const visibleTabs = computed(() => configStore.visibleTabs)
|
const visibleTabs = computed(() => configStore.visibleTabs)
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
const handleSaveConfig = async (config) => {
|
const handleSaveConfig = async (config: AppConfig) => {
|
||||||
try {
|
try {
|
||||||
await configStore.saveConfig(config)
|
await configStore.saveConfig(config)
|
||||||
showSettings.value = false
|
showSettings.value = false
|
||||||
@@ -126,21 +149,30 @@ const loadConfig = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取组件
|
// 获取组件
|
||||||
const getComponent = (key) => {
|
const getComponent = (key: string) => {
|
||||||
const components = {
|
const components = {
|
||||||
'file-system': FileSystem,
|
'file-system': FileSystem,
|
||||||
'db-cli': DbCli
|
'db-cli': DbCli,
|
||||||
|
'markdown-editor': MarkdownEditor
|
||||||
}
|
}
|
||||||
return components[key] || null
|
return components[key] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时加载配置
|
// 组件挂载时加载配置
|
||||||
|
// 禁止 Ctrl+滚轮缩放
|
||||||
|
const preventZoom = (e: WheelEvent) => {
|
||||||
|
if (e.ctrlKey) e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
|
|
||||||
// 设置更新事件监听
|
// 设置更新事件监听
|
||||||
updateStore.setupEventListeners()
|
updateStore.setupEventListeners()
|
||||||
|
|
||||||
|
// 禁止 Ctrl+滚轮缩放
|
||||||
|
document.addEventListener('wheel', preventZoom, { passive: false })
|
||||||
|
|
||||||
// 延迟检查更新(启动后 3 秒,静默模式)
|
// 延迟检查更新(启动后 3 秒,静默模式)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
updateStore.checkForUpdates(true)
|
updateStore.checkForUpdates(true)
|
||||||
@@ -149,7 +181,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 组件卸载时清理事件监听
|
// 组件卸载时清理事件监听
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('wheel', preventZoom)
|
||||||
updateStore.removeEventListeners()
|
updateStore.removeEventListeners()
|
||||||
|
// 兜底清除所有 Wails 事件监听器,防止泄漏
|
||||||
|
window.runtime?.EventsOffAll?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 窗口控制方法
|
// 窗口控制方法
|
||||||
@@ -163,6 +198,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 () => {
|
const handleMaximize = async () => {
|
||||||
try {
|
try {
|
||||||
if (window.go?.main?.App?.WindowMaximize) {
|
if (window.go?.main?.App?.WindowMaximize) {
|
||||||
@@ -189,10 +234,9 @@ watch(activeTab, (newTab) => {
|
|||||||
// 保存到 localStorage
|
// 保存到 localStorage
|
||||||
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, newTab)
|
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, newTab)
|
||||||
|
|
||||||
// 检查 Tab 是否在可见列表中
|
// 检查一级 Tab 是否在可见列表中
|
||||||
const isVisible = appConfig.value.visibleTabs.includes(newTab)
|
const isVisible = appConfig.value.visibleTabs.includes(newTab)
|
||||||
if (!isVisible && appConfig.value.visibleTabs.length > 0 && newTab !== appConfig.value.defaultTab) {
|
if (!isVisible && appConfig.value.visibleTabs.length > 0 && newTab !== appConfig.value.defaultTab) {
|
||||||
// 切换到默认 Tab(避免重复触发)
|
|
||||||
activeTab.value = appConfig.value.defaultTab
|
activeTab.value = appConfig.value.defaultTab
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -279,6 +323,25 @@ watch(activeTab, (newTab) => {
|
|||||||
color: #ffffff;
|
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 {
|
.window-control-btn svg {
|
||||||
display: block;
|
display: block;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -314,4 +377,9 @@ watch(activeTab, (newTab) => {
|
|||||||
.arco-tooltip {
|
.arco-tooltip {
|
||||||
--wails-draggable: no-drag;
|
--wails-draggable: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
|
||||||
|
html, body {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SystemInfo, CPU, Memory, Disk, File } from './types'
|
import type { SystemInfo, CPU, Memory, Disk, File } from './types'
|
||||||
|
import { debugError } from '@/utils/debugLog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
* 转换后端文件数据格式(蛇形 → 驼峰)
|
||||||
@@ -11,7 +12,8 @@ import type { SystemInfo, CPU, Memory, Disk, File } from './types'
|
|||||||
function transformFile(file: any): File {
|
function transformFile(file: any): File {
|
||||||
return {
|
return {
|
||||||
...file,
|
...file,
|
||||||
isDir: file.is_dir
|
isDir: file.is_dir,
|
||||||
|
modified_time: file.mod_time
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,22 @@ export async function writeFile(path: string, content: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 Base64 编码的二进制文件(图片等)
|
||||||
|
*/
|
||||||
|
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
||||||
|
if (!window.go?.main?.App?.SaveBase64File) {
|
||||||
|
throw new Error('SaveBase64File API 不可用')
|
||||||
|
}
|
||||||
|
if (!base64Content) {
|
||||||
|
throw new Error('无效的 base64 内容')
|
||||||
|
}
|
||||||
|
await window.go.main.App.SaveBase64File({
|
||||||
|
path: String(path),
|
||||||
|
content: base64Content
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除文件或目录
|
* 删除文件或目录
|
||||||
*/
|
*/
|
||||||
@@ -109,23 +127,25 @@ export async function deletePath(path: string): Promise<any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建目录
|
* 创建目录(parentPath + dirname 拼接为完整路径)
|
||||||
*/
|
*/
|
||||||
export async function createDir(path: string): Promise<any> {
|
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.CreateDir) {
|
if (!window.go?.main?.App?.CreateDir) {
|
||||||
throw new Error('CreateDir API 不可用')
|
throw new Error('CreateDir API 不可用')
|
||||||
}
|
}
|
||||||
return await window.go.main.App.CreateDir(path)
|
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||||
|
return await window.go.main.App.CreateDir(fullPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建文件
|
* 创建文件(dirPath + filename 拼接为完整路径)
|
||||||
*/
|
*/
|
||||||
export async function createFile(path: string): Promise<any> {
|
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
||||||
if (!window.go?.main?.App?.CreateFile) {
|
if (!window.go?.main?.App?.CreateFile) {
|
||||||
throw new Error('CreateFile API 不可用')
|
throw new Error('CreateFile API 不可用')
|
||||||
}
|
}
|
||||||
return await window.go.main.App.CreateFile(path)
|
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||||
|
return await window.go.main.App.CreateFile(fullPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,7 +182,7 @@ export async function listZipContents(zipPath: string): Promise<File[]> {
|
|||||||
const result = await window.go.main.App.ListZipContents(zipPath)
|
const result = await window.go.main.App.ListZipContents(zipPath)
|
||||||
return transformFileList(result)
|
return transformFileList(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] listZipContents 错误:', error)
|
debugError('[API] listZipContents 错误:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +198,7 @@ export async function extractFileFromZip(zipPath: string, filePath: string): Pro
|
|||||||
const result = await window.go.main.App.ExtractFileFromZip(zipPath, filePath)
|
const result = await window.go.main.App.ExtractFileFromZip(zipPath, filePath)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] extractFileFromZip 错误:', error)
|
debugError('[API] extractFileFromZip 错误:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +215,7 @@ export async function extractFileFromZipToTemp(zipPath: string, filePath: string
|
|||||||
const result = await window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
|
const result = await window.go.main.App.ExtractFileFromZipToTemp(zipPath, filePath)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] extractFileFromZipToTemp 错误:', error)
|
debugError('[API] extractFileFromZipToTemp 错误:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +231,7 @@ export async function getZipFileInfo(zipPath: string, filePath: string): Promise
|
|||||||
const result = await window.go.main.App.GetZipFileInfo(zipPath, filePath)
|
const result = await window.go.main.App.GetZipFileInfo(zipPath, filePath)
|
||||||
return transformFile(result)
|
return transformFile(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] getZipFileInfo 错误:', error)
|
debugError('[API] getZipFileInfo 错误:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +246,7 @@ export async function openPath(path: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await window.go.main.App.OpenPath(path)
|
await window.go.main.App.OpenPath(path)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] openPath 错误:', error)
|
debugError('[API] openPath 错误:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,7 +279,7 @@ export async function resolveShortcut(lnkPath: string): Promise<{
|
|||||||
const result = await window.go.main.App.ResolveShortcut(lnkPath)
|
const result = await window.go.main.App.ResolveShortcut(lnkPath)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] resolveShortcut 错误:', error)
|
debugError('[API] resolveShortcut 错误:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,7 +300,7 @@ export async function detectFileTypeByContent(path: string): Promise<{
|
|||||||
const result = await window.go.main.App.DetectFileTypeByContent(path)
|
const result = await window.go.main.App.DetectFileTypeByContent(path)
|
||||||
return result as any
|
return result as any
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] detectFileTypeByContent 错误:', error)
|
debugError('[API] detectFileTypeByContent 错误:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,4 +105,5 @@ export interface File {
|
|||||||
size: number
|
size: number
|
||||||
isDir: boolean
|
isDir: boolean
|
||||||
modified?: string
|
modified?: string
|
||||||
|
modified_time?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,111 +3,301 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, onBeforeUnmount, computed, nextTick } from 'vue'
|
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { EditorView, lineNumbers, highlightActiveLineGutter, keymap } from '@codemirror/view'
|
import {
|
||||||
import { EditorState } from '@codemirror/state'
|
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
|
||||||
import { defaultKeymap, history } from '@codemirror/commands'
|
EditorState, Compartment,
|
||||||
import { bracketMatching } from '@codemirror/language'
|
defaultKeymap, history,
|
||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
|
||||||
|
oneDark,
|
||||||
|
openSearchPanel, search
|
||||||
|
} from '@/utils/codemirrorExports'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
|
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
|
||||||
|
|
||||||
|
// ==================== Props & Emits ====================
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, required: true },
|
modelValue: { type: String, required: true },
|
||||||
fileExtension: { type: String, default: '' }
|
fileExtension: { type: String, default: '' },
|
||||||
|
filePath: { type: String, default: '' },
|
||||||
|
fileMtime: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
// ==================== 状态管理 ====================
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const editorContainer = ref(null)
|
const editorContainer = ref(null)
|
||||||
let view = null
|
let view = null
|
||||||
|
|
||||||
const createExtensions = async () => {
|
// 滚动位置缓存:LRU 最多 5 份,每份 3 分钟过期
|
||||||
|
const MAX_SCROLL_CACHE = 5
|
||||||
|
const SCROLL_CACHE_TTL = 3 * 60 * 1000 // 3 分钟
|
||||||
|
const fileScrollPositions = new Map() // filePath → { scrollTop, anchor, timestamp }
|
||||||
|
let currentFilePath = ''
|
||||||
|
let saveScrollTimer = null
|
||||||
|
|
||||||
|
// 清理过期缓存 + LRU 淘汰,保持最多 MAX_SCROLL_CACHE 条
|
||||||
|
const cleanScrollCache = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
// 清理过期的
|
||||||
|
for (const [key, val] of fileScrollPositions) {
|
||||||
|
if (now - val.timestamp > SCROLL_CACHE_TTL) {
|
||||||
|
fileScrollPositions.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LRU:超出上限时删除最旧的
|
||||||
|
if (fileScrollPositions.size > MAX_SCROLL_CACHE) {
|
||||||
|
let oldestKey = null
|
||||||
|
let oldestTime = Infinity
|
||||||
|
for (const [key, val] of fileScrollPositions) {
|
||||||
|
if (val.timestamp < oldestTime) {
|
||||||
|
oldestTime = val.timestamp
|
||||||
|
oldestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey) fileScrollPositions.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Compartment 实现动态切换,避免重建编辑器
|
||||||
|
const themeCompartment = new Compartment()
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
// ==================== 防抖处理 ====================
|
||||||
|
|
||||||
|
let emitTimeout = null
|
||||||
|
const debouncedEmit = (value) => {
|
||||||
|
if (emitTimeout) {
|
||||||
|
clearTimeout(emitTimeout)
|
||||||
|
}
|
||||||
|
emitTimeout = setTimeout(() => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前主题扩展
|
||||||
|
const getThemeExtension = () => {
|
||||||
|
if (themeStore.isDark) {
|
||||||
|
return [oneDark]
|
||||||
|
} else {
|
||||||
|
// 亮色主题:使用默认语法高亮样式
|
||||||
|
return [
|
||||||
|
EditorView.theme({
|
||||||
|
'&': { backgroundColor: '#ffffff' },
|
||||||
|
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
||||||
|
'.cm-line': { caretColor: '#000' },
|
||||||
|
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
||||||
|
'.cm-cursor': { borderLeftColor: '#000' }
|
||||||
|
}),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 扩展配置 ====================
|
||||||
|
|
||||||
|
const createExtensions = () => {
|
||||||
const extensions = [
|
const extensions = [
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
highlightActiveLineGutter(),
|
highlightActiveLineGutter(),
|
||||||
history(),
|
history(),
|
||||||
keymap.of(defaultKeymap),
|
keymap.of(defaultKeymap),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
|
|
||||||
|
// 查找替换(Ctrl+F / Ctrl+H)
|
||||||
|
search(),
|
||||||
|
|
||||||
|
// 内容更新监听(带防抖)
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
emit('update:modelValue', update.state.doc.toString())
|
debouncedEmit(update.state.doc.toString())
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 基础样式
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
'&': { height: '100%', fontSize: '13px' },
|
'&': { height: '100%', fontSize: '13px' },
|
||||||
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
||||||
'.cm-content': { padding: '8px', minHeight: '100%' },
|
'.cm-content': { padding: '8px' },
|
||||||
'.cm-line': { padding: '0 0' },
|
'.cm-line': { padding: '0 0' },
|
||||||
'&.cm-focused': { outline: 'none' }
|
'&.cm-focused': { outline: 'none' }
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
// 使用 Compartment 支持动态切换主题
|
||||||
|
themeCompartment.of(getThemeExtension()),
|
||||||
|
|
||||||
|
// 使用 Compartment 支持动态切换语言
|
||||||
|
languageCompartment.of([])
|
||||||
]
|
]
|
||||||
|
|
||||||
if (themeStore.isDark) {
|
|
||||||
extensions.push(oneDark)
|
|
||||||
}
|
|
||||||
|
|
||||||
const language = getLanguageFromExtension(props.fileExtension)
|
|
||||||
if (language !== 'text') {
|
|
||||||
const langExtension = await loadLanguageExtension(language)
|
|
||||||
if (langExtension) {
|
|
||||||
extensions.push(langExtension)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return extensions
|
return extensions
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEditor = async (docContent = '') => {
|
// ==================== 语言管理 ====================
|
||||||
|
|
||||||
|
const initLanguage = async () => {
|
||||||
|
const language = getLanguageFromExtension(props.fileExtension)
|
||||||
|
if (language === 'text') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const langExtension = await loadLanguageExtension(language)
|
||||||
|
if (langExtension && view) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: languageCompartment.reconfigure(langExtension)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[CodeEditor] 加载语言包失败: ${language}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 编辑器创建 ====================
|
||||||
|
|
||||||
|
const createEditor = (docContent = '') => {
|
||||||
if (!editorContainer.value) return
|
if (!editorContainer.value) return
|
||||||
|
|
||||||
const extensions = await createExtensions()
|
const state = EditorState.create({
|
||||||
const state = EditorState.create({ doc: docContent, extensions })
|
doc: docContent,
|
||||||
|
extensions: createExtensions()
|
||||||
|
})
|
||||||
|
|
||||||
view = new EditorView({ state, parent: editorContainer.value })
|
view = new EditorView({ state, parent: editorContainer.value })
|
||||||
|
|
||||||
|
// 滚动时防抖保存位置
|
||||||
|
view.scrollDOM.addEventListener('scroll', () => {
|
||||||
|
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||||
|
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
// 初始化语言
|
||||||
|
initLanguage()
|
||||||
}
|
}
|
||||||
|
|
||||||
const recreateEditor = async () => {
|
// ==================== 生命周期 ====================
|
||||||
if (!view) return
|
|
||||||
const currentDoc = view.state.doc.toString()
|
|
||||||
view.destroy()
|
|
||||||
await createEditor(currentDoc)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
await createEditor(props.modelValue || '')
|
createEditor(props.modelValue || '')
|
||||||
|
|
||||||
|
// 确保主题正确应用(在下一 tick)
|
||||||
|
nextTick(() => {
|
||||||
|
if (view) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (emitTimeout) clearTimeout(emitTimeout)
|
||||||
|
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||||
|
if (view?.scrollDOM) {
|
||||||
|
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
|
||||||
|
}
|
||||||
view?.destroy()
|
view?.destroy()
|
||||||
|
view = null
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, (newValue) => {
|
// ==================== 监听器 ====================
|
||||||
if (view && newValue !== view.state.doc.toString()) {
|
|
||||||
|
// 保存当前文件滚动位置(防抖)
|
||||||
|
const saveScrollPosition = () => {
|
||||||
|
if (!view || !currentFilePath) return
|
||||||
|
const scroller = view.scrollDOM
|
||||||
|
if (!scroller) return
|
||||||
|
fileScrollPositions.set(currentFilePath, {
|
||||||
|
scrollTop: scroller.scrollTop,
|
||||||
|
anchor: view.state.selection.main.anchor,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
cleanScrollCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听外部内容变化(切换文件/文件变更时触发)
|
||||||
|
watch([() => props.modelValue, () => props.fileMtime], ([newValue, newMtime], [oldValue, oldMtime]) => {
|
||||||
|
// 文件修改时间变了 → 说明磁盘内容有变更 → 强制刷新
|
||||||
|
const mtimeChanged = newMtime && oldMtime && newMtime !== oldMtime
|
||||||
|
if (view && (mtimeChanged || newValue !== view.state.doc.toString())) {
|
||||||
|
// 先保存旧文件的滚动位置
|
||||||
|
saveScrollPosition()
|
||||||
|
|
||||||
|
const newPath = props.filePath || ''
|
||||||
|
const isSameFile = currentFilePath && currentFilePath === newPath
|
||||||
|
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }
|
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' },
|
||||||
|
selection: { anchor: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
currentFilePath = newPath
|
||||||
|
|
||||||
|
if (isSameFile && fileScrollPositions.has(newPath)) {
|
||||||
|
// 同一文件 → 检查是否过期,未过期则恢复位置
|
||||||
|
const saved = fileScrollPositions.get(newPath)
|
||||||
|
if (saved && Date.now() - saved.timestamp <= SCROLL_CACHE_TTL) {
|
||||||
|
nextTick(() => {
|
||||||
|
if (view) {
|
||||||
|
view.dispatch({
|
||||||
|
selection: { anchor: saved.anchor },
|
||||||
|
effects: EditorView.scrollIntoView(saved.anchor)
|
||||||
|
})
|
||||||
|
view.scrollDOM.scrollTop = saved.scrollTop
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 过期了 → 强制滚动到顶部
|
||||||
|
nextTick(() => {
|
||||||
|
if (view) view.scrollDOM.scrollTop = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 不同文件 → 强制滚动到顶部(scrollIntoView 不一定重置 DOM scrollTop)
|
||||||
|
nextTick(() => {
|
||||||
|
if (view) {
|
||||||
|
view.scrollDOM.scrollTop = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听主题变化(使用 Compartment 重建,不丢失状态)
|
||||||
|
watch(() => themeStore.isDark, () => {
|
||||||
|
if (view) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const isDark = computed(() => themeStore.isDark)
|
// 监听文件扩展名变化(重新加载语言)
|
||||||
watch([isDark, () => props.fileExtension], async () => {
|
watch(() => props.fileExtension, () => {
|
||||||
await nextTick()
|
initLanguage()
|
||||||
await recreateEditor()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.codemirror-editor {
|
.codemirror-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-editor :deep(.cm-editor) {
|
.codemirror-editor :deep(.cm-editor) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codemirror-editor :deep(.cm-scroller) {
|
.codemirror-editor :deep(.cm-scroller) {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-editor :deep(.cm-content) {
|
||||||
|
/* 不设 height,让 CodeMirror 虚拟滚动自行计算文档高度 */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,720 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="device-test">
|
|
||||||
<!-- 系统信息 -->
|
|
||||||
<a-card class="test-card" title="系统信息">
|
|
||||||
<a-space direction="vertical" :size="16" style="width: 100%">
|
|
||||||
<a-button type="primary" @click="refreshSystemInfo">刷新系统信息</a-button>
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-card size="small" title="CPU 信息">
|
|
||||||
<div v-if="cpuInfo">
|
|
||||||
<p>核心数: {{ cpuInfo.cores }}</p>
|
|
||||||
<p>型号: {{ cpuInfo.model }}</p>
|
|
||||||
<p>使用率: {{ cpuInfo.usage }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else>加载中...</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-card size="small" title="内存信息">
|
|
||||||
<div v-if="memoryInfo">
|
|
||||||
<p>总内存: {{ memoryInfo.total_str }}</p>
|
|
||||||
<p>已用: {{ memoryInfo.used_str }}</p>
|
|
||||||
<p>可用: {{ memoryInfo.available_str }}</p>
|
|
||||||
<p>使用率: {{ memoryInfo.usage }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else>加载中...</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-card size="small" title="系统信息">
|
|
||||||
<div v-if="systemInfo">
|
|
||||||
<p>操作系统: {{ systemInfo.os }}</p>
|
|
||||||
<p>架构: {{ systemInfo.arch }}</p>
|
|
||||||
<p>主机名: {{ systemInfo.hostname }}</p>
|
|
||||||
<p>平台: {{ systemInfo.platform }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else>加载中...</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
<a-card size="small" title="磁盘信息" v-if="diskInfo && diskInfo.length > 0">
|
|
||||||
<a-table
|
|
||||||
:columns="diskColumns"
|
|
||||||
:data="diskInfo"
|
|
||||||
:pagination="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</a-card>
|
|
||||||
</a-space>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 文件系统操作 -->
|
|
||||||
<a-card class="test-card" title="文件系统操作">
|
|
||||||
<a-space direction="vertical" :size="16" style="width: 100%">
|
|
||||||
<a-input-group>
|
|
||||||
<a-auto-complete
|
|
||||||
v-model="filePath"
|
|
||||||
:data="pathHistory"
|
|
||||||
placeholder="输入文件或目录路径"
|
|
||||||
style="flex: 1"
|
|
||||||
@select="onPathSelect"
|
|
||||||
/>
|
|
||||||
<a-button @click="browseDirectory">浏览</a-button>
|
|
||||||
<a-button type="primary" @click="listDirectory">列出目录</a-button>
|
|
||||||
</a-input-group>
|
|
||||||
|
|
||||||
<!-- 收藏的文件 -->
|
|
||||||
<a-card size="small" title="⭐ 收藏的文件" v-if="favoriteFiles.length > 0">
|
|
||||||
<a-space wrap>
|
|
||||||
<a-tag
|
|
||||||
v-for="fav in favoriteFiles"
|
|
||||||
:key="fav.path"
|
|
||||||
closable
|
|
||||||
@close="removeFavorite(fav.path)"
|
|
||||||
@click="openFavoriteFile(fav.path)"
|
|
||||||
style="cursor: pointer; margin-bottom: 4px"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<span>{{ fav.is_dir ? '📁' : '📄' }}</span>
|
|
||||||
</template>
|
|
||||||
{{ fav.name }}
|
|
||||||
</a-tag>
|
|
||||||
</a-space>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 文件列表和内容区域 -->
|
|
||||||
<div class="file-panels-container">
|
|
||||||
<!-- 文件列表面板 -->
|
|
||||||
<div
|
|
||||||
class="file-panel-left"
|
|
||||||
:style="{ width: filePanelWidth.left + '%' }"
|
|
||||||
>
|
|
||||||
<a-card size="small" title="文件列表">
|
|
||||||
<template #extra>
|
|
||||||
<span style="font-size: 12px; color: #999;">
|
|
||||||
宽度: {{ filePanelWidth.left.toFixed(1) }}%
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<a-list
|
|
||||||
:data="fileList"
|
|
||||||
:loading="fileLoading"
|
|
||||||
style="max-height: 300px; overflow-y: auto"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<a-list-item>
|
|
||||||
<a-list-item-meta>
|
|
||||||
<template #title>
|
|
||||||
<a-space>
|
|
||||||
<span>{{ item.is_dir ? '📁' : '📄' }}</span>
|
|
||||||
<a @click="selectFile(item.path)">{{ item.name }}</a>
|
|
||||||
<a-button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
@click.stop="toggleFavorite(item)"
|
|
||||||
:style="{ color: isFavorite(item.path) ? '#ffcd00' : '' }"
|
|
||||||
>
|
|
||||||
{{ isFavorite(item.path) ? '⭐' : '☆' }}
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
<template #description>
|
|
||||||
<span v-if="!item.is_dir">大小: {{ formatBytes(item.size) }}</span>
|
|
||||||
<span>修改时间: {{ item.mod_time }}</span>
|
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
|
||||||
</a-list-item>
|
|
||||||
</template>
|
|
||||||
</a-list>
|
|
||||||
</a-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 水平拖拽条 -->
|
|
||||||
<div
|
|
||||||
class="resize-handle-horizontal"
|
|
||||||
@mousedown="startHorizontalResize"
|
|
||||||
title="← 拖拽调整宽度 →"
|
|
||||||
>
|
|
||||||
<div class="resize-handle-bar-horizontal"></div>
|
|
||||||
<div class="resize-handle-bar-horizontal"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 文件内容面板 -->
|
|
||||||
<div
|
|
||||||
class="file-panel-right"
|
|
||||||
:style="{ width: filePanelWidth.right + '%' }"
|
|
||||||
>
|
|
||||||
<a-card size="small" title="文件内容">
|
|
||||||
<template #extra>
|
|
||||||
<span style="font-size: 12px; color: #999;">
|
|
||||||
宽度: {{ filePanelWidth.right.toFixed(1) }}%
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<a-space direction="vertical" :size="8" style="width: 100%">
|
|
||||||
<div
|
|
||||||
class="file-content-wrapper"
|
|
||||||
:style="{ height: fileContentHeight + 'px' }"
|
|
||||||
>
|
|
||||||
<a-textarea
|
|
||||||
v-model="fileContent"
|
|
||||||
class="file-content-textarea"
|
|
||||||
placeholder="文件内容将显示在这里"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="resize-handle"
|
|
||||||
@mousedown="startResize"
|
|
||||||
title="拖拽调整高度"
|
|
||||||
>
|
|
||||||
<div class="resize-handle-bar"></div>
|
|
||||||
</div>
|
|
||||||
<a-space>
|
|
||||||
<a-button type="primary" @click="readFile" :loading="fileLoading">读取文件</a-button>
|
|
||||||
<a-button @click="writeFile" :loading="fileLoading" v-if="canSaveFile">写入文件</a-button>
|
|
||||||
<a-button danger @click="deleteFile" :loading="fileLoading">删除</a-button>
|
|
||||||
<a-button @click="clearContent" v-if="canClearContent">清空</a-button>
|
|
||||||
</a-space>
|
|
||||||
</a-space>
|
|
||||||
</a-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-space>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 环境变量 -->
|
|
||||||
<a-card class="test-card" title="环境变量">
|
|
||||||
<a-button @click="loadEnvVars" :loading="envLoading">加载环境变量</a-button>
|
|
||||||
<a-table
|
|
||||||
v-if="envVars"
|
|
||||||
:columns="envColumns"
|
|
||||||
:data="envTableData"
|
|
||||||
:pagination="{ pageSize: 20 }"
|
|
||||||
style="margin-top: 16px"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</a-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// 定义组件名称,用于 KeepAlive 缓存
|
|
||||||
defineOptions({
|
|
||||||
name: 'DeviceTest'
|
|
||||||
})
|
|
||||||
|
|
||||||
import {computed, onMounted, ref} from 'vue'
|
|
||||||
import {Message, Modal} from '@arco-design/web-vue'
|
|
||||||
import {
|
|
||||||
getSystemInfo,
|
|
||||||
getCPUInfo,
|
|
||||||
getMemoryInfo,
|
|
||||||
getDiskInfo,
|
|
||||||
getEnvVars,
|
|
||||||
listDir,
|
|
||||||
readFile as readFileApi
|
|
||||||
} from '@/api'
|
|
||||||
|
|
||||||
// 导入公共工具函数和常量
|
|
||||||
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
|
|
||||||
import { formatBytes, sortFileList } from '@/utils/fileUtils'
|
|
||||||
|
|
||||||
// 导入 composables
|
|
||||||
import { useFileOperations } from '@/composables/useFileOperations'
|
|
||||||
import { useFavoriteFiles } from '@/composables/useFavoriteFiles'
|
|
||||||
import { useLocalStorage } from '@/composables/useLocalStorage'
|
|
||||||
|
|
||||||
// ========== 使用 Composables ==========
|
|
||||||
|
|
||||||
// 文件操作
|
|
||||||
const {
|
|
||||||
filePath,
|
|
||||||
fileContent,
|
|
||||||
fileList,
|
|
||||||
fileLoading,
|
|
||||||
writeFile,
|
|
||||||
deleteFile,
|
|
||||||
} = useFileOperations({
|
|
||||||
onSuccess: (operation, data) => {
|
|
||||||
// 成功回调
|
|
||||||
},
|
|
||||||
onError: (operation, error) => {
|
|
||||||
console.error(`[DeviceTest] ${operation} 失败:`, error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 收藏功能
|
|
||||||
const {
|
|
||||||
favoriteFiles,
|
|
||||||
isFavorite,
|
|
||||||
toggleFavorite,
|
|
||||||
removeFavorite,
|
|
||||||
} = useFavoriteFiles(STORAGE_KEYS.DEVICE_TEST.FAVORITE_FILES)
|
|
||||||
|
|
||||||
// localStorage管理
|
|
||||||
const { storedValue: fileContentHeight } = useLocalStorage(
|
|
||||||
STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT_HEIGHT,
|
|
||||||
DEFAULTS.DEFAULT_CONTENT_HEIGHT
|
|
||||||
)
|
|
||||||
|
|
||||||
const { storedValue: filePanelWidth } = useLocalStorage(
|
|
||||||
STORAGE_KEYS.DEVICE_TEST.PANEL_WIDTH,
|
|
||||||
{ left: 50, right: 50 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const { storedValue: pathHistory } = useLocalStorage(
|
|
||||||
STORAGE_KEYS.DEVICE_TEST.PATH_HISTORY,
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========== 立即清理旧的文件内容缓存 ==========
|
|
||||||
// 在组件初始化之前清理,防止加载大文件导致空白
|
|
||||||
try {
|
|
||||||
const oldContent = localStorage.getItem(STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT)
|
|
||||||
if (oldContent) {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[DeviceTest] 清理缓存失败:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== DeviceTest 特有功能 ==========
|
|
||||||
|
|
||||||
const systemInfo = ref(null)
|
|
||||||
const cpuInfo = ref(null)
|
|
||||||
const memoryInfo = ref(null)
|
|
||||||
const diskInfo = ref(null)
|
|
||||||
const envVars = ref(null)
|
|
||||||
const envLoading = ref(false)
|
|
||||||
const isBinaryFile = ref(false) // 是否为二进制文件信息展示
|
|
||||||
|
|
||||||
const diskColumns = [
|
|
||||||
{title: '设备', dataIndex: 'device', width: 120},
|
|
||||||
{title: '挂载点', dataIndex: 'mountpoint', width: 200},
|
|
||||||
{title: '总容量', dataIndex: 'total_str', width: 100},
|
|
||||||
{title: '已用', dataIndex: 'used_str', width: 100},
|
|
||||||
{title: '可用', dataIndex: 'free_str', width: 100},
|
|
||||||
{title: '使用率', dataIndex: 'usage', width: 80}
|
|
||||||
]
|
|
||||||
|
|
||||||
const envColumns = [
|
|
||||||
{title: '变量名', dataIndex: 'key', width: 200},
|
|
||||||
{title: '值', dataIndex: 'value'}
|
|
||||||
]
|
|
||||||
|
|
||||||
const envTableData = computed(() => {
|
|
||||||
if (!envVars.value) return []
|
|
||||||
return Object.keys(envVars.value).map(key => ({
|
|
||||||
key,
|
|
||||||
value: envVars.value[key]
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// ========== 系统信息功能 ==========
|
|
||||||
|
|
||||||
const refreshSystemInfo = async () => {
|
|
||||||
try {
|
|
||||||
systemInfo.value = await getSystemInfo()
|
|
||||||
cpuInfo.value = await getCPUInfo()
|
|
||||||
memoryInfo.value = await getMemoryInfo()
|
|
||||||
diskInfo.value = await getDiskInfo()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取系统信息失败:', error)
|
|
||||||
Message.error('获取系统信息失败: ' + (error.message || error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadEnvVars = async () => {
|
|
||||||
envLoading.value = true
|
|
||||||
try {
|
|
||||||
envVars.value = await getEnvVars()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载环境变量失败:', error)
|
|
||||||
Message.error('加载环境变量失败: ' + (error.message || error))
|
|
||||||
} finally {
|
|
||||||
envLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 列出目录(重写以添加历史记录) ==========
|
|
||||||
|
|
||||||
const listDirectory = async () => {
|
|
||||||
if (!filePath.value) {
|
|
||||||
Message.error('请输入目录路径')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到历史记录
|
|
||||||
addToHistory(filePath.value)
|
|
||||||
|
|
||||||
fileLoading.value = true
|
|
||||||
try {
|
|
||||||
fileList.value = await listDir(filePath.value)
|
|
||||||
fileList.value = sortFileList(fileList.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('列出目录失败:', error)
|
|
||||||
Message.error('列出目录失败: ' + (error.message || error))
|
|
||||||
} finally {
|
|
||||||
fileLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 路径操作 ==========
|
|
||||||
|
|
||||||
const onPathSelect = (value) => {
|
|
||||||
filePath.value = value
|
|
||||||
listDirectory()
|
|
||||||
}
|
|
||||||
|
|
||||||
const browseDirectory = () => {
|
|
||||||
const path = prompt('请输入目录路径(例如: C:\\Users)')
|
|
||||||
if (path) {
|
|
||||||
filePath.value = path
|
|
||||||
listDirectory()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 路径历史记录 ==========
|
|
||||||
|
|
||||||
const addToHistory = (path) => {
|
|
||||||
if (!path || path.trim() === '') return
|
|
||||||
|
|
||||||
const index = pathHistory.value.indexOf(path)
|
|
||||||
if (index > -1) {
|
|
||||||
pathHistory.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pathHistory.value.unshift(path)
|
|
||||||
if (pathHistory.value.length > 20) {
|
|
||||||
pathHistory.value = pathHistory.value.slice(0, 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 文件选择(重写以添加历史记录) ==========
|
|
||||||
|
|
||||||
const selectFile = (path) => {
|
|
||||||
if (!path) return
|
|
||||||
|
|
||||||
filePath.value = path
|
|
||||||
addToHistory(path)
|
|
||||||
|
|
||||||
const item = fileList.value.find(f => f.path === path)
|
|
||||||
|
|
||||||
// 如果 fileList 为空或找不到该文件,尝试读取
|
|
||||||
if (!item) {
|
|
||||||
readFile()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.is_dir) {
|
|
||||||
listDirectory()
|
|
||||||
} else {
|
|
||||||
readFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 文件读取(重写以跳过二进制文件) ==========
|
|
||||||
|
|
||||||
const readFile = async () => {
|
|
||||||
if (!filePath.value) {
|
|
||||||
Message.error('请输入文件路径')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addToHistory(filePath.value)
|
|
||||||
|
|
||||||
// 检查文件扩展名
|
|
||||||
const ext = filePath.value.split('.').pop()?.toLowerCase() || ''
|
|
||||||
const binaryExts = ['exe', 'dll', 'so', 'dylib', 'zip', 'rar', '7z', 'tar', 'gz', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'mp3', 'mp4', 'avi', 'mkv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico']
|
|
||||||
|
|
||||||
if (binaryExts.includes(ext)) {
|
|
||||||
showBinaryFileInfo(ext)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileLoading.value = true
|
|
||||||
isBinaryFile.value = false // 标记为文本文件
|
|
||||||
try {
|
|
||||||
const content = await readFileApi(filePath.value)
|
|
||||||
|
|
||||||
// 检查文件大小(提高到2MB,合理的大文件支持)
|
|
||||||
const maxDisplaySize = 2 * 1024 * 1024 // 2MB
|
|
||||||
if (content.length > maxDisplaySize) {
|
|
||||||
fileContent.value = content.substring(0, maxDisplaySize) + '\n\n... (文件过大,已截断,仅显示前 2MB) ...'
|
|
||||||
// 大文件警告改为控制台日志
|
|
||||||
console.warn(`文件过大 (${(content.length / 1024).toFixed(2)} KB),已截断显示`)
|
|
||||||
} else {
|
|
||||||
fileContent.value = content
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件读取成功,静默无提示
|
|
||||||
} catch (error) {
|
|
||||||
Message.error('读取文件失败: ' + error.message)
|
|
||||||
} finally {
|
|
||||||
fileLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 显示二进制文件信息 ==========
|
|
||||||
|
|
||||||
const showBinaryFileInfo = (ext) => {
|
|
||||||
const file = fileList.value.find(f => f.path === filePath.value)
|
|
||||||
if (!file) {
|
|
||||||
Message.warning('无法找到文件信息')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置为二进制文件信息展示状态
|
|
||||||
isBinaryFile.value = true
|
|
||||||
|
|
||||||
const extDisplay = ext.toUpperCase()
|
|
||||||
const sizeDisplay = formatBytes(file.size)
|
|
||||||
|
|
||||||
// 判断文件类型
|
|
||||||
let fileType = '二进制文件'
|
|
||||||
if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico'].includes(ext)) fileType = '图片文件'
|
|
||||||
else if (['mp3', 'wav', 'flac'].includes(ext)) fileType = '音频文件'
|
|
||||||
else if (['mp4', 'avi', 'mkv', 'mov'].includes(ext)) fileType = '视频文件'
|
|
||||||
else if (['exe', 'dll', 'so'].includes(ext)) fileType = '可执行文件'
|
|
||||||
else if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) fileType = '压缩文件'
|
|
||||||
else if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) fileType = '文档文件'
|
|
||||||
|
|
||||||
fileContent.value = `╔════════════════════════════════════════════════════════════╗
|
|
||||||
║ 📄 ${fileType} - ${extDisplay} ║
|
|
||||||
╠════════════════════════════════════════════════════════════╣
|
|
||||||
║ ║
|
|
||||||
║ 📁 文件名: ${file.name.padEnd(40)}║
|
|
||||||
║ 📂 完整路径: ${filePath.value} ║
|
|
||||||
║ ║
|
|
||||||
║ 📊 大小: ${sizeDisplay.padEnd(10)} (${file.size.toLocaleString()} 字节) ║
|
|
||||||
║ 📅 修改时间: ${file.mod_time} ║
|
|
||||||
║ 🏷️ 类型: ${fileType.padEnd(15)} (${extDisplay}) ║
|
|
||||||
║ ║
|
|
||||||
║ ℹ️ 这是二进制文件,不支持文本预览 ║
|
|
||||||
║ 如需查看或编辑,请使用专门的工具 ║
|
|
||||||
║ ║
|
|
||||||
╚════════════════════════════════════════════════════════════╝`
|
|
||||||
|
|
||||||
Message.info(`已加载 ${fileType} 信息`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 打开收藏的文件 ==========
|
|
||||||
|
|
||||||
const openFavoriteFile = (path) => {
|
|
||||||
filePath.value = path
|
|
||||||
addToHistory(path)
|
|
||||||
|
|
||||||
const fav = favoriteFiles.value.find(f => f.path === path)
|
|
||||||
if (fav && fav.is_dir) {
|
|
||||||
listDirectory()
|
|
||||||
} else {
|
|
||||||
readFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 计算属性:按钮显示控制 ==========
|
|
||||||
|
|
||||||
// 是否可以保存文件(只有文本文件可以保存)
|
|
||||||
const canSaveFile = computed(() => {
|
|
||||||
return !isBinaryFile.value && fileContent.value !== ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 是否可以清空内容
|
|
||||||
const canClearContent = computed(() => {
|
|
||||||
return !isBinaryFile.value && fileContent.value !== ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// ========== 拖拽调整高度 ==========
|
|
||||||
|
|
||||||
const startResize = (e) => {
|
|
||||||
const startY = e.clientY
|
|
||||||
const startHeight = fileContentHeight.value
|
|
||||||
|
|
||||||
const onMouseMove = (moveEvent) => {
|
|
||||||
const deltaY = moveEvent.clientY - startY
|
|
||||||
const newHeight = startHeight + deltaY
|
|
||||||
|
|
||||||
if (newHeight >= 100 && newHeight <= 800) {
|
|
||||||
fileContentHeight.value = newHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT_HEIGHT,
|
|
||||||
fileContentHeight.value.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
|
||||||
document.addEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 水平拖拽调整面板宽度 ==========
|
|
||||||
|
|
||||||
const startHorizontalResize = (e) => {
|
|
||||||
const container = e.target.closest('.file-panels-container')
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
const startX = e.clientX
|
|
||||||
const containerWidth = container.offsetWidth
|
|
||||||
const startLeftWidth = (filePanelWidth.value.left / 100) * containerWidth
|
|
||||||
|
|
||||||
const onMouseMove = (moveEvent) => {
|
|
||||||
const deltaX = moveEvent.clientX - startX
|
|
||||||
const newLeftWidth = startLeftWidth + deltaX
|
|
||||||
const newLeftPercent = (newLeftWidth / containerWidth) * 100
|
|
||||||
|
|
||||||
if (newLeftPercent >= 20 && newLeftPercent <= 80) {
|
|
||||||
filePanelWidth.value.left = newLeftPercent
|
|
||||||
filePanelWidth.value.right = 100 - newLeftPercent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEYS.DEVICE_TEST.PANEL_WIDTH,
|
|
||||||
JSON.stringify(filePanelWidth.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
|
||||||
document.addEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 初始化 ==========
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
refreshSystemInfo()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.device-test {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-card {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文件面板容器 */
|
|
||||||
.file-panels-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
align-items: stretch;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧面板 */
|
|
||||||
.file-panel-left {
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 右侧面板 */
|
|
||||||
.file-panel-right {
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 水平拖拽手柄 */
|
|
||||||
.resize-handle-horizontal {
|
|
||||||
width: 8px;
|
|
||||||
cursor: col-resize;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
margin-left: -4px;
|
|
||||||
margin-right: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle-horizontal::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 3px;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-border-2);
|
|
||||||
border-left: 1px solid var(--color-border-2);
|
|
||||||
border-right: 1px solid var(--color-border-2);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle-horizontal:hover::before {
|
|
||||||
background: var(--color-fill-2);
|
|
||||||
border-color: rgb(var(--primary-6));
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle-horizontal::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 2px;
|
|
||||||
height: 40px;
|
|
||||||
background: var(--color-border-3);
|
|
||||||
border-radius: 1px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle-horizontal:hover::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 水平拖拽手柄的视觉指示条(已删除,改用 ::after 伪元素)*/
|
|
||||||
|
|
||||||
/* 文件内容区域容器 */
|
|
||||||
.file-content-wrapper {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: height 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文件内容文本框 */
|
|
||||||
.file-content-textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 拖拽手柄 */
|
|
||||||
.resize-handle {
|
|
||||||
height: 8px;
|
|
||||||
cursor: ns-resize;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-fill-2);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 4px 0;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:hover {
|
|
||||||
background: var(--color-fill-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 拖拽手柄的视觉指示条 */
|
|
||||||
.resize-handle-bar {
|
|
||||||
width: 40px;
|
|
||||||
height: 3px;
|
|
||||||
background: var(--color-border-3);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:hover .resize-handle-bar {
|
|
||||||
background: rgb(var(--primary-6));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="config.visible"
|
v-if="config.visible"
|
||||||
|
ref="menuRef"
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
:style="{ left: config.x + 'px', top: config.y + 'px' }"
|
:style="menuStyle"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<!-- 空白区域菜单 -->
|
<!-- 空白区域菜单 -->
|
||||||
@@ -21,6 +22,16 @@
|
|||||||
|
|
||||||
<!-- 文件菜单 -->
|
<!-- 文件菜单 -->
|
||||||
<template v-else-if="config.context === 'file' && config.selectedFile">
|
<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 class="context-menu-divider"></div>
|
||||||
<div
|
<div
|
||||||
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
|
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
|
||||||
@@ -46,9 +57,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import type { ContextMenuConfig, FileItem } from '@/types/file-system'
|
import type { ContextMenuConfig, FileItem } from '@/types/file-system'
|
||||||
import { isOfficeFile } from '@/utils/fileTypeHelpers'
|
import { isOfficeFile } from '@/utils/fileTypeHelpers'
|
||||||
|
|
||||||
|
const menuRef = ref<HTMLElement>()
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
config: ContextMenuConfig
|
config: ContextMenuConfig
|
||||||
@@ -64,6 +78,26 @@ interface Emits {
|
|||||||
|
|
||||||
const emit = defineEmits<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 children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||||
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
|
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
|
||||||
|
|
||||||
const hoverTimer = ref<number | null>(null)
|
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||||
const leaveTimer = ref<number | null>(null)
|
const leaveTimer = ref<NodeJS.Timeout | null>(null)
|
||||||
const hoveringMenu = ref(false)
|
const hoveringMenu = ref(false)
|
||||||
|
|
||||||
const menuKey = `menu-${props.item.path}-${props.level}`
|
const menuKey = `menu-${props.item.path}-${props.level}`
|
||||||
@@ -162,11 +162,14 @@ const onSubmenuLeave = () => {
|
|||||||
leaveTimer.value = scheduleClose(100)
|
leaveTimer.value = scheduleClose(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = (event: MouseEvent) => {
|
||||||
if (leaveTimer.value) clearTimeout(leaveTimer.value)
|
if (leaveTimer.value) clearTimeout(leaveTimer.value)
|
||||||
|
|
||||||
const event = props.item.isDir ? 'navigate' : 'openFile'
|
// 阻止事件冒泡,避免触发父级 breadcrumb-segment 的点击
|
||||||
emit(event, props.item.path)
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const eventType = props.item.isDir ? 'navigate' : 'openFile'
|
||||||
|
emit(eventType, props.item.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emitNavigate = (path: string) => emit('navigate', path)
|
const emitNavigate = (path: string) => emit('navigate', path)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="code-editor">
|
|
||||||
<!-- 代码编辑器 -->
|
|
||||||
<CodeMirror
|
|
||||||
v-if="!isEditMode"
|
|
||||||
:model-value="content"
|
|
||||||
:extensions="extensions"
|
|
||||||
:style="{ height: `${height}px` }"
|
|
||||||
@update:model-value="handleContentUpdate"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 编辑模式 -->
|
|
||||||
<CodeMirror
|
|
||||||
v-else
|
|
||||||
v-model="editableContent"
|
|
||||||
:extensions="extensions"
|
|
||||||
:style="{ height: `${height}px` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, computed } from 'vue'
|
|
||||||
import CodeMirror from 'vue-codemirror6'
|
|
||||||
import { javascript } from '@codemirror/lang-javascript'
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
|
||||||
import { keymap } from '@codemirror/view'
|
|
||||||
import { EditorView } from '@codemirror/view'
|
|
||||||
import { EditorState } from '@codemirror/state'
|
|
||||||
import { basicSetup } from 'codemirror'
|
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
|
||||||
|
|
||||||
// Props
|
|
||||||
interface Props {
|
|
||||||
content: string
|
|
||||||
height: number
|
|
||||||
isEditMode: boolean
|
|
||||||
currentFileExtension: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
height: 400
|
|
||||||
})
|
|
||||||
|
|
||||||
// Emits
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:content', content: string): void
|
|
||||||
(e: 'save'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// 可编辑内容
|
|
||||||
const editableContent = ref(props.content)
|
|
||||||
|
|
||||||
// 监听 content 变化
|
|
||||||
watch(() => props.content, (newContent) => {
|
|
||||||
editableContent.value = newContent
|
|
||||||
})
|
|
||||||
|
|
||||||
// 内容更新
|
|
||||||
const handleContentUpdate = (value: string) => {
|
|
||||||
emit('update:content', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据文件扩展名获取语言
|
|
||||||
const getLanguage = (ext: string) => {
|
|
||||||
const languageMap: Record<string, any> = {
|
|
||||||
js: javascript(),
|
|
||||||
jsx: javascript(),
|
|
||||||
ts: javascript(),
|
|
||||||
tsx: javascript(),
|
|
||||||
md: markdown()
|
|
||||||
}
|
|
||||||
return languageMap[ext] || []
|
|
||||||
}
|
|
||||||
|
|
||||||
// CodeMirror 扩展
|
|
||||||
const extensions = computed(() => {
|
|
||||||
const ext = props.currentFileExtension
|
|
||||||
|
|
||||||
return [
|
|
||||||
basicSetup,
|
|
||||||
keymap.of(/* 添加快捷键 */),
|
|
||||||
EditorView.theme({ '&': { height: '100%' }, '.cm-scroller': { overflow: 'auto' } }),
|
|
||||||
oneDark,
|
|
||||||
...getLanguage(ext)
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.code-editor {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="file-editor-panel" :style="{ width: width + '%' }">
|
|
||||||
<!-- 面板标题 -->
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">{{ title }}</span>
|
|
||||||
<div class="panel-actions">
|
|
||||||
<a-button v-if="canSave" type="primary" size="small" @click="handleSave">
|
|
||||||
保存
|
|
||||||
</a-button>
|
|
||||||
<a-button v-if="canReset" size="small" type="outline" @click="handleReset">
|
|
||||||
重置
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
v-if="isEditableWithPreview"
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
@click="handleToggleEditMode"
|
|
||||||
>
|
|
||||||
{{ isEditMode ? '预览' : '编辑' }}
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 编辑器内容 -->
|
|
||||||
<div class="editor-content">
|
|
||||||
<!-- 代码/文本编辑器 -->
|
|
||||||
<CodeEditor
|
|
||||||
v-if="!isMediaFile && !isPdfFile && !isBinary"
|
|
||||||
:content="fileContent"
|
|
||||||
:height="height"
|
|
||||||
:isEditMode="isEditMode"
|
|
||||||
:currentFileExtension="currentFileExtension"
|
|
||||||
@update:content="handleContentUpdate"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 媒体预览 -->
|
|
||||||
<MediaPreview
|
|
||||||
v-else-if="isMediaFile"
|
|
||||||
:url="previewUrl"
|
|
||||||
:type="mediaType"
|
|
||||||
@load="handleMediaLoad"
|
|
||||||
@error="handleMediaError"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- PDF预览 -->
|
|
||||||
<iframe
|
|
||||||
v-else-if="isPdfFile"
|
|
||||||
:src="previewUrl"
|
|
||||||
class="preview-pdf"
|
|
||||||
></iframe>
|
|
||||||
|
|
||||||
<!-- 二进制文件信息 -->
|
|
||||||
<BinaryInfo v-else :content="fileContent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部调整条 -->
|
|
||||||
<div v-if="!isBinary && !isMediaFile" class="resizer" @mousedown="handleStartResize"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import CodeEditor from './FileEditor/CodeEditor.vue'
|
|
||||||
import MediaPreview from './FileEditor/MediaPreview.vue'
|
|
||||||
import BinaryInfo from './FileEditor/BinaryInfo.vue'
|
|
||||||
|
|
||||||
// Props
|
|
||||||
interface Props {
|
|
||||||
config: any
|
|
||||||
width: number
|
|
||||||
currentDirectory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
// Emits
|
|
||||||
interface Emits {
|
|
||||||
(e: 'save'): void
|
|
||||||
(e: 'reset'): void
|
|
||||||
(e: 'toggleEditMode'): void
|
|
||||||
(e: 'startResize', event: MouseEvent): void
|
|
||||||
(e: 'contentUpdate', content: string): void
|
|
||||||
(e: 'imageLoad', dimensions: string): void
|
|
||||||
(e: 'imageError'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const title = computed(() => {
|
|
||||||
if (props.config.isImageView) return '🖼️ 图片预览'
|
|
||||||
if (props.config.isVideoView) return '🎬 视频预览'
|
|
||||||
if (props.config.isAudioView) return '🎵 音频预览'
|
|
||||||
if (props.config.isPdfFile) return '📕 PDF 预览'
|
|
||||||
if (props.config.isHtmlFile) return '🌐 HTML'
|
|
||||||
if (props.config.isMarkdownFile) return '📝 Markdown'
|
|
||||||
if (props.config.isBinaryFile) return 'ℹ️ 二进制文件'
|
|
||||||
return '📝 文件内容'
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileContent = computed(() => props.config.fileContent || '')
|
|
||||||
const isEditMode = computed(() => props.config.isEditMode || false)
|
|
||||||
const height = computed(() => props.config.fileContentHeight || 400)
|
|
||||||
const previewUrl = computed(() => props.config.previewUrl || '')
|
|
||||||
const currentFileExtension = computed(() => props.config.currentFileExtension || '')
|
|
||||||
const canSave = computed(() => props.config.canSaveFile || false)
|
|
||||||
const canReset = computed(() => props.config.canResetContent || false)
|
|
||||||
const isEditableWithPreview = computed(() => {
|
|
||||||
const ext = currentFileExtension.value
|
|
||||||
return ['html', 'htm', 'md', 'markdown'].includes(ext)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMediaFile = computed(() =>
|
|
||||||
props.config.isImageView ||
|
|
||||||
props.config.isVideoView ||
|
|
||||||
props.config.isAudioView
|
|
||||||
)
|
|
||||||
|
|
||||||
const isPdfFile = computed(() => props.config.isPdfFile)
|
|
||||||
const isBinary = computed(() => props.config.isBinaryFile)
|
|
||||||
|
|
||||||
const mediaFileType = computed(() => {
|
|
||||||
if (props.config.isImageView) return 'image'
|
|
||||||
if (props.config.isVideoView) return 'video'
|
|
||||||
if (props.config.isAudioView) return 'audio'
|
|
||||||
return 'image'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 事件处理
|
|
||||||
const handleSave = () => emit('save')
|
|
||||||
const handleReset = () => emit('reset')
|
|
||||||
const handleToggleEditMode = () => emit('toggleEditMode')
|
|
||||||
const handleStartResize = (event: MouseEvent) => emit('startResize', event)
|
|
||||||
const handleContentUpdate = (content: string) => emit('contentUpdate', content)
|
|
||||||
const handleMediaLoad = (dimensions: string) => emit('imageLoad', dimensions)
|
|
||||||
const handleMediaError = () => emit('imageError')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.file-editor-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-bg-1);
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: var(--color-bg-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-pdf {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizer {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--color-border);
|
|
||||||
cursor: row-resize;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizer:hover {
|
|
||||||
background: rgb(var(--primary-6));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="file-editor-panel" :style="{ width: width + '%' }">
|
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">
|
<span class="panel-title">
|
||||||
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
<template v-if="config.isImageView">🖼️ 图片预览</template>
|
||||||
@@ -8,9 +8,19 @@
|
|||||||
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
|
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
|
||||||
<template v-else-if="config.isHtmlFile">🌐 HTML 预览</template>
|
<template v-else-if="config.isHtmlFile">🌐 HTML 预览</template>
|
||||||
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
|
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
|
||||||
|
<template v-else-if="config.isExcelFile">📊 Excel 预览</template>
|
||||||
|
<template v-else-if="config.isWordFile">📄 Word 预览</template>
|
||||||
|
<template v-else-if="config.isCsvFile">📋 CSV 预览</template>
|
||||||
<template v-else>📝 文件内容</template>
|
<template v-else>📝 文件内容</template>
|
||||||
</span>
|
</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">
|
<a-tooltip :content="config.currentFileFullPath" position="left">
|
||||||
<span
|
<span
|
||||||
class="panel-filename"
|
class="panel-filename"
|
||||||
@@ -19,15 +29,22 @@
|
|||||||
{{ config.currentFileName }}
|
{{ config.currentFileName }}
|
||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<icon-copy
|
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="left">
|
||||||
class="copy-icon"
|
<a-button
|
||||||
title="复制路径"
|
size="mini"
|
||||||
@click="handleCopyPath"
|
type="text"
|
||||||
/>
|
:status="copied ? 'success' : 'normal'"
|
||||||
|
@click="handleCopyPath"
|
||||||
|
>
|
||||||
|
<icon-copy v-if="!copied" />
|
||||||
|
<icon-check v-else />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-content">
|
<div class="editor-content thin-dark-scrollbar">
|
||||||
<!-- 二进制文件提示 -->
|
<!-- 二进制文件提示 -->
|
||||||
<div v-if="config.isBinaryFile" class="binary-file-message">
|
<div v-if="config.isBinaryFile" class="binary-file-message">
|
||||||
<pre>{{ config.fileContent }}</pre>
|
<pre>{{ config.fileContent }}</pre>
|
||||||
@@ -77,6 +94,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Excel 预览 -->
|
||||||
|
<div v-else-if="config.isExcelFile" class="office-preview">
|
||||||
|
<div class="office-preview-container" ref="excelPreviewRef">
|
||||||
|
<a-spin v-if="config.officeLoading" :loading="true" tip="加载中...">
|
||||||
|
<div class="loading-placeholder"></div>
|
||||||
|
</a-spin>
|
||||||
|
<a-alert v-else-if="config.officeError" type="error" :message="config.officeError" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Word 预览 -->
|
||||||
|
<div v-else-if="config.isWordFile" class="office-preview">
|
||||||
|
<div class="office-preview-container" ref="wordPreviewRef">
|
||||||
|
<a-spin v-if="config.officeLoading" :loading="true" tip="加载中...">
|
||||||
|
<div class="loading-placeholder"></div>
|
||||||
|
</a-spin>
|
||||||
|
<a-alert v-else-if="config.officeError" type="error" :message="config.officeError" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV 预览/编辑 -->
|
||||||
|
<div v-else-if="config.isCsvFile" class="csv-preview-wrapper">
|
||||||
|
<div class="preview-mode-switch">
|
||||||
|
<a-tooltip v-if="config.isEditMode && config.canResetContent" position="left" content="恢复原始内容">
|
||||||
|
<a-button type="outline" size="small" @click="handleReset">
|
||||||
|
<template #icon><icon-undo /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="config.canSaveFile" position="left" content="保存 (Ctrl+S)">
|
||||||
|
<a-button type="primary" size="small" @click="handleSave">
|
||||||
|
<template #icon><icon-save /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip v-if="!config.isEditMode" position="left" content="导出 PDF">
|
||||||
|
<a-button type="outline" size="small" @click="handleExportCsvPDF">
|
||||||
|
<template #icon><icon-file-pdf /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip position="left" :content="getModeSwitchTooltip()">
|
||||||
|
<a-button type="primary" size="small" @click="handleToggleEditMode">
|
||||||
|
<template #icon>
|
||||||
|
<icon-eye v-if="config.isEditMode" />
|
||||||
|
<icon-edit v-else />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览模式 -->
|
||||||
|
<div v-if="!config.isEditMode" class="office-preview-container" ref="csvPreviewRef">
|
||||||
|
<a-spin v-if="config.officeLoading" :loading="true" tip="加载中...">
|
||||||
|
<div class="loading-placeholder"></div>
|
||||||
|
</a-spin>
|
||||||
|
<a-alert v-else-if="config.officeError" type="error" :message="config.officeError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑模式 -->
|
||||||
|
<div v-else class="csv-edit-wrapper">
|
||||||
|
<AsyncCodeEditor
|
||||||
|
:model-value="config.fileContent"
|
||||||
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
|
@update:model-value="handleContentUpdate"
|
||||||
|
class="code-editor"
|
||||||
|
/>
|
||||||
|
<div class="resize-handle-v" @mousedown="handleStartResize" title="拖拽调整高度">
|
||||||
|
<div class="resize-dots"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- HTML 预览/编辑 -->
|
<!-- HTML 预览/编辑 -->
|
||||||
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
|
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
|
||||||
<!-- 编辑模式/预览模式切换按钮 -->
|
<!-- 编辑模式/预览模式切换按钮 -->
|
||||||
@@ -123,8 +212,7 @@
|
|||||||
<iframe
|
<iframe
|
||||||
v-if="!config.isEditMode"
|
v-if="!config.isEditMode"
|
||||||
class="html-preview-content"
|
class="html-preview-content"
|
||||||
:srcdoc="htmlContentWithTheme"
|
:src="htmlPreviewUrl"
|
||||||
:key="getCurrentTheme()"
|
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|
||||||
<!-- 编辑模式 -->
|
<!-- 编辑模式 -->
|
||||||
@@ -132,6 +220,8 @@
|
|||||||
<AsyncCodeEditor
|
<AsyncCodeEditor
|
||||||
:model-value="config.fileContent"
|
:model-value="config.fileContent"
|
||||||
:file-extension="config.currentFileExtension"
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
@update:model-value="handleContentUpdate"
|
@update:model-value="handleContentUpdate"
|
||||||
class="code-editor"
|
class="code-editor"
|
||||||
/>
|
/>
|
||||||
@@ -162,6 +252,16 @@
|
|||||||
<template #icon><icon-save /></template>
|
<template #icon><icon-save /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</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
|
<a-tooltip
|
||||||
position="left"
|
position="left"
|
||||||
@@ -181,13 +281,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 预览模式 -->
|
<!-- 预览模式 -->
|
||||||
<div v-if="!config.isEditMode" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
|
<div v-if="!config.isEditMode" ref="markdownPreviewRef" class="markdown-preview-content markdown-content thin-dark-scrollbar" v-html="config.rendered"></div>
|
||||||
|
|
||||||
<!-- 编辑模式 -->
|
<!-- 编辑模式 -->
|
||||||
<div v-else class="markdown-edit-wrapper">
|
<div v-else class="markdown-edit-wrapper">
|
||||||
<AsyncCodeEditor
|
<AsyncCodeEditor
|
||||||
:model-value="config.fileContent"
|
:model-value="config.fileContent"
|
||||||
:file-extension="config.currentFileExtension"
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
@update:model-value="handleContentUpdate"
|
@update:model-value="handleContentUpdate"
|
||||||
class="code-editor"
|
class="code-editor"
|
||||||
/>
|
/>
|
||||||
@@ -241,6 +343,8 @@
|
|||||||
<AsyncCodeEditor
|
<AsyncCodeEditor
|
||||||
:model-value="config.fileContent"
|
:model-value="config.fileContent"
|
||||||
:file-extension="config.currentFileExtension"
|
:file-extension="config.currentFileExtension"
|
||||||
|
:file-path="config.currentFileFullPath"
|
||||||
|
:file-mtime="config.fileMtime"
|
||||||
@update:model-value="handleContentUpdate"
|
@update:model-value="handleContentUpdate"
|
||||||
class="code-editor"
|
class="code-editor"
|
||||||
/>
|
/>
|
||||||
@@ -254,12 +358,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, nextTick, defineAsyncComponent } from 'vue'
|
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { Message } from '@arco-design/web-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, IconCheck, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
|
||||||
import { getFileName } from '@/utils/fileUtils'
|
import { getFileName, escapeHtml } from '@/utils/fileUtils'
|
||||||
|
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||||
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||||
|
|
||||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||||
const AsyncCodeEditor = defineAsyncComponent({
|
const AsyncCodeEditor = defineAsyncComponent({
|
||||||
@@ -268,6 +375,38 @@ const AsyncCodeEditor = defineAsyncComponent({
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Office 预览容器引用
|
||||||
|
const excelPreviewRef = ref<HTMLElement | null>(null)
|
||||||
|
const wordPreviewRef = ref<HTMLElement | null>(null)
|
||||||
|
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
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
config: FileEditorPanelConfig
|
config: FileEditorPanelConfig
|
||||||
@@ -288,54 +427,18 @@ interface Emits {
|
|||||||
(e: 'contentUpdate', content: string): void
|
(e: 'contentUpdate', content: string): void
|
||||||
(e: 'imageLoad', dimensions: string): void
|
(e: 'imageLoad', dimensions: string): void
|
||||||
(e: 'imageError'): void
|
(e: 'imageError'): void
|
||||||
|
(e: 'openLocalFile', link: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// 获取当前主题
|
// HTML 预览 URL(使用后端接口)
|
||||||
const getCurrentTheme = () => {
|
const htmlPreviewUrl = computed(() => {
|
||||||
return document.body.getAttribute('arco-theme') || 'light'
|
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) {
|
||||||
}
|
return ''
|
||||||
|
}
|
||||||
// 生成带主题样式的 HTML 内容
|
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||||
const htmlContentWithTheme = computed(() => {
|
return `http://localhost:8073/localfs/html-preview?path=${encodedPath}`
|
||||||
if (!props.config.rendered || props.config.isEditMode) return ''
|
|
||||||
|
|
||||||
const theme = getCurrentTheme()
|
|
||||||
const bgColor = theme === 'dark' ? '#1a1a1a' : '#ffffff'
|
|
||||||
const textColor = theme === 'dark' ? '#e8e8e8' : '#333333'
|
|
||||||
|
|
||||||
return `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: ${bgColor};
|
|
||||||
color: ${textColor};
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
a { color: ${theme === 'dark' ? '#4e9af1' : '#0066cc'}; }
|
|
||||||
table { border-collapse: collapse; width: 100%; }
|
|
||||||
th, td { border: 1px solid ${theme === 'dark' ? '#444' : '#ddd'}; padding: 8px; }
|
|
||||||
th { background-color: ${theme === 'dark' ? '#333' : '#f2f2f2'}; }
|
|
||||||
code { background-color: ${theme === 'dark' ? '#333' : '#f4f4f4'}; padding: 2px 6px; border-radius: 3px; }
|
|
||||||
pre { background-color: ${theme === 'dark' ? '#2a2a2a' : '#f4f4f4'}; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
|
||||||
pre code { background-color: transparent; padding: 0; }
|
|
||||||
img { max-width: 100%; height: auto; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>${props.config.rendered}</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算属性:判断文件是否在当前目录
|
// 计算属性:判断文件是否在当前目录
|
||||||
@@ -395,6 +498,100 @@ const handleImageError = () => {
|
|||||||
emit('imageError')
|
emit('imageError')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打印窗口导出 PDF 公共函数
|
||||||
|
const openPrintWindow = (title: string, bodyHtml: string, extraStyle = '') => {
|
||||||
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (!printWindow) {
|
||||||
|
Message.error('无法打开打印窗口,请检查浏览器设置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { padding: 0; }
|
||||||
|
@page { margin: 15mm; size: A4; }
|
||||||
|
}
|
||||||
|
${extraStyle}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>${bodyHtml}</body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
printWindow.document.close()
|
||||||
|
|
||||||
|
setTimeout(() => { printWindow.print() }, 500)
|
||||||
|
Message.success('PDF 导出窗口已打开')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown PDF 导出处理
|
||||||
|
const handleExportPDF = async () => {
|
||||||
|
const markdownContent = markdownPreviewRef.value
|
||||||
|
if (!markdownContent) {
|
||||||
|
Message.error('无法获取 Markdown 内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openPrintWindow(
|
||||||
|
props.config.currentFileName || 'Markdown 导出 PDF',
|
||||||
|
`<div class="markdown-content">${markdownContent.innerHTML}</div>`,
|
||||||
|
`
|
||||||
|
.markdown-content { background: white; padding: 20px; max-width: 100%; }
|
||||||
|
@media print {
|
||||||
|
.markdown-content { 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; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV PDF 导出处理
|
||||||
|
const handleExportCsvPDF = async () => {
|
||||||
|
const csvContent = csvPreviewRef.value?.querySelector('.csv-content table')
|
||||||
|
if (!csvContent) {
|
||||||
|
Message.error('无法获取 CSV 内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openPrintWindow(
|
||||||
|
props.config.currentFileName || 'CSV 导出 PDF',
|
||||||
|
csvContent.outerHTML,
|
||||||
|
`
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-bottom: 12pt; page-break-inside: auto; }
|
||||||
|
tr { page-break-inside: avoid; page-break-after: auto; }
|
||||||
|
th, td { border: 1px solid #dfe2e5; padding: 6px 10px; text-align: left; white-space: nowrap; }
|
||||||
|
th { background-color: #f6f8fa; font-weight: 600; }
|
||||||
|
tr:nth-child(even) { background-color: #f8f8f8; }
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
|
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
|
||||||
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
||||||
// 从编辑模式切换到预览模式
|
// 从编辑模式切换到预览模式
|
||||||
@@ -408,6 +605,130 @@ watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听主题变化,重新渲染 mermaid 图表
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
watch(() => themeStore.isDark, async () => {
|
||||||
|
if (!props.config.isEditMode && markdownPreviewRef.value) {
|
||||||
|
try {
|
||||||
|
// 等 DOM 更新完成后再重新渲染
|
||||||
|
await nextTick()
|
||||||
|
await rerenderMermaidDiagrams(markdownPreviewRef.value)
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 Excel 文件变化,触发预览
|
||||||
|
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath] as const, async ([isExcel, filePath]) => {
|
||||||
|
if (isExcel && filePath && excelPreviewRef.value) {
|
||||||
|
await loadExcelPreview(filePath)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 监听 Word 文件变化,触发预览
|
||||||
|
watch(() => [props.config.isWordFile, props.config.currentFileFullPath] as const, async ([isWord, filePath]) => {
|
||||||
|
if (isWord && filePath && wordPreviewRef.value) {
|
||||||
|
await loadWordPreview(filePath)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 监听 CSV 文件变化或编辑模式切换,触发预览
|
||||||
|
watch(() => [props.config.isCsvFile, props.config.currentFileFullPath, props.config.isEditMode] as const, async ([isCsv, filePath, isEditMode]) => {
|
||||||
|
if (isCsv && filePath && !isEditMode) {
|
||||||
|
await nextTick()
|
||||||
|
if (csvPreviewRef.value) {
|
||||||
|
await loadCsvPreview(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Excel 预览加载(直接使用本地文件服务器,秒开)
|
||||||
|
const loadExcelPreview = async (filePath: string) => {
|
||||||
|
if (!excelPreviewRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
|
|
||||||
|
// 直接从本地文件服务器获取(不走 base64)
|
||||||
|
const fileUrl = props.config.previewUrl
|
||||||
|
const response = await fetch(fileUrl)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
||||||
|
|
||||||
|
const result = await previewExcel(file, excelPreviewRef.value)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '预览失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[loadExcelPreview] 错误:', error)
|
||||||
|
if (excelPreviewRef.value) {
|
||||||
|
excelPreviewRef.value.innerHTML = `
|
||||||
|
<div class="preview-error">
|
||||||
|
<p>❌ Excel 预览失败</p>
|
||||||
|
<p class="error-detail">${error?.message || '未知错误'}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word 预览加载
|
||||||
|
const loadWordPreview = async (filePath: string) => {
|
||||||
|
if (!wordPreviewRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
|
|
||||||
|
const fileUrl = props.config.previewUrl
|
||||||
|
const response = await fetch(fileUrl)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
||||||
|
|
||||||
|
const result = await previewWord(file, wordPreviewRef.value)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '预览失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[loadWordPreview] 错误:', error)
|
||||||
|
if (wordPreviewRef.value) {
|
||||||
|
wordPreviewRef.value.innerHTML = `
|
||||||
|
<div class="preview-error">
|
||||||
|
<p>❌ Word 预览失败</p>
|
||||||
|
<p class="error-detail">${error?.message || '未知错误'}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 预览加载
|
||||||
|
const loadCsvPreview = async (filePath: string) => {
|
||||||
|
if (!csvPreviewRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
csvPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
|
|
||||||
|
const blob = props.config.fileContent && !props.config.isBinaryFile
|
||||||
|
? new Blob([props.config.fileContent], { type: 'text/csv' })
|
||||||
|
: await (await fetch(props.config.previewUrl)).blob()
|
||||||
|
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
|
||||||
|
|
||||||
|
const result = await previewCsv(file, csvPreviewRef.value)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '预览失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[loadCsvPreview] 错误:', error)
|
||||||
|
if (csvPreviewRef.value) {
|
||||||
|
csvPreviewRef.value.innerHTML = `
|
||||||
|
<div class="preview-error">
|
||||||
|
<p>❌ CSV 预览失败</p>
|
||||||
|
<p class="error-detail">${error?.message || String(error)}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取模式切换按钮的提示文本
|
// 获取模式切换按钮的提示文本
|
||||||
const getModeSwitchTooltip = () => {
|
const getModeSwitchTooltip = () => {
|
||||||
if (props.config.isEditMode) {
|
if (props.config.isEditMode) {
|
||||||
@@ -424,24 +745,76 @@ const getPreviewButtonTooltip = () => {
|
|||||||
return '切换到预览'
|
return '切换到预览'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制文件路径
|
// 复制文件路径(带状态反馈)
|
||||||
const handleCopyPath = () => {
|
const { copied, copy: copyPath, cleanup: copyCleanup } = useClipboardCopy()
|
||||||
const path = props.config.currentFileFullPath
|
|
||||||
if (!path) return
|
|
||||||
|
|
||||||
navigator.clipboard.writeText(path).then(() => {
|
const handleCopyPath = async () => {
|
||||||
Message.success('路径已复制')
|
await copyPath(props.config.currentFileFullPath)
|
||||||
}).catch(() => {
|
|
||||||
// 降级方案
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.value = path
|
|
||||||
document.body.appendChild(input)
|
|
||||||
input.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(input)
|
|
||||||
Message.success('路径已复制')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 Markdown 预览中的本地文件链接点击
|
||||||
|
const handleMarkdownLinkClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
const link = target.closest('a[data-local-link]') as HTMLAnchorElement | null
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const localLink = link.getAttribute('data-local-link')
|
||||||
|
if (localLink) {
|
||||||
|
emit('openLocalFile', localLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听预览区域的变化,添加/移除事件监听
|
||||||
|
watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode], [oldRefVal]) => {
|
||||||
|
// 移除旧的监听器
|
||||||
|
if (oldRefVal) {
|
||||||
|
oldRefVal.removeEventListener('click', handleMarkdownLinkClick)
|
||||||
|
}
|
||||||
|
// 添加新的监听器(仅在预览模式且有 DOM 元素时)
|
||||||
|
if (refVal && !isEditMode) {
|
||||||
|
refVal.addEventListener('click', handleMarkdownLinkClick)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 处理 HTML iframe 发送的消息(链接点击)
|
||||||
|
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||||
|
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||||
|
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||||
|
const allowedOrigins = [
|
||||||
|
window.location.origin,
|
||||||
|
'null', // about:blank 或 data: URL
|
||||||
|
'http://localhost:8073', // 本地文件服务器
|
||||||
|
]
|
||||||
|
if (!allowedOrigins.includes(event.origin)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = event.data
|
||||||
|
if (data && data.type === 'openLocalFile' && data.path) {
|
||||||
|
// 直接传递路径,由父组件处理相对路径解析
|
||||||
|
emit('openLocalFile', data.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 iframe 的 postMessage + 全屏事件
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('message', handleHtmlIframeMessage)
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (markdownPreviewRef.value) {
|
||||||
|
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
|
||||||
|
}
|
||||||
|
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
copyCleanup()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -452,6 +825,12 @@ const handleCopyPath = () => {
|
|||||||
background: var(--color-bg-1);
|
background: var(--color-bg-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-editor-panel:fullscreen {
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -465,8 +844,25 @@ const handleCopyPath = () => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header > * {
|
||||||
|
--wails-draggable: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅全屏模式下 header 可拖动窗口 */
|
||||||
|
.file-editor-panel:fullscreen .panel-header {
|
||||||
|
--wails-draggable: drag;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filename-with-copy {
|
.filename-with-copy {
|
||||||
@@ -662,6 +1058,18 @@ const handleCopyPath = () => {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 本地文件链接样式 */
|
||||||
|
.markdown-preview-content :deep(a.local-file-link) {
|
||||||
|
color: rgb(var(--primary-6));
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px dashed rgb(var(--primary-6));
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-content :deep(a.local-file-link:hover) {
|
||||||
|
background-color: rgba(var(--primary-6), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-preview-content :deep(code) {
|
.markdown-preview-content :deep(code) {
|
||||||
background: var(--color-fill-2);
|
background: var(--color-fill-2);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -781,6 +1189,67 @@ const handleCopyPath = () => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Office 预览 */
|
||||||
|
.office-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSV 预览/编辑 */
|
||||||
|
.csv-preview-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-edit-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.office-preview-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error .error-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error .error-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.binary-file-message pre {
|
.binary-file-message pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -823,16 +1292,61 @@ const handleCopyPath = () => {
|
|||||||
fill: var(--color-text-1);
|
fill: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 深色模式适配 ========== */
|
/* ========== 代码高亮主题色(不依赖 hljs 主题 CSS) ========== */
|
||||||
|
|
||||||
/* Mermaid 图表深色模式 */
|
/* 亮色模式 - GitHub 配色 */
|
||||||
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid) {
|
.markdown-preview-content :deep(.hljs) {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
color: #24292e;
|
||||||
|
background: #f6f8fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[arco-theme*='dark'] .markdown-preview-content :deep(.mermaid *) {
|
.markdown-preview-content :deep(.hljs-comment),
|
||||||
color: var(--color-text-1) !important;
|
.markdown-preview-content :deep(.hljs-quote) { color: #6a737d; font-style: italic; }
|
||||||
stroke: var(--color-text-1) !important;
|
|
||||||
|
.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 自定义属性 */
|
/* 代码高亮深色模式 - 使用 CSS 自定义属性 */
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="file-item-row"
|
|
||||||
:class="{
|
|
||||||
'file-item-selected': isSelected,
|
|
||||||
'file-item-editing': isEditing
|
|
||||||
}"
|
|
||||||
:data-file-path="file.path"
|
|
||||||
@click="handleClick"
|
|
||||||
@dblclick="handleDoubleClick"
|
|
||||||
@contextmenu.prevent="handleContextMenu"
|
|
||||||
>
|
|
||||||
<!-- 文件图标 -->
|
|
||||||
<span class="file-item-icon">{{ icon }}</span>
|
|
||||||
|
|
||||||
<!-- 编辑状态 -->
|
|
||||||
<a-input
|
|
||||||
v-if="isEditing"
|
|
||||||
:model-value="editingName"
|
|
||||||
size="mini"
|
|
||||||
class="file-name-edit-input"
|
|
||||||
@update:model-value="handleNameUpdate"
|
|
||||||
@blur="handleSave"
|
|
||||||
@keyup.enter="handleSave"
|
|
||||||
@keyup.esc="handleCancel"
|
|
||||||
@click.stop
|
|
||||||
ref="inputRef"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 正常显示状态 -->
|
|
||||||
<span v-else class="file-item-name" :title="file.name">{{ file.name }}</span>
|
|
||||||
|
|
||||||
<!-- 文件大小 -->
|
|
||||||
<span v-if="!file.is_dir && !isEditing" class="file-item-size">
|
|
||||||
{{ formattedSize }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- 收藏按钮 -->
|
|
||||||
<a-button
|
|
||||||
v-if="!isEditing"
|
|
||||||
type="text"
|
|
||||||
size="mini"
|
|
||||||
@click.stop="handleToggleFavorite"
|
|
||||||
class="file-item-fav"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<icon-star-fill v-if="isFavorited" :style="{ color: '#ffcd00' }" />
|
|
||||||
<icon-star v-else />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<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 type { FileItem } from '@/types/file-system'
|
|
||||||
|
|
||||||
// Props
|
|
||||||
interface Props {
|
|
||||||
file: FileItem
|
|
||||||
isSelected: boolean
|
|
||||||
isEditing: boolean
|
|
||||||
editingName?: string
|
|
||||||
isFavorited: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
editingName: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// Emits
|
|
||||||
interface Emits {
|
|
||||||
(e: 'click', file: FileItem): void
|
|
||||||
(e: 'doubleClick', file: FileItem): void
|
|
||||||
(e: 'toggleFavorite', file: FileItem): void
|
|
||||||
(e: 'save', newName: string): void
|
|
||||||
(e: 'cancel'): void
|
|
||||||
(e: 'nameUpdate', newName: string): void
|
|
||||||
(e: 'contextMenu', event: MouseEvent, file: FileItem): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// Refs
|
|
||||||
const inputRef = ref()
|
|
||||||
|
|
||||||
// 监听编辑状态变化,自动聚焦
|
|
||||||
watch(() => props.isEditing, (newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
nextTick(() => {
|
|
||||||
focusInput()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const icon = computed(() => getFileIcon(props.file))
|
|
||||||
const formattedSize = computed(() => formatBytes(props.file.size))
|
|
||||||
|
|
||||||
// 事件处理
|
|
||||||
const handleClick = () => {
|
|
||||||
emit('click', props.file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
|
||||||
emit('doubleClick', props.file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleFavorite = () => {
|
|
||||||
emit('toggleFavorite', props.file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNameUpdate = (value: string) => {
|
|
||||||
emit('nameUpdate', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
emit('save', props.editingName || props.file.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('cancel')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContextMenu = (event: MouseEvent) => {
|
|
||||||
emit('contextMenu', event, props.file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 聚焦到输入框并选中文本
|
|
||||||
const focusInput = () => {
|
|
||||||
const input = inputRef.value?.$el?.querySelector('input')
|
|
||||||
if (input) {
|
|
||||||
input.focus()
|
|
||||||
|
|
||||||
// 选中文件名部分(不包括扩展名)
|
|
||||||
const value = input.value
|
|
||||||
const lastDotIndex = value.lastIndexOf('.')
|
|
||||||
|
|
||||||
// 如果有扩展名,只选中文件名部分;否则选中全部
|
|
||||||
if (lastDotIndex > 0) {
|
|
||||||
input.setSelectionRange(0, lastDotIndex)
|
|
||||||
} else {
|
|
||||||
input.select()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法供父组件调用
|
|
||||||
const focus = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
focusInput()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
const input = inputRef.value?.$el?.querySelector('input')
|
|
||||||
if (input) {
|
|
||||||
input.select()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
focus,
|
|
||||||
selectAll
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.file-item-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
border-bottom: 1px solid var(--color-border-2);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-row:hover {
|
|
||||||
background: var(--color-fill-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-row.file-item-selected {
|
|
||||||
background: var(--color-fill-3) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-row.file-item-editing {
|
|
||||||
background: var(--color-fill-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-2);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-size {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-fav {
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-row:hover .file-item-fav {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-edit-input {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-edit-input :deep(.arco-input) {
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 0 8px;
|
|
||||||
height: 24px;
|
|
||||||
line-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 编辑状态下的样式调整 */
|
|
||||||
.file-item-row.file-item-editing .file-item-fav {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item-row.file-item-editing .file-item-size {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,40 +2,73 @@
|
|||||||
<div class="file-list-panel" :style="{ width: width + '%' }">
|
<div class="file-list-panel" :style="{ width: width + '%' }">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">📋 文件列表</span>
|
<span class="panel-title">📋 文件列表</span>
|
||||||
<span class="panel-count">{{ config.fileList.length }} 项</span>
|
<div class="panel-header-right">
|
||||||
|
<span class="panel-count">{{ config.fileList.length }} 项</span>
|
||||||
|
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
||||||
|
<a-button size="mini" type="text" class="settings-btn">
|
||||||
|
<icon-more />
|
||||||
|
</a-button>
|
||||||
|
<template #content>
|
||||||
|
<div class="col-setting-title">列设置</div>
|
||||||
|
<div class="col-setting-item" style="cursor: default;">
|
||||||
|
<span class="drag-handle"></span>
|
||||||
|
<a-checkbox :model-value="showHeader" @change="(val: boolean) => { showHeader = val; localStorage.setItem(SHOW_HEADER_KEY, String(val)) }">
|
||||||
|
显示表头
|
||||||
|
</a-checkbox>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(col, idx) in orderedColumns"
|
||||||
|
:key="col.key"
|
||||||
|
class="col-setting-item"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, idx)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="onDrop($event, idx)"
|
||||||
|
>
|
||||||
|
<span class="drag-handle">⠿</span>
|
||||||
|
<a-checkbox
|
||||||
|
:model-value="col.visible"
|
||||||
|
:disabled="col.key === 'name' && visibleCount <= 1"
|
||||||
|
@change="(val: boolean) => toggleColumn(col.key, val)"
|
||||||
|
>{{ col.label }}</a-checkbox>
|
||||||
|
<!-- 可排序列:点击图标排序 -->
|
||||||
|
<span
|
||||||
|
v-if="colSortMap[col.key]"
|
||||||
|
class="col-sort-icon"
|
||||||
|
:class="{ 'col-sort-active': sortBy === colSortMap[col.key] }"
|
||||||
|
:title="sortBy === colSortMap[col.key] ? (sortOrder === 'asc' ? '升序 → 点击降序' : '降序 → 点击升序') : `按${col.label}排序`"
|
||||||
|
@click.stop="emit('sort', colSortMap[col.key])"
|
||||||
|
>
|
||||||
|
<IconSort v-if="sortBy !== colSortMap[col.key]" />
|
||||||
|
<IconSortAscending v-else-if="sortOrder === 'asc'" />
|
||||||
|
<IconSortDescending v-else />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-popover>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="file-list-wrapper"
|
class="file-list-wrapper"
|
||||||
@contextmenu.prevent="handleContextMenu"
|
@contextmenu.prevent="handleWrapperContextMenu"
|
||||||
>
|
>
|
||||||
<!-- 文件列表 -->
|
<!-- 文件列表(a-table) -->
|
||||||
<a-list
|
<a-table
|
||||||
v-if="config.fileList.length > 0 || config.fileLoading"
|
v-if="config.fileList.length > 0 || config.fileLoading"
|
||||||
|
:columns="tableColumns"
|
||||||
:data="config.fileList"
|
:data="config.fileList"
|
||||||
:loading="config.fileLoading"
|
:loading="config.fileLoading"
|
||||||
:bordered="false"
|
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
class="compact-list"
|
:bordered="false"
|
||||||
>
|
:show-header="showHeader"
|
||||||
<template #item="{ item }">
|
size="mini"
|
||||||
<FileItemRow
|
:row-class-name="getRowClassName"
|
||||||
:file="item"
|
:scroll="{ y: 'auto' }"
|
||||||
:is-selected="isSelected(item)"
|
class="file-table"
|
||||||
:is-editing="isEditing(item)"
|
@row-click="handleRowClick"
|
||||||
:editing-name="props.config.editingFileName"
|
@row-contextmenu="handleRowContextMenu"
|
||||||
:is-favorited="isFavorited(item.path)"
|
/>
|
||||||
@click="handleFileClick"
|
|
||||||
@double-click="handleFileDoubleClick"
|
|
||||||
@toggle-favorite="handleToggleFavorite"
|
|
||||||
@save="handleSaveEditing"
|
|
||||||
@cancel="handleCancelEditing"
|
|
||||||
@name-update="handleNameUpdate"
|
|
||||||
@context-menu="handleItemContextMenu"
|
|
||||||
ref="fileItemRefs"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</a-list>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div v-if="config.fileList.length === 0 && !config.fileLoading" class="empty-state">
|
<div v-if="config.fileList.length === 0 && !config.fileLoading" class="empty-state">
|
||||||
@@ -47,8 +80,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { h, computed, nextTick, ref } from 'vue'
|
||||||
import FileItemRow from './FileItemRow.vue'
|
import { Input, Button } from '@arco-design/web-vue'
|
||||||
|
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } from '@arco-design/web-vue/es/icon'
|
||||||
|
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
|
||||||
|
import { STORAGE_KEYS } from '@/utils/constants'
|
||||||
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
import type { FileListPanelConfig, FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@@ -56,6 +92,8 @@ interface Props {
|
|||||||
config: FileListPanelConfig
|
config: FileListPanelConfig
|
||||||
width: number
|
width: number
|
||||||
favorites: string[]
|
favorites: string[]
|
||||||
|
sortBy: string
|
||||||
|
sortOrder: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -70,96 +108,267 @@ interface Emits {
|
|||||||
(e: 'cancelEditing'): void
|
(e: 'cancelEditing'): void
|
||||||
(e: 'contextMenu', event: MouseEvent, file: FileItem | null): void
|
(e: 'contextMenu', event: MouseEvent, file: FileItem | null): void
|
||||||
(e: 'nameUpdate', newName: string): void
|
(e: 'nameUpdate', newName: string): void
|
||||||
|
(e: 'sort', field: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// Refs
|
// 列 key → 排序字段映射
|
||||||
const fileItemRefs = ref()
|
const colSortMap: Record<string, string> = {
|
||||||
|
icon: 'type',
|
||||||
// 计算辅助方法
|
name: 'name',
|
||||||
const isSelected = (item: FileItem): boolean => {
|
time: 'modified_time',
|
||||||
return props.config.selectedFileItem?.path === item.path
|
size: 'size'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEditing = (item: FileItem): boolean => {
|
// ========== 列配置(支持显隐 + 排序) ==========
|
||||||
return props.config.editingFilePath === item.path
|
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
|
||||||
|
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
|
||||||
|
|
||||||
|
interface ColumnConfig {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
visible: boolean
|
||||||
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFavorited = (path: string): boolean => {
|
const defaultColumns: ColumnConfig[] = [
|
||||||
return props.favorites.includes(path)
|
{ key: 'icon', label: '图标(T)', visible: true, order: 0 },
|
||||||
}
|
{ key: 'name', label: '名称', visible: true, order: 1 },
|
||||||
|
{ key: 'time', label: '时间', visible: true, order: 2 },
|
||||||
|
{ key: 'size', label: '大小', visible: true, order: 3 },
|
||||||
|
{ key: 'fav', label: '收藏', visible: true, order: 4 }
|
||||||
|
]
|
||||||
|
|
||||||
// 事件处理
|
// 从 localStorage 恢复或使用默认值(按 key 匹合,允许列数变化)
|
||||||
const handleFileClick = (file: FileItem) => {
|
function loadColSettings(): ColumnConfig[] {
|
||||||
emit('fileClick', file)
|
try {
|
||||||
}
|
const saved = localStorage.getItem(COL_SETTINGS_KEY)
|
||||||
|
if (saved) {
|
||||||
const handleFileDoubleClick = (file: FileItem) => {
|
const parsed = JSON.parse(saved) as ColumnConfig[]
|
||||||
emit('fileDoubleClick', file)
|
if (Array.isArray(parsed)) {
|
||||||
}
|
// 以 defaultColumns 为基准,合并已保存的 visible/order
|
||||||
|
return defaultColumns.map((def, i) => {
|
||||||
const handleToggleFavorite = (file: FileItem) => {
|
const existing = parsed.find(p => p.key === def.key)
|
||||||
emit('toggleFavorite', file)
|
return existing ? { ...def, visible: existing.visible ?? true, order: existing.order ?? i } : { ...def }
|
||||||
}
|
})
|
||||||
|
}
|
||||||
const handleNameUpdate = (newName: string) => {
|
|
||||||
emit('nameUpdate', newName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveEditing = (newName: string) => {
|
|
||||||
if (props.config.editingFilePath) {
|
|
||||||
emit('saveEditing', props.config.editingFilePath, newName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelEditing = () => {
|
|
||||||
emit('cancelEditing')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemContextMenu = (event: MouseEvent, file: FileItem) => {
|
|
||||||
emit('contextMenu', event, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContextMenu = (event: MouseEvent) => {
|
|
||||||
// 检查点击的是哪个文件项
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
const listItem = target.closest('.arco-list-item')
|
|
||||||
|
|
||||||
if (listItem) {
|
|
||||||
// 找到对应的文件索引
|
|
||||||
const items = document.querySelectorAll('.arco-list-item')
|
|
||||||
const index = Array.from(items).indexOf(listItem)
|
|
||||||
|
|
||||||
if (index !== -1 && index < props.config.fileList.length) {
|
|
||||||
const clickedFile = props.config.fileList[index]
|
|
||||||
emit('contextMenu', event, clickedFile)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
} catch { /* localStorage 不可用则使用默认列配置 */ }
|
||||||
|
return [...defaultColumns]
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有点击文件项,传递空白区域事件
|
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
||||||
|
// 默认显示表头(localStorage 无值时兼容旧行为)
|
||||||
|
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
|
||||||
|
|
||||||
|
// 手动持久化(避免 deep watch 频繁写入)
|
||||||
|
function saveColSettings() {
|
||||||
|
localStorage.setItem(COL_SETTINGS_KEY, JSON.stringify(colSettings.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序后的列配置
|
||||||
|
const orderedColumns = computed(() =>
|
||||||
|
[...colSettings.value].sort((a, b) => a.order - b.order)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 可见列数量
|
||||||
|
const visibleCount = computed(() =>
|
||||||
|
colSettings.value.filter(c => c.visible).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 切换单列显隐
|
||||||
|
const toggleColumn = (key: string, visible: boolean) => {
|
||||||
|
const col = colSettings.value.find(c => c.key === key)
|
||||||
|
if (col) { col.visible = visible; saveColSettings() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML5 拖拽排序
|
||||||
|
const dragIdx = ref(-1)
|
||||||
|
const onDragStart = (_e: DragEvent, idx: number) => { dragIdx.value = idx }
|
||||||
|
const onDrop = (_e: DragEvent, idx: number) => {
|
||||||
|
if (dragIdx.value === -1 || dragIdx.value === idx) return
|
||||||
|
const list = [...orderedColumns.value]
|
||||||
|
const [moved] = list.splice(dragIdx.value, 1)
|
||||||
|
list.splice(idx, 0, moved)
|
||||||
|
// 更新 order 值
|
||||||
|
list.forEach((c, i) => {
|
||||||
|
const target = colSettings.value.find(x => x.key === c.key)
|
||||||
|
if (target) target.order = i
|
||||||
|
})
|
||||||
|
dragIdx.value = -1
|
||||||
|
saveColSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序图标渲染
|
||||||
|
const sortIcon = (field: string) => {
|
||||||
|
if (props.sortBy !== field) return () => h(IconSort, { style: { fontSize: '12px', color: 'var(--color-text-4)' } })
|
||||||
|
return () => props.sortOrder === 'asc'
|
||||||
|
? h(IconSortAscending, { style: { fontSize: '12px', color: 'rgb(var(--primary-6))' } })
|
||||||
|
: h(IconSortDescending, { style: { fontSize: '12px', color: 'rgb(var(--primary-6))' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据配置构建单列定义
|
||||||
|
function buildColumn(key: string, editPath: string | undefined) {
|
||||||
|
switch (key) {
|
||||||
|
case 'icon':
|
||||||
|
return {
|
||||||
|
title: () => h('div', {
|
||||||
|
class: 'th-sortable th-sort-center',
|
||||||
|
onClick: () => emit('sort', 'type')
|
||||||
|
}, [
|
||||||
|
h('span', { style: { fontWeight: 600, fontSize: '11px', marginRight: '2px' } }, 'T'),
|
||||||
|
sortIcon('type')()
|
||||||
|
]),
|
||||||
|
width: 32,
|
||||||
|
bodyCellClass: 'col-icon',
|
||||||
|
render: ({ record }: { record: FileItem }) => {
|
||||||
|
const ext = getExt(record.name)
|
||||||
|
return h('span', {
|
||||||
|
class: 'file-item-icon',
|
||||||
|
title: ext ? `.${ext.toUpperCase()} : ${record.name}` : record.name
|
||||||
|
}, getFileIcon(record))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'name':
|
||||||
|
return {
|
||||||
|
title: () => h('div', {
|
||||||
|
class: 'th-sortable',
|
||||||
|
onClick: () => emit('sort', 'name')
|
||||||
|
}, [
|
||||||
|
h('span', { style: { fontWeight: 600 } }, '名称'),
|
||||||
|
sortIcon('name')()
|
||||||
|
]),
|
||||||
|
dataIndex: 'name',
|
||||||
|
ellipsis: true,
|
||||||
|
render: ({ record }: { record: FileItem }) => {
|
||||||
|
const isEditing = editPath === record.path
|
||||||
|
if (isEditing) {
|
||||||
|
return h(Input, {
|
||||||
|
modelValue: props.config.editingFileName || record.name,
|
||||||
|
size: 'mini',
|
||||||
|
class: 'file-name-edit-input',
|
||||||
|
'onUpdate:modelValue': (val: string) => emit('nameUpdate', val),
|
||||||
|
onBlur: () => emit('saveEditing', editPath!, props.config.editingFileName || record.name),
|
||||||
|
onKeyup: (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === 'Enter') emit('saveEditing', editPath!, props.config.editingFileName || record.name)
|
||||||
|
else if (ev.key === 'Escape') emit('cancelEditing')
|
||||||
|
},
|
||||||
|
onClick: (ev: Event) => ev.stopPropagation()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return h('span', { class: 'file-item-name', title: record.name }, record.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'time':
|
||||||
|
return {
|
||||||
|
title: () => h('div', {
|
||||||
|
class: 'th-sortable th-sort-right',
|
||||||
|
onClick: () => emit('sort', 'modified_time')
|
||||||
|
}, [
|
||||||
|
h('span', { style: { fontWeight: 600 } }, '时间'),
|
||||||
|
sortIcon('modified_time')()
|
||||||
|
]),
|
||||||
|
dataIndex: 'modified_time',
|
||||||
|
width: 125,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: ({ record }: { record: FileItem }) => {
|
||||||
|
if (editPath === record.path || !record.modified_time) return null
|
||||||
|
return h('span', { class: 'file-item-time' }, formatFileTime(record.modified_time))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'size':
|
||||||
|
return {
|
||||||
|
title: () => h('div', {
|
||||||
|
class: 'th-sortable th-sort-right',
|
||||||
|
onClick: () => emit('sort', 'size')
|
||||||
|
}, [
|
||||||
|
h('span', { style: { fontWeight: 600 } }, '大小'),
|
||||||
|
sortIcon('size')()
|
||||||
|
]),
|
||||||
|
dataIndex: 'size',
|
||||||
|
width: 70,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: ({ record }: { record: FileItem }) => {
|
||||||
|
if (record.isDir || editPath === record.path) return null
|
||||||
|
return h('span', { class: 'file-item-size' }, formatBytes(record.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'fav':
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
width: 28,
|
||||||
|
render: ({ record }: { record: FileItem }) => {
|
||||||
|
if (editPath === record.path) return null
|
||||||
|
const favorited = props.favorites.includes(record.path)
|
||||||
|
return h(Button, {
|
||||||
|
type: 'text',
|
||||||
|
size: 'mini',
|
||||||
|
class: 'file-item-fav',
|
||||||
|
onClick: (ev: Event) => { ev.stopPropagation(); emit('toggleFavorite', record) }
|
||||||
|
}, {
|
||||||
|
icon: () => favorited
|
||||||
|
? h(IconStarFill, { style: { color: '#ffcd00' } })
|
||||||
|
: h(IconStar)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 动态表格列 ==========
|
||||||
|
const tableColumns = computed(() => {
|
||||||
|
const editPath = props.config.editingFilePath
|
||||||
|
return orderedColumns.value
|
||||||
|
.filter(c => c.visible)
|
||||||
|
.map(c => buildColumn(c.key, editPath))
|
||||||
|
.filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========== 行事件处理 ==========
|
||||||
|
const handleRowClick = (record: FileItem, ev: Event) => {
|
||||||
|
const target = ev.target as HTMLElement
|
||||||
|
if (target.closest('.arco-btn') || target.closest('.arco-input-wrapper')) return
|
||||||
|
emit('fileClick', record)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowContextMenu = (record: FileItem, ev: Event) => {
|
||||||
|
ev.preventDefault()
|
||||||
|
emit('contextMenu', ev as MouseEvent, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowClassName = (record: FileItem): string => [
|
||||||
|
props.config.selectedFileItem?.path === record.path && 'row-selected',
|
||||||
|
props.config.editingFilePath === record.path && 'row-editing'
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
const handleWrapperContextMenu = (event: MouseEvent) => {
|
||||||
emit('contextMenu', event, null)
|
emit('contextMenu', event, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露方法供父组件调用
|
|
||||||
const focusEditingItem = () => {
|
const focusEditingItem = () => {
|
||||||
const index = props.config.fileList.findIndex(
|
nextTick(() => {
|
||||||
item => item.path === props.config.editingFilePath
|
const input = document.querySelector('.file-table .file-name-edit-input input') as HTMLInputElement | null
|
||||||
)
|
if (!input) return
|
||||||
if (index !== -1 && fileItemRefs.value?.[index]) {
|
input.focus()
|
||||||
const item = fileItemRefs.value[index]
|
const val = input.value
|
||||||
item.focus?.()
|
const dot = val.lastIndexOf('.')
|
||||||
item.selectAll?.()
|
input.setSelectionRange(0, dot > 0 ? dot : val.length)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({ focusEditingItem })
|
||||||
focusEditingItem
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* ====== 布局 ====== */
|
||||||
.file-list-panel {
|
.file-list-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -168,37 +377,183 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 10px 12px;
|
padding: 6px 12px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--color-bg-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-header-right {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
color: var(--color-text-1);
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-count {
|
.panel-title { font-size: 13px; font-weight: 600; color: var(--color-text-1); }
|
||||||
font-size: 12px;
|
.panel-count { font-size: 12px; color: var(--color-text-3); }
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.settings-btn:hover {
|
||||||
|
color: var(--color-text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 列项排序图标 */
|
||||||
|
.col-sort-icon {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.col-sort-icon:hover {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
.col-sort-active {
|
||||||
|
color: rgb(var(--primary-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动容器 */
|
||||||
.file-list-wrapper {
|
.file-list-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px;
|
overflow-x: hidden;
|
||||||
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-list :deep(.arco-list-item) {
|
/* ====== Table 全局覆盖 ====== */
|
||||||
padding: 0;
|
.file-table :deep(.arco-table) {
|
||||||
border: none;
|
font-size: 13px;
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-table :deep(.arco-table-cell) {
|
||||||
|
padding: 5px 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头样式 */
|
||||||
|
.file-table :deep(.arco-table-header) {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table :deep(.arco-table-th) {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
font-weight: normal;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 可排序列头 */
|
||||||
|
.file-table :deep(.th-sortable) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.file-table :deep(.th-sortable:hover) {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
}
|
||||||
|
.file-table :deep(.th-sort-right) { justify-content: flex-end; }
|
||||||
|
.file-table :deep(.th-sort-center) { justify-content: center; }
|
||||||
|
|
||||||
|
/* 表体行 */
|
||||||
|
.file-table :deep(.arco-table-tbody .arco-table-tr) {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.file-table :deep(.arco-table-tbody .arco-table-tr:hover:not(.row-selected)) {
|
||||||
|
background: var(--color-fill-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数据单元格 */
|
||||||
|
.file-table :deep(.arco-table-td) {
|
||||||
|
border-bottom: 1px solid var(--color-border-2);
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行状态 */
|
||||||
|
.file-table :deep(.arco-table-tr.row-selected) {
|
||||||
|
background: var(--color-fill-3) !important;
|
||||||
|
}
|
||||||
|
.file-table :deep(.arco-table-tr.row-selected .file-item-name) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.file-table :deep(.arco-table-tr.row-editing) {
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 列内容 ====== */
|
||||||
|
.col-icon { text-align: center; vertical-align: middle !important; }
|
||||||
|
.file-item-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.file-item-name { font-size: 13px; color: var(--color-text-2); }
|
||||||
|
.file-item-size,
|
||||||
|
.file-item-time { font-size: 11px; color: var(--color-text-3); }
|
||||||
|
|
||||||
|
/* 收藏星标 */
|
||||||
|
.file-item-fav { opacity: 0.5; transition: opacity 0.2s; }
|
||||||
|
.file-table :deep(.arco-table-tr:hover .file-item-fav) { opacity: 1; }
|
||||||
|
|
||||||
|
/* 编辑输入框 */
|
||||||
|
.file-name-edit-input :deep(.arco-input) {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== 列设置面板 ====== */
|
||||||
|
.col-setting-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
padding: 4px 8px 6px;
|
||||||
|
border-bottom: 1px solid var(--color-border-2);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-setting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.col-setting-item:active { cursor: grabbing; }
|
||||||
|
.col-setting-item:hover { background: var(--color-fill-1); }
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -208,8 +563,5 @@ defineExpose({
|
|||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.empty-state span:nth-child(2) { font-size: 14px; }
|
||||||
.empty-state span:nth-child(2) {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -55,9 +55,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, provide, type Ref } from 'vue'
|
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 { listDir } from '@/api/system'
|
||||||
import { getParentPath, joinPath, normalizePathSeparators } from '@/utils/pathHelpers'
|
|
||||||
import { sortFileList } from '@/utils/fileUtils'
|
import { sortFileList } from '@/utils/fileUtils'
|
||||||
import { useTimeout } from '@/composables/useTimeout'
|
import { useTimeout } from '@/composables/useTimeout'
|
||||||
import DropdownItem from './DropdownItem.vue'
|
import DropdownItem from './DropdownItem.vue'
|
||||||
@@ -101,38 +100,39 @@ interface PathSegment {
|
|||||||
const segments = computed<PathSegment[]>(() => {
|
const segments = computed<PathSegment[]>(() => {
|
||||||
if (!props.path) return []
|
if (!props.path) return []
|
||||||
|
|
||||||
const normalizedPath = props.path.replace(/\\/g, '/')
|
const path = props.path.replace(/\\/g, '/')
|
||||||
|
|
||||||
if (/^[A-Za-z]:\/?$/.test(normalizedPath)) {
|
// 根目录
|
||||||
const driveLetter = normalizedPath.charAt(0) + ':'
|
if (/^[A-Za-z]:\/?$/.test(path)) {
|
||||||
return [{ name: driveLetter, path: driveLetter + '/' }]
|
const drive = path[0] + ':'
|
||||||
|
return [{ name: drive, path: drive + '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = normalizedPath.split('/').filter(p => p)
|
return path.split('/').filter(Boolean).reduce<PathSegment[]>((acc, part, i) => {
|
||||||
let currentPath = ''
|
const prev = acc[i - 1]?.path || ''
|
||||||
|
const current = part.endsWith(':') ? part + '/' : prev + (prev.endsWith('/') ? '' : '/') + part
|
||||||
return parts.map((part, index) => {
|
acc.push({ name: part, path: current })
|
||||||
if (index === 0 && part.endsWith(':')) {
|
return acc
|
||||||
currentPath = part + '/'
|
}, [])
|
||||||
} else {
|
|
||||||
currentPath += '/' + part
|
|
||||||
}
|
|
||||||
return { name: part, path: currentPath }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeIndex = ref<number | null>(null)
|
const activeIndex = ref<number | null>(null)
|
||||||
|
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||||
const closeTimer = ref<NodeJS.Timeout | null>(null)
|
const closeTimer = ref<NodeJS.Timeout | null>(null)
|
||||||
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const lastLoadedPath = ref('')
|
||||||
|
|
||||||
const loadChildren = async (path: string) => {
|
const loadChildren = async (path: string) => {
|
||||||
|
if (path === lastLoadedPath.value) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await listDir(path)
|
const files = await listDir(path)
|
||||||
|
lastLoadedPath.value = path
|
||||||
children.value = sortFileList(files.map(f => ({
|
children.value = sortFileList(files.map(f => ({
|
||||||
name: f.name,
|
name: f.name,
|
||||||
path: f.path,
|
path: f.path,
|
||||||
@@ -154,17 +154,22 @@ const resetAndClose = () => {
|
|||||||
const onHover = (segment: PathSegment, index: number) => {
|
const onHover = (segment: PathSegment, index: number) => {
|
||||||
if (index === segments.value.length - 1) return
|
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
|
activeIndex.value = index
|
||||||
loadChildren(segment.path)
|
loadChildren(segment.path)
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMenuEnter = () => {
|
const onMenuEnter = () => {
|
||||||
|
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMenuLeave = () => {
|
const onMenuLeave = () => {
|
||||||
|
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||||
closeTimer.value = delay(() => {
|
closeTimer.value = delay(() => {
|
||||||
resetAndClose()
|
resetAndClose()
|
||||||
}, 100)
|
}, 100)
|
||||||
@@ -188,6 +193,7 @@ const onOpenFile = (path: string) => {
|
|||||||
watch(() => props.path, () => {
|
watch(() => props.path, () => {
|
||||||
activeIndex.value = null
|
activeIndex.value = null
|
||||||
children.value = []
|
children.value = []
|
||||||
|
lastLoadedPath.value = ''
|
||||||
openMenus.value = new Map()
|
openMenus.value = new Map()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
:key="fav.path"
|
:key="fav.path"
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
:class="{
|
:class="{
|
||||||
|
'sidebar-item-pinned': fav.pinnedAt,
|
||||||
|
'sidebar-item-pinned-first': index === firstPinnedIndex,
|
||||||
|
'sidebar-item-pinned-last': index === lastPinnedIndex,
|
||||||
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
|
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
|
||||||
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
|
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
|
||||||
}"
|
}"
|
||||||
@@ -27,8 +30,19 @@
|
|||||||
@drop="handleDrop($event, index)"
|
@drop="handleDrop($event, index)"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
>
|
>
|
||||||
<span class="sidebar-item-icon">{{ fav.isDir ? '📁' : '📄' }}</span>
|
<span class="sidebar-item-icon">{{ getFileIcon(fav) }}</span>
|
||||||
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
|
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="mini"
|
||||||
|
@click.stop="handleTogglePin(fav)"
|
||||||
|
class="sidebar-item-pin"
|
||||||
|
:class="{ 'is-pinned': fav.pinnedAt }"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<icon-pushpin :style="{ opacity: fav.pinnedAt ? 1 : 0.4 }" />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
size="mini"
|
size="mini"
|
||||||
@@ -51,6 +65,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@@ -60,10 +75,21 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// 计算第一个和最后一个置顶项的索引
|
||||||
|
const pinnedIndices = computed(() => {
|
||||||
|
return props.config.favoriteFiles
|
||||||
|
.map((fav, index) => fav.pinnedAt ? index : -1)
|
||||||
|
.filter(i => i !== -1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstPinnedIndex = computed(() => pinnedIndices.value[0] ?? -1)
|
||||||
|
const lastPinnedIndex = computed(() => pinnedIndices.value[pinnedIndices.value.length - 1] ?? -1)
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'openFavorite', file: FavoriteFile): void
|
(e: 'openFavorite', file: FavoriteFile): void
|
||||||
(e: 'removeFavorite', path: string): void
|
(e: 'removeFavorite', path: string): void
|
||||||
|
(e: 'togglePin', path: string): void
|
||||||
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
|
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
|
||||||
(e: 'longPressCancel'): void
|
(e: 'longPressCancel'): void
|
||||||
(e: 'dragStart', event: DragEvent, index: number): void
|
(e: 'dragStart', event: DragEvent, index: number): void
|
||||||
@@ -75,7 +101,8 @@ interface Emits {
|
|||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// 图标导入
|
// 图标导入
|
||||||
import { IconStar, IconClose } from '@arco-design/web-vue/es/icon'
|
import { IconStar, IconClose, IconPushpin } from '@arco-design/web-vue/es/icon'
|
||||||
|
import { getFileIcon } from '@/utils/fileUtils'
|
||||||
|
|
||||||
// 事件处理
|
// 事件处理
|
||||||
const handleOpenFavorite = (file: FavoriteFile) => {
|
const handleOpenFavorite = (file: FavoriteFile) => {
|
||||||
@@ -86,6 +113,10 @@ const handleRemoveFavorite = (file: FavoriteFile) => {
|
|||||||
emit('removeFavorite', file.path)
|
emit('removeFavorite', file.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = (file: FavoriteFile) => {
|
||||||
|
emit('togglePin', file.path)
|
||||||
|
}
|
||||||
|
|
||||||
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||||
emit('longPressStart', event, index)
|
emit('longPressStart', event, index)
|
||||||
}
|
}
|
||||||
@@ -200,6 +231,32 @@ const handleDragEnd = () => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-item-pinned {
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-pinned-first {
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-pinned-last {
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-pinned-first.sidebar-item-pinned-last {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-pin {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover .sidebar-item-pin {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-empty {
|
.sidebar-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -27,64 +27,58 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 正常模式:面包屑导航 -->
|
<!-- 正常模式:面包屑导航 -->
|
||||||
<div v-else class="path-breadcrumb-wrapper">
|
<div v-else class="path-breadcrumb-wrapper">
|
||||||
|
<!-- 快捷访问(仅图标,面包屑前) -->
|
||||||
|
<a-dropdown>
|
||||||
|
<a-button size="mini" type="text">
|
||||||
|
<template #icon><icon-forward /></template>
|
||||||
|
</a-button>
|
||||||
|
<template #content>
|
||||||
|
<a-doption
|
||||||
|
v-for="shortcut in config.commonPaths"
|
||||||
|
:key="shortcut.path"
|
||||||
|
@click="handleGoToPath(shortcut.path)"
|
||||||
|
>
|
||||||
|
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
||||||
|
{{ (shortcut.name || '').substring(2) }}
|
||||||
|
</a-doption>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
<PathBreadcrumb
|
<PathBreadcrumb
|
||||||
:path="config.filePath"
|
:path="config.filePath"
|
||||||
@navigate="handleGoToPath"
|
@navigate="handleGoToPath"
|
||||||
@openFile="handleOpenFile"
|
@openFile="handleOpenFile"
|
||||||
/>
|
/>
|
||||||
<a-tooltip content="复制路径" position="top">
|
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
|
||||||
<div class="copy-icon-wrapper" @click="handleCopyPath">
|
<a-button
|
||||||
<icon-copy />
|
size="mini"
|
||||||
</div>
|
type="text"
|
||||||
|
:status="copied ? 'success' : 'normal'"
|
||||||
|
class="toolbar-copy-btn"
|
||||||
|
@click="handleCopyPath"
|
||||||
|
>
|
||||||
|
<icon-copy v-if="!copied" />
|
||||||
|
<icon-check v-else />
|
||||||
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<!-- 快捷路径下拉 -->
|
<!-- 搜索框 -->
|
||||||
<a-dropdown v-if="!config.isBrowsingZip">
|
<a-input-search
|
||||||
<a-button size="small">
|
:model-value="config.searchKeyword"
|
||||||
<template #icon>
|
placeholder="搜索文件..."
|
||||||
<icon-forward />
|
size="small"
|
||||||
</template>
|
class="toolbar-search"
|
||||||
快捷访问
|
allow-clear
|
||||||
</a-button>
|
@search="handleSearch"
|
||||||
<template #content>
|
@update:model-value="handleSearchInput"
|
||||||
<a-doption
|
@keyup.escape="handleClearSearch"
|
||||||
v-for="shortcut in config.commonPaths"
|
/>
|
||||||
:key="shortcut.path"
|
|
||||||
@click="handleGoToPath(shortcut.path)"
|
|
||||||
>
|
|
||||||
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
|
||||||
{{ (shortcut.name || '').substring(2) }}
|
|
||||||
</a-doption>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
|
|
||||||
<!-- 历史记录下拉 -->
|
|
||||||
<a-dropdown>
|
|
||||||
<a-button size="small">
|
|
||||||
<template #icon>
|
|
||||||
<icon-history />
|
|
||||||
</template>
|
|
||||||
历史
|
|
||||||
</a-button>
|
|
||||||
<template #content>
|
|
||||||
<a-doption
|
|
||||||
v-for="path in config.pathHistory.slice(0, 10)"
|
|
||||||
:key="path"
|
|
||||||
@click="handleGoToPath(path)"
|
|
||||||
>
|
|
||||||
{{ path }}
|
|
||||||
</a-doption>
|
|
||||||
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
|
||||||
size="small"
|
size="small"
|
||||||
:loading="config.fileLoading"
|
:loading="config.fileLoading"
|
||||||
@click="handleRefresh"
|
@click="handleRefresh"
|
||||||
@@ -95,6 +89,29 @@
|
|||||||
刷新
|
刷新
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 历史记录下拉(仅图标,Ctrl+H) -->
|
||||||
|
<a-dropdown
|
||||||
|
v-model:popup-visible="historyPopupVisible"
|
||||||
|
>
|
||||||
|
<a-tooltip content="历史记录 (Ctrl+H)" position="left">
|
||||||
|
<a-button size="small">
|
||||||
|
<template #icon><icon-history /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<template #content>
|
||||||
|
<div class="history-dropdown-content">
|
||||||
|
<a-doption
|
||||||
|
v-for="path in config.pathHistory.slice(0, 10)"
|
||||||
|
:key="path"
|
||||||
|
@click="handleGoToPath(path)"
|
||||||
|
>
|
||||||
|
<span class="history-path-text">{{ path }}</span>
|
||||||
|
</a-doption>
|
||||||
|
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
|
||||||
<!-- 切换侧边栏 -->
|
<!-- 切换侧边栏 -->
|
||||||
<a-button
|
<a-button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -110,10 +127,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy } from '@arco-design/web-vue/es/icon'
|
|
||||||
import type { ToolbarConfig } from '@/types/file-system'
|
import type { ToolbarConfig } from '@/types/file-system'
|
||||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||||
|
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -126,25 +143,20 @@ const props = defineProps<Props>()
|
|||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:filePath', path: string): void
|
(e: 'update:filePath', path: string): void
|
||||||
(e: 'update:showSidebar', show: boolean): void
|
(e: 'update:showSidebar', show: boolean): void
|
||||||
|
(e: 'update:searchKeyword', keyword: string): void
|
||||||
(e: 'refresh'): void
|
(e: 'refresh'): void
|
||||||
(e: 'exitZip'): void
|
(e: 'exitZip'): void
|
||||||
(e: 'goToPath', path: string): void
|
(e: 'goToPath', path: string): void
|
||||||
(e: 'openFile', path: string): void
|
(e: 'openFile', path: string): void
|
||||||
(e: 'navigateToZipDirectory', path: string): void
|
(e: 'navigateToZipDirectory', path: string): void
|
||||||
(e: 'showMessage', message: string, type: 'success' | 'error' | 'warning' | 'info'): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
|
||||||
|
const historyPopupVisible = ref(false)
|
||||||
|
|
||||||
// 事件处理
|
// 事件处理
|
||||||
const handlePathUpdate = (path: string) => {
|
|
||||||
emit('update:filePath', path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePathSelect = (value: string) => {
|
|
||||||
emit('goToPath', value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGoToPath = (path: string) => {
|
const handleGoToPath = (path: string) => {
|
||||||
emit('goToPath', path)
|
emit('goToPath', path)
|
||||||
}
|
}
|
||||||
@@ -173,22 +185,30 @@ const handleToggleSidebar = () => {
|
|||||||
emit('update:showSidebar', !props.config.showSidebar)
|
emit('update:showSidebar', !props.config.showSidebar)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyPath = async () => {
|
const handleSearch = (keyword: string) => {
|
||||||
const path = props.config.filePath
|
emit('update:searchKeyword', keyword)
|
||||||
if (!path) return
|
}
|
||||||
|
|
||||||
try {
|
const handleSearchInput = (keyword: string) => {
|
||||||
await navigator.clipboard.writeText(path)
|
emit('update:searchKeyword', keyword)
|
||||||
emit('showMessage', '路径已复制', 'success')
|
}
|
||||||
} catch {
|
|
||||||
const input = document.createElement('input')
|
const handleClearSearch = () => {
|
||||||
input.value = path
|
emit('update:searchKeyword', '')
|
||||||
document.body.appendChild(input)
|
}
|
||||||
input.select()
|
|
||||||
document.execCommand('copy')
|
// 切换历史记录下拉面板(供父组件 Ctrl+H 调用)
|
||||||
document.body.removeChild(input)
|
const toggleHistoryDropdown = () => {
|
||||||
emit('showMessage', '路径已复制', 'success')
|
historyPopupVisible.value = !historyPopupVisible.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { copied, copy: copyPath } = useClipboardCopy()
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({ toggleHistoryDropdown })
|
||||||
|
|
||||||
|
const handleCopyPath = async () => {
|
||||||
|
await copyPath(props.config.filePath)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -217,6 +237,11 @@ const handleCopyPath = async () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-search {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.path-input-wrapper {
|
.path-input-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
@@ -243,22 +268,8 @@ const handleCopyPath = async () => {
|
|||||||
border-color: var(--color-border-2);
|
border-color: var(--color-border-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-icon-wrapper {
|
.toolbar-copy-btn {
|
||||||
display: flex;
|
padding: 2px 4px;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 4px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-icon-wrapper:hover {
|
|
||||||
color: rgb(var(--primary-6));
|
|
||||||
background: var(--color-fill-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zip-breadcrumb {
|
.zip-breadcrumb {
|
||||||
@@ -309,4 +320,19 @@ const handleCopyPath = async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 历史记录下拉 */
|
||||||
|
.history-dropdown-content {
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-path-text {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拷贝路径 composable(3-tier fallback: Wails native → clipboard API → execCommand)
|
||||||
|
*/
|
||||||
|
export function useClipboardCopy() {
|
||||||
|
const copied = ref(false)
|
||||||
|
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const copy = async (path: string) => {
|
||||||
|
if (!path || copied.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.runtime?.ClipboardSetText) {
|
||||||
|
await window.runtime.ClipboardSetText(path)
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(path)
|
||||||
|
}
|
||||||
|
copied.value = true
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.style.position = 'fixed'
|
||||||
|
input.style.opacity = '0'
|
||||||
|
input.value = path
|
||||||
|
document.body.appendChild(input)
|
||||||
|
input.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(input)
|
||||||
|
copied.value = true
|
||||||
|
} catch {
|
||||||
|
Message.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyTimer) clearTimeout(copyTimer)
|
||||||
|
copyTimer = setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (copyTimer) { clearTimeout(copyTimer); copyTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { copied, copy, cleanup }
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
* 提供收藏文件的添加、删除、排序等功能
|
* 提供收藏文件的添加、删除、排序等功能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, watch } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { STORAGE_KEYS } from '@/utils/constants'
|
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'
|
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
|
||||||
|
|
||||||
export function useFavorites() {
|
export function useFavorites() {
|
||||||
@@ -18,6 +20,21 @@ export function useFavorites() {
|
|||||||
pressedIndex: -1
|
pressedIndex: -1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序收藏列表:置顶项在前(按 pinnedAt 降序),非置顶项按添加时间降序
|
||||||
|
*/
|
||||||
|
const sortFavorites = () => {
|
||||||
|
favorites.value = [...favorites.value].sort((a, b) => {
|
||||||
|
// 置顶项优先
|
||||||
|
if (a.pinnedAt && !b.pinnedAt) return -1
|
||||||
|
if (!a.pinnedAt && b.pinnedAt) return 1
|
||||||
|
// 都是置顶项,按置顶时间降序
|
||||||
|
if (a.pinnedAt && b.pinnedAt) return b.pinnedAt - a.pinnedAt
|
||||||
|
// 都不是置顶项,按添加时间降序(最新在前)
|
||||||
|
return b.addedAt - a.addedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 localStorage 加载收藏列表
|
* 从 localStorage 加载收藏列表
|
||||||
*/
|
*/
|
||||||
@@ -32,6 +49,9 @@ export function useFavorites() {
|
|||||||
...fav,
|
...fav,
|
||||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
sortFavorites()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载收藏列表失败:', error)
|
console.error('加载收藏列表失败:', error)
|
||||||
@@ -49,13 +69,23 @@ export function useFavorites() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化路径用于比较(Windows 大小写不敏感)
|
||||||
|
*/
|
||||||
|
const normalizePath = (path: string): string => {
|
||||||
|
return path.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加收藏
|
* 添加收藏
|
||||||
*/
|
*/
|
||||||
const addFavorite = (file: FileItem) => {
|
const addFavorite = (file: FileItem) => {
|
||||||
// 检查是否已存在
|
if (isFavorite(file.path)) {
|
||||||
const exists = favorites.value.some(fav => fav.path === file.path)
|
return false
|
||||||
if (exists) {
|
}
|
||||||
|
|
||||||
|
if (favorites.value.length >= DEFAULTS.MAX_FAVORITES_LENGTH) {
|
||||||
|
Message.warning(`收藏夹已满,最多收藏 ${DEFAULTS.MAX_FAVORITES_LENGTH} 项`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,17 +93,11 @@ export function useFavorites() {
|
|||||||
...file,
|
...file,
|
||||||
addedAt: Date.now()
|
addedAt: Date.now()
|
||||||
} as FavoriteFile)
|
} as FavoriteFile)
|
||||||
|
sortFavorites()
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 标准化路径用于比较(后端已统一为 /,直接转小写)
|
|
||||||
*/
|
|
||||||
const normalizePath = (path: string): string => {
|
|
||||||
return path.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除收藏
|
* 删除收藏
|
||||||
*/
|
*/
|
||||||
@@ -90,14 +114,12 @@ export function useFavorites() {
|
|||||||
* 切换收藏状态
|
* 切换收藏状态
|
||||||
*/
|
*/
|
||||||
const toggleFavorite = (file: FileItem) => {
|
const toggleFavorite = (file: FileItem) => {
|
||||||
const exists = isFavorite(file.path)
|
if (isFavorite(file.path)) {
|
||||||
if (exists) {
|
|
||||||
removeFavorite(file.path)
|
removeFavorite(file.path)
|
||||||
return false
|
return false
|
||||||
} else {
|
|
||||||
addFavorite(file)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
addFavorite(file)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,23 +131,54 @@ export function useFavorites() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 长按开始
|
* 切换置顶状态
|
||||||
*/
|
*/
|
||||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
const togglePin = (path: string) => {
|
||||||
const isMouse = event instanceof MouseEvent
|
const normalizedPath = normalizePath(path)
|
||||||
const isTouch = event instanceof TouchEvent
|
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||||
|
if (fav) {
|
||||||
|
fav.pinnedAt = fav.pinnedAt ? undefined : Date.now()
|
||||||
|
sortFavorites()
|
||||||
|
saveFavorites()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 只支持鼠标左键或触摸
|
/**
|
||||||
if (isMouse && event.button !== 0) return
|
* 检查是否已置顶
|
||||||
if (!isMouse && !isTouch) return
|
*/
|
||||||
|
const isPinned = (path: string): boolean => {
|
||||||
|
const normalizedPath = normalizePath(path)
|
||||||
|
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||||
|
return !!fav?.pinnedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新收藏项路径(重命名时使用,保留置顶状态和添加时间)
|
||||||
|
*/
|
||||||
|
const updateFavoritePath = (oldPath: string, newName: string) => {
|
||||||
|
const normalizedOld = normalizePath(oldPath)
|
||||||
|
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedOld)
|
||||||
|
if (!fav) 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.pressedIndex = index
|
||||||
draggingState.value.draggedIndex = index
|
draggingState.value.draggedIndex = index
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 长按取消
|
|
||||||
*/
|
|
||||||
const onLongPressCancel = () => {
|
const onLongPressCancel = () => {
|
||||||
if (!draggingState.value.isDragging) {
|
if (!draggingState.value.isDragging) {
|
||||||
draggingState.value.pressedIndex = -1
|
draggingState.value.pressedIndex = -1
|
||||||
@@ -133,23 +186,15 @@ export function useFavorites() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 拖拽开始
|
|
||||||
*/
|
|
||||||
const onDragStart = (event: DragEvent, index: number) => {
|
const onDragStart = (event: DragEvent, index: number) => {
|
||||||
draggingState.value.isDragging = true
|
draggingState.value.isDragging = true
|
||||||
draggingState.value.draggedIndex = index
|
draggingState.value.draggedIndex = index
|
||||||
|
|
||||||
// 设置拖拽数据
|
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
event.dataTransfer.setData('text/plain', index.toString())
|
event.dataTransfer.setData('text/plain', index.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 拖拽经过
|
|
||||||
*/
|
|
||||||
const onDragOver = (event: DragEvent) => {
|
const onDragOver = (event: DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
@@ -157,79 +202,53 @@ export function useFavorites() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 放置
|
|
||||||
*/
|
|
||||||
const onDrop = (event: DragEvent, targetIndex: number) => {
|
const onDrop = (event: DragEvent, targetIndex: number) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const fromIndex = draggingState.value.draggedIndex
|
const fromIndex = draggingState.value.draggedIndex
|
||||||
const toIndex = targetIndex
|
if (fromIndex === targetIndex || fromIndex === -1) {
|
||||||
|
|
||||||
if (fromIndex === toIndex || fromIndex === -1) {
|
|
||||||
resetDragging()
|
resetDragging()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动元素
|
|
||||||
const item = favorites.value.splice(fromIndex, 1)[0]
|
const item = favorites.value.splice(fromIndex, 1)[0]
|
||||||
favorites.value.splice(toIndex, 0, item)
|
favorites.value.splice(targetIndex, 0, item)
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
resetDragging()
|
resetDragging()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 拖拽结束
|
|
||||||
*/
|
|
||||||
const onDragEnd = () => {
|
const onDragEnd = () => {
|
||||||
resetDragging()
|
resetDragging()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置拖拽状态
|
|
||||||
*/
|
|
||||||
const resetDragging = () => {
|
const resetDragging = () => {
|
||||||
draggingState.value.isDragging = false
|
draggingState.value.isDragging = false
|
||||||
draggingState.value.draggedIndex = -1
|
draggingState.value.draggedIndex = -1
|
||||||
draggingState.value.pressedIndex = -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()
|
loadFavorites()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
|
||||||
favorites,
|
favorites,
|
||||||
draggingState,
|
draggingState,
|
||||||
|
|
||||||
// 方法
|
|
||||||
addFavorite,
|
addFavorite,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
|
togglePin,
|
||||||
|
isPinned,
|
||||||
|
updateFavoritePath,
|
||||||
|
|
||||||
// 拖拽方法
|
|
||||||
onLongPressStart,
|
onLongPressStart,
|
||||||
onLongPressCancel,
|
onLongPressCancel,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
reorder,
|
|
||||||
|
|
||||||
// 工具方法
|
|
||||||
loadFavorites,
|
loadFavorites,
|
||||||
saveFavorites,
|
saveFavorites,
|
||||||
resetDragging
|
resetDragging
|
||||||
|
|||||||
@@ -6,11 +6,18 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { STORAGE_KEYS, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
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'
|
import { useFileOperations } from './useFileOperations'
|
||||||
|
import type { FileItem } from '@/types/file-system'
|
||||||
|
|
||||||
export interface UseFileEditOptions {
|
export interface UseFileEditOptions {
|
||||||
currentFilePath?: any
|
currentFilePath?: import('vue').Ref<FileItem | null>
|
||||||
currentDirectory?: any
|
currentDirectory?: import('vue').Ref<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件大小限制(5MB)
|
// 文件大小限制(5MB)
|
||||||
@@ -23,6 +30,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
const fileContent = ref('')
|
const fileContent = ref('')
|
||||||
const originalContent = ref('')
|
const originalContent = ref('')
|
||||||
|
|
||||||
|
// 当前文件路径(用于验证更新是否来自当前文件)
|
||||||
|
const currentFilePathRef = ref('')
|
||||||
|
|
||||||
// 编辑状态
|
// 编辑状态
|
||||||
const isEditMode = ref(false)
|
const isEditMode = ref(false)
|
||||||
const fileContentHeight = ref(400)
|
const fileContentHeight = ref(400)
|
||||||
@@ -34,6 +44,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
// 保存状态
|
// 保存状态
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||||
|
const fileVersion = ref(0)
|
||||||
|
|
||||||
// 使用文件操作 composable
|
// 使用文件操作 composable
|
||||||
const { readFile, writeFile } = useFileOperations({
|
const { readFile, writeFile } = useFileOperations({
|
||||||
onSuccess: (operation, data) => {
|
onSuccess: (operation, data) => {
|
||||||
@@ -54,76 +67,42 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 已知二进制扩展名(无需读取内容即可判定)
|
||||||
* 获取文件扩展名
|
const KNOWN_BINARY_EXTS = new Set([
|
||||||
*/
|
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite', 'pdb', 'idb',
|
||||||
const getFileExtension = (filepath: any): string => {
|
'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
|
||||||
const path = getFilePath(filepath)
|
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||||
if (!path || typeof path !== 'string') return ''
|
'msi', 'jar', 'war', 'ear', 'apk'
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为二进制文件(基于扩展名)
|
* 判断是否为二进制文件(基于扩展名)
|
||||||
* 注意:媒体文件(图片、视频、音频、PDF)不是二进制文件,它们可以预览
|
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
||||||
* 对于无扩展名的文件,返回 null 表示未知,需要内容检测
|
|
||||||
*/
|
*/
|
||||||
const isBinaryFileByExt = (filepath: any): boolean | null => {
|
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
|
||||||
const ext = getFileExtension(filepath)
|
const path = getFilePath(filepath)
|
||||||
|
const ext = getExt(path)
|
||||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||||
|
|
||||||
|
// 已知二进制扩展名 → 直接判定
|
||||||
|
if (KNOWN_BINARY_EXTS.has(ext)) return true
|
||||||
|
|
||||||
// 媒体文件(可预览,不算二进制)
|
// 媒体文件(可预览,不算二进制)
|
||||||
const isMediaFile = FILE_EXTENSIONS.IMAGE.includes(ext) ||
|
const isMediaFile = isImageFile(path) ||
|
||||||
FILE_EXTENSIONS.VIDEO.includes(ext) ||
|
isVideoFile(path) ||
|
||||||
FILE_EXTENSIONS.AUDIO.includes(ext) ||
|
isAudioFile(path) ||
|
||||||
['pdf', 'html', 'htm', 'md', 'markdown'].includes(ext)
|
isPdfFile(path) ||
|
||||||
|
['html', 'htm', 'md', 'markdown'].includes(ext)
|
||||||
|
|
||||||
|
// Office 文件和 CSV(可预览)
|
||||||
|
const isOfficeFile = isExcelFile(path) || isWordFile(path) || isCsvFile(path)
|
||||||
|
|
||||||
// 文本或代码文件(可编辑)
|
// 文本或代码文件(可编辑)
|
||||||
const isTextFile = FILE_EXTENSIONS.TEXT.includes(ext) ||
|
const isTextFile = isTextEditable(path) || isConfigFile(path) ||
|
||||||
FILE_EXTENSIONS.CODE.includes(ext) ||
|
FILE_EXTENSIONS.CODE.includes(ext)
|
||||||
FILE_EXTENSIONS.CONFIG.includes(ext)
|
|
||||||
|
|
||||||
// 如果是媒体文件或文本文件,就不是二进制
|
// 如果是媒体文件、Office 文件或文本文件,就不是二进制
|
||||||
if (isMediaFile || isTextFile) return false
|
if (isMediaFile || isOfficeFile || isTextFile) return false
|
||||||
|
|
||||||
// 确认的二进制文件类型
|
|
||||||
const knownBinaryTypes = ['exe', 'dll', 'so', 'bin', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg', 'pdb', 'idb', 'lib', 'obj', 'o', 'a']
|
|
||||||
if (knownBinaryTypes.includes(ext)) return true
|
|
||||||
|
|
||||||
// 其他扩展名未知,需要内容检测
|
// 其他扩展名未知,需要内容检测
|
||||||
return null
|
return null
|
||||||
@@ -198,12 +177,26 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
|||||||
try {
|
try {
|
||||||
isBinaryFile.value = false
|
isBinaryFile.value = false
|
||||||
|
|
||||||
// 先清空内容,避免显示之前文件的内容
|
// 记录当前加载的文件路径,用于后续验证更新
|
||||||
fileContent.value = ''
|
currentFilePathRef.value = path
|
||||||
originalContent.value = ''
|
|
||||||
|
// 增加文件版本号,使之前的过期更新失效
|
||||||
|
fileVersion.value++
|
||||||
|
|
||||||
|
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
||||||
|
// 新内容加载完成后会直接替换旧内容
|
||||||
|
|
||||||
const filename = getFilePath(path)
|
const filename = getFilePath(path)
|
||||||
const ext = getFileExtension(filename)
|
const ext = getExt(filename)
|
||||||
|
|
||||||
|
// Office 文件直接读取内容进行预览,跳过二进制检测
|
||||||
|
if (isExcelFile(filename) || isWordFile(filename)) {
|
||||||
|
const content = await readFile(path)
|
||||||
|
fileContent.value = content
|
||||||
|
originalContent.value = content
|
||||||
|
isEditMode.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 先检查扩展名,如果是已知的二进制文件,直接生成提示信息
|
// 先检查扩展名,如果是已知的二进制文件,直接生成提示信息
|
||||||
const binaryCheck = isBinaryFileByExt(filename)
|
const binaryCheck = isBinaryFileByExt(filename)
|
||||||
@@ -384,6 +377,12 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
const saveDraft = () => {
|
const saveDraft = () => {
|
||||||
if (!currentFilePath.value) return
|
if (!currentFilePath.value) return
|
||||||
|
|
||||||
|
// Office 文件不支持草稿功能
|
||||||
|
const path = getFilePath(currentFilePath.value)
|
||||||
|
if (isExcelFile(path) || isWordFile(path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${currentFilePath.value}`
|
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${currentFilePath.value}`
|
||||||
const draft = {
|
const draft = {
|
||||||
content: fileContent.value,
|
content: fileContent.value,
|
||||||
@@ -402,6 +401,18 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
* 加载草稿
|
* 加载草稿
|
||||||
*/
|
*/
|
||||||
const loadDraft = (path: string) => {
|
const loadDraft = (path: string) => {
|
||||||
|
// Office 文件不支持草稿功能,并清除已有的草稿
|
||||||
|
if (isExcelFile(path) || isWordFile(path)) {
|
||||||
|
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
console.debug('[useFileEdit] 已清除 Office 文件草稿:', path)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除草稿失败:', error)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
||||||
draftKey.value = key
|
draftKey.value = key
|
||||||
|
|
||||||
@@ -440,6 +451,33 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅更新文件路径(重命名场景:内容不变,只切换路径关联)
|
||||||
|
* 迁移草稿 key,更新 currentFilePathRef
|
||||||
|
*/
|
||||||
|
const updateFilePath = (newPath: string) => {
|
||||||
|
const oldPath = currentFilePathRef.value
|
||||||
|
|
||||||
|
// 迁移草稿(旧 key → 新 key)
|
||||||
|
if (draftKey.value && oldPath !== newPath) {
|
||||||
|
try {
|
||||||
|
const draft = localStorage.getItem(draftKey.value)
|
||||||
|
if (draft) {
|
||||||
|
const newKey = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${newPath}`
|
||||||
|
localStorage.setItem(newKey, draft)
|
||||||
|
localStorage.removeItem(draftKey.value)
|
||||||
|
draftKey.value = newKey
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[useFileEdit] 草稿迁移失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只更新内部路径字符串引用,不触碰 currentFilePath(它是 FileItem 对象,由父组件管理)
|
||||||
|
// 这样不会触发 watch → clearDraft
|
||||||
|
currentFilePathRef.value = newPath
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置文件内容
|
* 重置文件内容
|
||||||
*/
|
*/
|
||||||
@@ -485,17 +523,21 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新文件内容
|
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容)
|
||||||
*/
|
*/
|
||||||
const updateContent = (content: string) => {
|
const updateContent = (content: string, expectedVersion?: number) => {
|
||||||
// 确保只有在内容真正改变时才更新
|
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
||||||
|
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
||||||
|
expected: expectedVersion,
|
||||||
|
current: fileVersion.value,
|
||||||
|
content: content.substring(0, 50)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (fileContent.value !== content) {
|
if (fileContent.value !== content) {
|
||||||
fileContent.value = content
|
fileContent.value = content
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动保存草稿(防抖)
|
|
||||||
// 实际实现应该使用防抖函数
|
|
||||||
// saveDraft()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -516,12 +558,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
return filePath.startsWith(currentDirectory.value)
|
return filePath.startsWith(currentDirectory.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听文件内容变化,自动保存草稿
|
|
||||||
watch(fileContent, () => {
|
|
||||||
// 实际实现应该使用防抖
|
|
||||||
// saveDraft()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// 监听文件路径变化,清除草稿
|
// 监听文件路径变化,清除草稿
|
||||||
watch(currentFilePath, (newPath, oldPath) => {
|
watch(currentFilePath, (newPath, oldPath) => {
|
||||||
if (newPath !== oldPath) {
|
if (newPath !== oldPath) {
|
||||||
@@ -538,6 +574,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
isSaving,
|
isSaving,
|
||||||
isBinaryFile,
|
isBinaryFile,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
fileVersion,
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
contentChanged,
|
contentChanged,
|
||||||
@@ -563,13 +600,10 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
|||||||
// 其他
|
// 其他
|
||||||
resetContent,
|
resetContent,
|
||||||
clearContent,
|
clearContent,
|
||||||
|
updateFilePath,
|
||||||
setEditorHeight,
|
setEditorHeight,
|
||||||
|
|
||||||
// 文件类型检查
|
// 文件类型检查
|
||||||
isImageFile,
|
|
||||||
isVideoFile,
|
|
||||||
isAudioFile,
|
|
||||||
isPdfFile,
|
|
||||||
isBinaryFileByExt,
|
isBinaryFileByExt,
|
||||||
isFileInCurrentDirectory
|
isFileInCurrentDirectory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
|
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { getPathSeparator } from '@/utils/fileUtils'
|
||||||
import {
|
import {
|
||||||
listDir,
|
listDir,
|
||||||
readFile as readFileApi,
|
readFile as readFileApi,
|
||||||
@@ -13,12 +13,12 @@ import {
|
|||||||
createFile,
|
createFile,
|
||||||
createDir,
|
createDir,
|
||||||
renamePath as renamePathApi,
|
renamePath as renamePathApi,
|
||||||
listZipContents,
|
listZipContents as listZipContentsApi,
|
||||||
extractFileFromZip,
|
extractFileFromZip,
|
||||||
extractFileFromZipToTemp,
|
extractFileFromZipToTemp as extractZipToTempApi,
|
||||||
getFileServerURL
|
getFileServerURL as getFileServerUrlApi
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import type { FileOperationResult } from '@/types/file-system'
|
import type { FileItem, FileOperationResult } from '@/types/file-system'
|
||||||
|
|
||||||
export interface UseFileOperationsOptions {
|
export interface UseFileOperationsOptions {
|
||||||
onSuccess?: (operation: string, data: any) => void
|
onSuccess?: (operation: string, data: any) => void
|
||||||
@@ -99,11 +99,10 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
|||||||
*/
|
*/
|
||||||
const createNewFile = async (
|
const createNewFile = async (
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
filename: string,
|
filename: string
|
||||||
content: string = ''
|
|
||||||
): Promise<FileItem> => {
|
): Promise<FileItem> => {
|
||||||
try {
|
try {
|
||||||
const result = await createFile(dirPath, filename, content)
|
const result = await createFile(dirPath, filename)
|
||||||
onSuccess?.('createFile', { dirPath, filename, result })
|
onSuccess?.('createFile', { dirPath, filename, result })
|
||||||
return result as FileItem
|
return result as FileItem
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -133,7 +132,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
|||||||
*/
|
*/
|
||||||
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
|
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
|
||||||
// 构造新路径
|
// 构造新路径
|
||||||
const separator = oldPath.includes('\\') ? '\\' : '/'
|
const separator = getPathSeparator(oldPath)
|
||||||
const parentPath = oldPath.substring(
|
const parentPath = oldPath.substring(
|
||||||
0,
|
0,
|
||||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||||
@@ -186,7 +185,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
|||||||
*/
|
*/
|
||||||
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
|
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
|
||||||
try {
|
try {
|
||||||
const result = await listZipContents(zipPath)
|
const result = await listZipContentsApi(zipPath)
|
||||||
onSuccess?.('listZipContents', { zipPath, count: result.length })
|
onSuccess?.('listZipContents', { zipPath, count: result.length })
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -216,7 +215,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
|||||||
*/
|
*/
|
||||||
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
|
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const tempPath = await extractFileFromZipToTemp(zipPath, filePath)
|
const tempPath = await extractZipToTempApi(zipPath, filePath)
|
||||||
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
|
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
|
||||||
return tempPath
|
return tempPath
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -231,7 +230,7 @@ export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
|||||||
*/
|
*/
|
||||||
const getFileServerURL = async (): Promise<string> => {
|
const getFileServerURL = async (): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const url = await getFileServerURL()
|
const url = await getFileServerUrlApi()
|
||||||
onSuccess?.('getFileServerURL', { url })
|
onSuccess?.('getFileServerURL', { url })
|
||||||
return url
|
return url
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
* 提供文件预览 URL 生成、媒体元数据获取等功能
|
* 提供文件预览 URL 生成、媒体元数据获取等功能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
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 { 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'
|
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
|
||||||
|
|
||||||
// 内容检测大小限制(与后端一致)
|
// 内容检测大小限制(与后端一致)
|
||||||
@@ -24,8 +29,17 @@ export interface UseFilePreviewOptions {
|
|||||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||||
|
|
||||||
// 文件服务器 URL(硬编码,与旧版本保持一致)
|
// 文件服务器 URL(优先从后端获取,降级到默认值)
|
||||||
const fileServerURL = 'http://localhost:18765'
|
let _fileServerURL = 'http://localhost:8073'
|
||||||
|
const initFileServerURL = async () => {
|
||||||
|
try {
|
||||||
|
const url = await window.go.main.App.GetFileServerURL()
|
||||||
|
if (url) _fileServerURL = url
|
||||||
|
} catch { /* 使用默认值 */ }
|
||||||
|
}
|
||||||
|
initFileServerURL()
|
||||||
|
|
||||||
|
const getFileServerURL = () => _fileServerURL
|
||||||
|
|
||||||
// 预览 URL
|
// 预览 URL
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
@@ -40,7 +54,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
const getPreviewUrl = (path: string): string => {
|
const getPreviewUrl = (path: string): string => {
|
||||||
if (!path) return ''
|
if (!path) return ''
|
||||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
||||||
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,159 +95,42 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
const getFileType = (filename: string): FileType => {
|
const getFileType = (filename: string): FileType => {
|
||||||
if (!filename || typeof filename !== 'string') return 'Binary' as 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
|
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 => {
|
const isPreviewable = (filename: string): boolean => {
|
||||||
if (!filename || typeof filename !== 'string') return false
|
if (!filename || typeof filename !== 'string') return false
|
||||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
return isPreviewableType(filename)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断文件是否可编辑
|
* 判断文件是否可编辑
|
||||||
*/
|
*/
|
||||||
const isEditable = (filename: string, fileSize: number): boolean => {
|
const isEditable = (filename: string, fileSize: number): boolean => {
|
||||||
// 检查文件大小
|
|
||||||
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
|
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查文件类型
|
|
||||||
if (!filename || typeof filename !== 'string') 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) ||
|
return FILE_EXTENSIONS.CODE.includes(ext) ||
|
||||||
FILE_EXTENSIONS.TEXT.includes(ext) ||
|
isTextEditable(filename) ||
|
||||||
FILE_EXTENSIONS.CONFIG.includes(ext) ||
|
isConfigFile(filename) ||
|
||||||
['html', 'htm', 'md', 'markdown'].includes(ext)
|
isHtmlFile(filename) ||
|
||||||
|
isMarkdownFile(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,14 +197,6 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
|
|
||||||
// 文件类型判断(同步,基于扩展名)
|
// 文件类型判断(同步,基于扩展名)
|
||||||
getFileType,
|
getFileType,
|
||||||
isImageFile,
|
|
||||||
isVideoFile,
|
|
||||||
isAudioFile,
|
|
||||||
isPdfFile,
|
|
||||||
isHtmlFile,
|
|
||||||
isMarkdownFile,
|
|
||||||
isCodeFile,
|
|
||||||
isTextFile,
|
|
||||||
isPreviewable,
|
isPreviewable,
|
||||||
isEditable,
|
isEditable,
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { STORAGE_KEYS } from '@/utils/constants'
|
import { STORAGE_KEYS } from '@/utils/constants'
|
||||||
import { normalizePathSeparators } from '@/utils/pathHelpers'
|
import { normalizePathSeparators } from '@/utils/fileUtils'
|
||||||
import type { PathHistory } from '@/types/file-system'
|
import type { PathHistory } from '@/types/file-system'
|
||||||
|
|
||||||
export interface UsePathNavigationOptions {
|
export interface UsePathNavigationOptions {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="file-system-container">
|
<div class="file-system-container">
|
||||||
<!-- 顶部工具栏 -->
|
<!-- 顶部工具栏 -->
|
||||||
<Toolbar
|
<Toolbar
|
||||||
|
ref="toolbarRef"
|
||||||
:config="toolbarConfig"
|
:config="toolbarConfig"
|
||||||
@update:file-path="handleFilePathUpdate"
|
@update:file-path="handleFilePathUpdate"
|
||||||
@update:show-sidebar="handleSidebarToggle"
|
@update:show-sidebar="handleSidebarToggle"
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
@go-to-path="handleGoToPath"
|
@go-to-path="handleGoToPath"
|
||||||
@open-file="handleOpenFile"
|
@open-file="handleOpenFile"
|
||||||
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
||||||
|
@update:search-keyword="handleSearchKeywordUpdate"
|
||||||
@show-message="handleShowMessage"
|
@show-message="handleShowMessage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
:config="sidebarConfig"
|
:config="sidebarConfig"
|
||||||
@open-favorite="handleOpenFavorite"
|
@open-favorite="handleOpenFavorite"
|
||||||
@remove-favorite="handleRemoveFavorite"
|
@remove-favorite="handleRemoveFavorite"
|
||||||
|
@toggle-pin="handleTogglePin"
|
||||||
@long-press-start="handleLongPressStart"
|
@long-press-start="handleLongPressStart"
|
||||||
@long-press-cancel="handleLongPressCancel"
|
@long-press-cancel="handleLongPressCancel"
|
||||||
@drag-start="handleDragStart"
|
@drag-start="handleDragStart"
|
||||||
@@ -30,12 +33,14 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 文件列表和编辑器区域 -->
|
<!-- 文件列表和编辑器区域 -->
|
||||||
<div class="file-workspace">
|
<div ref="workspaceRef" class="file-workspace">
|
||||||
<!-- 文件列表面板 -->
|
<!-- 文件列表面板 -->
|
||||||
<FileListPanel
|
<FileListPanel
|
||||||
:config="fileListPanelConfig"
|
:config="fileListPanelConfig"
|
||||||
:width="panelWidth.left"
|
:width="panelWidth.left"
|
||||||
:favorites="favoritePaths"
|
:favorites="favoritePaths"
|
||||||
|
:sort-by="sortBy"
|
||||||
|
:sort-order="sortOrder"
|
||||||
@file-click="handleFileClick"
|
@file-click="handleFileClick"
|
||||||
@file-double-click="handleFileDoubleClick"
|
@file-double-click="handleFileDoubleClick"
|
||||||
@toggle-favorite="handleToggleFavorite"
|
@toggle-favorite="handleToggleFavorite"
|
||||||
@@ -44,11 +49,12 @@
|
|||||||
@cancel-editing="handleCancelEditing"
|
@cancel-editing="handleCancelEditing"
|
||||||
@name-update="handleNameUpdate"
|
@name-update="handleNameUpdate"
|
||||||
@context-menu="handleContextMenu"
|
@context-menu="handleContextMenu"
|
||||||
|
@sort="setSort"
|
||||||
ref="fileListPanelRef"
|
ref="fileListPanelRef"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 分隔条 -->
|
<!-- 分隔条 -->
|
||||||
<div class="resizer" @mousedown="startResizeHorizontal"></div>
|
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
||||||
|
|
||||||
<!-- 文件编辑器面板 -->
|
<!-- 文件编辑器面板 -->
|
||||||
<FileEditorPanel
|
<FileEditorPanel
|
||||||
@@ -63,6 +69,7 @@
|
|||||||
@content-update="handleContentUpdate"
|
@content-update="handleContentUpdate"
|
||||||
@image-load="handleImageLoad"
|
@image-load="handleImageLoad"
|
||||||
@image-error="handleImageError"
|
@image-error="handleImageError"
|
||||||
|
@open-local-file="handleOpenLocalFile"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,10 +104,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Message, Modal } from '@arco-design/web-vue'
|
||||||
import { marked, renderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||||
import 'highlight.js/styles/github-dark.css'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
// 导入子组件
|
// 导入子组件
|
||||||
import Toolbar from './components/Toolbar.vue'
|
import Toolbar from './components/Toolbar.vue'
|
||||||
@@ -119,10 +127,10 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
|||||||
|
|
||||||
// 导入工具函数
|
// 导入工具函数
|
||||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
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 { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile } from '@/utils/fileTypeHelpers'
|
import { listDir, saveBase64File } from '@/api/system'
|
||||||
import { listDir } from '@/api/system'
|
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
|
import { createResizeHandler } from '@/utils/resize'
|
||||||
|
|
||||||
// 导入类型
|
// 导入类型
|
||||||
import type { FileItem, FavoriteFile, ContextMenuConfig, ShortcutPath } from '@/types/file-system'
|
import type { FileItem, FavoriteFile, ContextMenuConfig, ShortcutPath } from '@/types/file-system'
|
||||||
@@ -134,10 +142,10 @@ defineOptions({
|
|||||||
|
|
||||||
// ========== 工具函数(最先定义,避免初始化顺序问题) ==========
|
// ========== 工具函数(最先定义,避免初始化顺序问题) ==========
|
||||||
|
|
||||||
// 判断是否可以在编辑/预览模式之间切换(HTML/Markdown)
|
// 判断是否可以在编辑/预览模式之间切换(HTML/Markdown/CSV)
|
||||||
const isEditableWithPreview = (filename: string): boolean => {
|
const isEditableWithPreview = (filename: string): boolean => {
|
||||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||||
return ['html', 'htm', 'md', 'markdown'].includes(ext)
|
return ['html', 'htm', 'md', 'markdown', 'csv', 'tsv'].includes(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 状态管理 ==========
|
// ========== 状态管理 ==========
|
||||||
@@ -146,6 +154,31 @@ const isEditableWithPreview = (filename: string): boolean => {
|
|||||||
const fileList = ref<FileItem[]>([])
|
const fileList = ref<FileItem[]>([])
|
||||||
const fileLoading = ref(false)
|
const fileLoading = ref(false)
|
||||||
const selectedFileItem = ref<FileItem | null>(null)
|
const selectedFileItem = ref<FileItem | null>(null)
|
||||||
|
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||||
|
|
||||||
|
// 排序状态(带 localStorage 持久化)
|
||||||
|
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
||||||
|
type SortField = 'name' | 'size' | 'type' | 'modified_time'
|
||||||
|
const defaultSort: { sortBy: SortField; sortOrder: 'asc' | 'desc' } = { sortBy: 'name', sortOrder: 'asc' }
|
||||||
|
let savedSort: typeof defaultSort | null = null
|
||||||
|
try { savedSort = JSON.parse(localStorage.getItem(SORT_STORAGE_KEY) || '') } catch { /* localStorage 不可用则使用默认排序 */ }
|
||||||
|
const sortBy = ref<SortField>(savedSort?.sortBy || defaultSort.sortBy)
|
||||||
|
const sortOrder = ref(savedSort?.sortOrder || defaultSort.sortOrder)
|
||||||
|
|
||||||
|
const doSort = () => {
|
||||||
|
fileList.value = sortFileList(fileList.value, { sortBy: sortBy.value, sortOrder: sortOrder.value })
|
||||||
|
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify({ sortBy: sortBy.value, sortOrder: sortOrder.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSort = (field: SortField) => {
|
||||||
|
if (sortBy.value === field) {
|
||||||
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = field
|
||||||
|
sortOrder.value = 'asc'
|
||||||
|
}
|
||||||
|
doSort()
|
||||||
|
}
|
||||||
|
|
||||||
// 导航锁:防止同时执行多个导航操作
|
// 导航锁:防止同时执行多个导航操作
|
||||||
const isNavigating = ref(false)
|
const isNavigating = ref(false)
|
||||||
@@ -157,6 +190,18 @@ const editingFileName = ref('')
|
|||||||
// 侧边栏
|
// 侧边栏
|
||||||
const showSidebar = ref(true)
|
const showSidebar = ref(true)
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
// 过滤后的文件列表(基于搜索关键词)
|
||||||
|
const filteredFileList = computed(() => {
|
||||||
|
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||||
|
if (!keyword) return fileList.value
|
||||||
|
return fileList.value.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 面板宽度(带 localStorage 持久化)
|
// 面板宽度(带 localStorage 持久化)
|
||||||
const restorePanelWidth = (): { left: number; right: number } => {
|
const restorePanelWidth = (): { left: number; right: number } => {
|
||||||
try {
|
try {
|
||||||
@@ -185,6 +230,7 @@ const savePanelWidth = (width: { left: number; right: number }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const panelWidth = ref(restorePanelWidth())
|
const panelWidth = ref(restorePanelWidth())
|
||||||
|
const workspaceRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 系统路径(使用 composable)
|
// 系统路径(使用 composable)
|
||||||
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
|
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
|
||||||
@@ -223,7 +269,7 @@ const fileOps = useFileOperations({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 收藏夹
|
// 收藏夹
|
||||||
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite } = 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 } =
|
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath } =
|
||||||
@@ -240,7 +286,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 文件编辑
|
// 文件编辑
|
||||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef } =
|
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||||
useFileEdit({
|
useFileEdit({
|
||||||
currentFilePath: selectedFileItem,
|
currentFilePath: selectedFileItem,
|
||||||
currentDirectory: filePath
|
currentDirectory: filePath
|
||||||
@@ -262,7 +308,10 @@ const toolbarConfig = computed(() => ({
|
|||||||
zipFileName: '',
|
zipFileName: '',
|
||||||
zipBreadcrumbs: [],
|
zipBreadcrumbs: [],
|
||||||
fileLoading: fileLoading.value,
|
fileLoading: fileLoading.value,
|
||||||
showSidebar: showSidebar.value
|
showSidebar: showSidebar.value,
|
||||||
|
sortBy: sortBy.value,
|
||||||
|
sortOrder: sortOrder.value,
|
||||||
|
searchKeyword: searchKeyword.value
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 侧边栏配置
|
// 侧边栏配置
|
||||||
@@ -274,7 +323,7 @@ const sidebarConfig = computed(() => ({
|
|||||||
|
|
||||||
// 文件列表面板配置
|
// 文件列表面板配置
|
||||||
const fileListPanelConfig = computed(() => ({
|
const fileListPanelConfig = computed(() => ({
|
||||||
fileList: fileList.value,
|
fileList: filteredFileList.value,
|
||||||
fileLoading: fileLoading.value,
|
fileLoading: fileLoading.value,
|
||||||
selectedFileItem: selectedFileItem.value,
|
selectedFileItem: selectedFileItem.value,
|
||||||
editingFilePath: editingFilePath.value,
|
editingFilePath: editingFilePath.value,
|
||||||
@@ -319,13 +368,19 @@ const fileEditorPanelConfig = computed(() => {
|
|||||||
isPdfFile: isPdfFile(currentFileName),
|
isPdfFile: isPdfFile(currentFileName),
|
||||||
isHtmlFile: isHtmlFile(currentFileName),
|
isHtmlFile: isHtmlFile(currentFileName),
|
||||||
isMarkdownFile: isMarkdownFile(currentFileName),
|
isMarkdownFile: isMarkdownFile(currentFileName),
|
||||||
|
isExcelFile: isExcelFile(currentFileName),
|
||||||
|
isWordFile: isWordFile(currentFileName),
|
||||||
|
isCsvFile: isCsvFile(currentFileName),
|
||||||
|
officeLoading: false,
|
||||||
|
officeError: null,
|
||||||
canSaveFile: canSaveFile.value,
|
canSaveFile: canSaveFile.value,
|
||||||
canResetContent: canResetContent.value,
|
canResetContent: canResetContent.value,
|
||||||
canPreviewFile: isEditableWithPreview(currentFileName),
|
canPreviewFile: isEditableWithPreview(currentFileName),
|
||||||
imageLoading: imageLoading.value,
|
imageLoading: imageLoading.value,
|
||||||
currentImageDimensions: currentImageDimensions.value,
|
currentImageDimensions: currentImageDimensions.value,
|
||||||
currentFileExtension,
|
currentFileExtension,
|
||||||
isBinaryFile: isBinaryFileRef.value
|
isBinaryFile: isBinaryFileRef.value,
|
||||||
|
fileMtime: selectedFileItem.value?.modified_time || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -344,6 +399,10 @@ const handleRefresh = async () => {
|
|||||||
await loadDirectory(filePath.value)
|
await loadDirectory(filePath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearchKeywordUpdate = (keyword: string) => {
|
||||||
|
searchKeyword.value = keyword
|
||||||
|
}
|
||||||
|
|
||||||
const handleGoToPath = async (path: string) => {
|
const handleGoToPath = async (path: string) => {
|
||||||
await navigate(path)
|
await navigate(path)
|
||||||
}
|
}
|
||||||
@@ -362,9 +421,10 @@ const handleOpenFile = async (path: string) => {
|
|||||||
// 是目录,导航进入
|
// 是目录,导航进入
|
||||||
await navigate(path)
|
await navigate(path)
|
||||||
} else {
|
} else {
|
||||||
// 是文件,选中并加载
|
// 是文件,先加载内容,再更新选中状态(避免闪烁)
|
||||||
selectedFileItem.value = targetFile
|
|
||||||
await loadFileContent(path)
|
await loadFileContent(path)
|
||||||
|
// 内容加载完成后再更新选中状态,确保 fileContent 和 selectedFileItem 同步
|
||||||
|
selectedFileItem.value = targetFile
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 未找到,尝试直接导航(可能是目录)
|
// 未找到,尝试直接导航(可能是目录)
|
||||||
@@ -377,6 +437,55 @@ const handleOpenFile = async (path: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 HTML/Markdown 预览中的本地文件链接点击
|
||||||
|
const handleOpenLocalFile = async (link: string) => {
|
||||||
|
if (!link) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
let targetPath = link
|
||||||
|
|
||||||
|
// 剥离 /localfs/ 前缀(HTML 预览模式下 JS 生成的路径带此前缀)
|
||||||
|
if (link.startsWith('/localfs/')) {
|
||||||
|
targetPath = link.replace(/^\/localfs\//, '').replace(/\//g, '\\')
|
||||||
|
}
|
||||||
|
// 如果是相对路径,基于当前预览文件所在目录解析
|
||||||
|
else if (!link.match(/^[A-Za-z]:/) && !link.startsWith('/')) {
|
||||||
|
const currentFilePath = fileEditorPanelConfig.value.currentFileFullPath
|
||||||
|
if (currentFilePath) {
|
||||||
|
const currentDir = getParentPath(currentFilePath)
|
||||||
|
targetPath = resolveRelativePath(currentDir, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用打开文件处理
|
||||||
|
await handleOpenFile(targetPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打开本地文件链接失败:', error)
|
||||||
|
Message.error(`无法打开文件: ${link}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析相对路径为绝对路径
|
||||||
|
const resolveRelativePath = (basePath: string, relativePath: string): string => {
|
||||||
|
// 统一使用 / 作为分隔符
|
||||||
|
const base = basePath.replace(/\\/g, '/')
|
||||||
|
const relative = relativePath.replace(/\\/g, '/')
|
||||||
|
|
||||||
|
// 处理 ./ 和 ../
|
||||||
|
const parts = base.split('/')
|
||||||
|
const segments = relative.split('/')
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment === '..') {
|
||||||
|
parts.pop()
|
||||||
|
} else if (segment !== '.' && segment !== '') {
|
||||||
|
parts.push(segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
const handleNavigateToZipDirectory = async (path: string) => {
|
const handleNavigateToZipDirectory = async (path: string) => {
|
||||||
// 暂时不处理 ZIP
|
// 暂时不处理 ZIP
|
||||||
}
|
}
|
||||||
@@ -402,51 +511,36 @@ const handleRemoveFavorite = (path: string) => {
|
|||||||
removeFav(path)
|
removeFav(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = (path: string) => {
|
||||||
|
togglePin(path)
|
||||||
|
}
|
||||||
|
|
||||||
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||||
// 拖拽开始
|
onLongPressStart(event, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLongPressCancel = () => {
|
const handleLongPressCancel = () => {
|
||||||
// 拖拽取消
|
onLongPressCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (event: DragEvent, index: number) => {
|
const handleDragStart = (event: DragEvent, index: number) => {
|
||||||
// 拖拽开始
|
onDragStart(event, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
const handleDragOver = (event: DragEvent) => {
|
||||||
// 拖拽经过
|
onDragOver(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (event: DragEvent, targetIndex: number) => {
|
const handleDrop = (event: DragEvent, targetIndex: number) => {
|
||||||
// 放置
|
onDrop(event, targetIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
// 拖拽结束
|
onDragEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件列表事件
|
// 文件列表事件
|
||||||
const handleFileClick = async (file: FileItem) => {
|
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) {
|
if (file.isDir) {
|
||||||
// 目录:使用 navigate 函数,确保历史记录正确更新
|
// 目录:使用 navigate 函数,确保历史记录正确更新
|
||||||
@@ -462,25 +556,6 @@ const handleFileDoubleClick = async (file: FileItem) => {
|
|||||||
if (file.isDir) {
|
if (file.isDir) {
|
||||||
await navigate(file.path)
|
await navigate(file.path)
|
||||||
} else {
|
} 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)
|
selectFile(file.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -513,7 +588,8 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
|||||||
const trimmedName = newName.trim()
|
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) {
|
if (trimmedName === oldName) {
|
||||||
editingFilePath.value = ''
|
editingFilePath.value = ''
|
||||||
editingFileName.value = ''
|
editingFileName.value = ''
|
||||||
@@ -528,7 +604,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 dirPath = oldPath.substring(0, oldPath.lastIndexOf(separator))
|
||||||
const newPath = dirPath + separator + trimmedName
|
const newPath = dirPath + separator + trimmedName
|
||||||
|
|
||||||
@@ -564,24 +640,12 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果重命名的是当前打开的文件,先关闭编辑器和预览
|
// 标记是否需要重命名后仅更新路径(内容不变,零闪烁)
|
||||||
if (selectedFileItem.value?.path === oldPath) {
|
let needUpdatePath = false
|
||||||
// 如果是文件(不是文件夹),才需要关闭编辑器
|
|
||||||
if (!selectedFileItem.value.isDir) {
|
|
||||||
// 清空编辑器内容
|
|
||||||
await clearContent()
|
|
||||||
|
|
||||||
// 清空预览URL
|
// 如果重命名的是当前打开的文件
|
||||||
if (previewUrl.value) {
|
if (selectedFileItem.value?.path === oldPath && !selectedFileItem.value.isDir) {
|
||||||
previewUrl.value = ''
|
needUpdatePath = true
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消选中状态
|
|
||||||
selectedFileItem.value = null
|
|
||||||
|
|
||||||
// 等待文件句柄释放(文件需要更长时间)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renamedFile = await fileOps.rename(oldPath, trimmedName)
|
const renamedFile = await fileOps.rename(oldPath, trimmedName)
|
||||||
@@ -589,13 +653,19 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
|||||||
// 更新文件列表(保留收藏状态)
|
// 更新文件列表(保留收藏状态)
|
||||||
updateFileInList(oldPath, renamedFile)
|
updateFileInList(oldPath, renamedFile)
|
||||||
|
|
||||||
// 如果重命名的是收藏的文件,更新收藏夹中的路径
|
// 如果重命名的是收藏的文件,更新收藏夹中的路径(保留置顶状态)
|
||||||
if (isFavorite(oldPath)) {
|
if (isFavorite(oldPath)) {
|
||||||
removeFav(oldPath)
|
updateFavoritePath(oldPath, trimmedName)
|
||||||
toggleFav(renamedFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
||||||
|
|
||||||
|
// 仅更新路径关联,不重新加载内容(编辑器内容不变,零闪烁)
|
||||||
|
if (needUpdatePath && !renamedFile.isDir) {
|
||||||
|
selectedFileItem.value = renamedFile
|
||||||
|
updateFilePath(newPath)
|
||||||
|
updatePreviewUrl(newPath)
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 提取错误信息
|
// 提取错误信息
|
||||||
let errorMsg = error?.message || error?.toString() || '未知错误'
|
let errorMsg = error?.message || error?.toString() || '未知错误'
|
||||||
@@ -609,7 +679,6 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
|||||||
|
|
||||||
// 针对常见错误提供友好提示
|
// 针对常见错误提供友好提示
|
||||||
if (errorMsg.includes('being used by another process') ||
|
if (errorMsg.includes('being used by another process') ||
|
||||||
errorMsg.includes('being used by another process') ||
|
|
||||||
errorMsg.includes('被另一个进程占用')) {
|
errorMsg.includes('被另一个进程占用')) {
|
||||||
errorMsg = '文件正在被其他程序使用,请先关闭该文件后重试'
|
errorMsg = '文件正在被其他程序使用,请先关闭该文件后重试'
|
||||||
if (selectedFileItem.value?.isDir) {
|
if (selectedFileItem.value?.isDir) {
|
||||||
@@ -739,11 +808,6 @@ const handleCreateFile = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (false) { // ZIP 浏览模式已禁用
|
|
||||||
Message.warning('ZIP 浏览模式下不支持创建文件')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showInputDialog(
|
showInputDialog(
|
||||||
UI_TEXT.CREATE_FILE,
|
UI_TEXT.CREATE_FILE,
|
||||||
UI_TEXT.ENTER_FILE_NAME,
|
UI_TEXT.ENTER_FILE_NAME,
|
||||||
@@ -771,11 +835,8 @@ const handleCreateFile = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建完整路径
|
|
||||||
const fullPath = `${filePath.value}\\${fileName}`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newFile = await fileOps.createNewFile(fullPath)
|
const newFile = await fileOps.createNewFile(filePath.value, fileName)
|
||||||
Message.success(`✓ 文件 "${fileName}" 创建成功`)
|
Message.success(`✓ 文件 "${fileName}" 创建成功`)
|
||||||
addFileToList(newFile)
|
addFileToList(newFile)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -795,11 +856,6 @@ const handleCreateDir = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (false) { // ZIP 浏览模式已禁用
|
|
||||||
Message.warning('ZIP 浏览模式下不支持创建文件夹')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showInputDialog(
|
showInputDialog(
|
||||||
UI_TEXT.CREATE_FOLDER,
|
UI_TEXT.CREATE_FOLDER,
|
||||||
UI_TEXT.ENTER_FOLDER_NAME,
|
UI_TEXT.ENTER_FOLDER_NAME,
|
||||||
@@ -827,11 +883,8 @@ const handleCreateDir = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建完整路径
|
|
||||||
const fullPath = `${filePath.value}\\${folderName}`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newDir = await fileOps.createNewDir(fullPath)
|
const newDir = await fileOps.createNewDir(filePath.value, folderName)
|
||||||
Message.success(`✓ 文件夹 "${folderName}" 创建成功`)
|
Message.success(`✓ 文件夹 "${folderName}" 创建成功`)
|
||||||
addFileToList(newDir)
|
addFileToList(newDir)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -906,28 +959,20 @@ const handleReset = () => {
|
|||||||
resetContent()
|
resetContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStartResize = (event: MouseEvent) => {
|
const handleStartResize = createResizeHandler(
|
||||||
event.preventDefault()
|
() => workspaceRef.value,
|
||||||
|
() => fileContentHeight.value,
|
||||||
const startY = event.clientY
|
{
|
||||||
const startHeight = fileContentHeight.value
|
direction: 'vertical',
|
||||||
|
outputMode: 'pixels',
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
minPixels: 200,
|
||||||
const deltaY = e.clientY - startY
|
onResize: (px) => { fileContentHeight.value = px },
|
||||||
fileContentHeight.value = Math.max(200, startHeight + deltaY)
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const onMouseUp = () => {
|
|
||||||
window.removeEventListener('mousemove', onMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', onMouseMove)
|
|
||||||
window.addEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContentUpdate = (content: string) => {
|
const handleContentUpdate = (content: string) => {
|
||||||
updateContent(content)
|
// useFileEdit 内部会检查版本号和时间,防止过期更新
|
||||||
|
updateContent(content, fileVersion.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImageLoad = (dimensions: string) => {
|
const handleImageLoad = (dimensions: string) => {
|
||||||
@@ -972,7 +1017,7 @@ const selectFile = async (path: string) => {
|
|||||||
name: fileName,
|
name: fileName,
|
||||||
isDir: false,
|
isDir: false,
|
||||||
size: 0,
|
size: 0,
|
||||||
mod_time: '',
|
modified_time: '',
|
||||||
is_favorite: isFavorite(path)
|
is_favorite: isFavorite(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -996,6 +1041,28 @@ const loadFileContent = async (path: string) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 大文件预检:基于目录列表中的 size 字段,避免读取大文件导致卡死
|
||||||
|
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
|
||||||
|
const sizeMB = (fileSize / 1024 / 1024).toFixed(2)
|
||||||
|
clearContent()
|
||||||
|
fileContent.value = `================================================================
|
||||||
|
⚠️ 文件过大 (${sizeMB} MB)
|
||||||
|
================================================================
|
||||||
|
|
||||||
|
文件名: ${fileName}
|
||||||
|
完整路径: ${path}
|
||||||
|
文件大小: ${sizeMB} MB
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
当前文件超过 ${(FILE_SIZE_THRESHOLDS.BIG_FILE / 1024)}KB,不适合在编辑器中打开。
|
||||||
|
|
||||||
|
💡 提示:
|
||||||
|
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
|
||||||
|
• 右键菜单 → "在资源管理器中显示" 查看文件位置
|
||||||
|
================================================================`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 对于小文件(≤500KB)且扩展名不可识别的情况,进行内容检测
|
// 对于小文件(≤500KB)且扩展名不可识别的情况,进行内容检测
|
||||||
if (fileSize > 0 && fileSize <= 500 * 1024) {
|
if (fileSize > 0 && fileSize <= 500 * 1024) {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
||||||
@@ -1025,7 +1092,7 @@ const loadDirectory = async (path: string) => {
|
|||||||
fileLoading.value = true
|
fileLoading.value = true
|
||||||
try {
|
try {
|
||||||
fileList.value = await fileOps.listDirectory(path)
|
fileList.value = await fileOps.listDirectory(path)
|
||||||
fileList.value = sortFileList(fileList.value)
|
doSort()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Message.error(`加载目录失败: ${error}`)
|
Message.error(`加载目录失败: ${error}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1037,7 +1104,8 @@ const loadDirectory = async (path: string) => {
|
|||||||
* 添加文件到列表(保持排序)
|
* 添加文件到列表(保持排序)
|
||||||
*/
|
*/
|
||||||
const addFileToList = (item: FileItem) => {
|
const addFileToList = (item: FileItem) => {
|
||||||
fileList.value = sortFileList([...fileList.value, { ...item, is_favorite: false }])
|
fileList.value = [...fileList.value, { ...item, is_favorite: false }]
|
||||||
|
doSort()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1089,11 +1157,11 @@ const loadZipDirectoryContents = async (zipPath: string, currentDir: string): Pr
|
|||||||
path: f.path,
|
path: f.path,
|
||||||
isDir: f.isDir,
|
isDir: f.isDir,
|
||||||
size: f.size || 0,
|
size: f.size || 0,
|
||||||
mod_time: f.mod_time || '',
|
modified_time: f.modified_time || '',
|
||||||
is_favorite: false
|
is_favorite: false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return sortFileList(result)
|
return sortFileList(result, { sortBy: sortBy.value, sortOrder: sortOrder.value })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载 ZIP 目录失败:', error)
|
console.error('加载 ZIP 目录失败:', error)
|
||||||
Message.error(`加载 ZIP 目录失败: ${error}`)
|
Message.error(`加载 ZIP 目录失败: ${error}`)
|
||||||
@@ -1133,37 +1201,19 @@ const extractZipTextAndRead = async (zipPath: string, filePath: string): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startResizeHorizontal = (event: MouseEvent) => {
|
const handleHorizontalResize = createResizeHandler(
|
||||||
event.preventDefault()
|
() => workspaceRef.value,
|
||||||
|
() => panelWidth.value.left,
|
||||||
const startX = event.clientX
|
{
|
||||||
const container = event.currentTarget as HTMLElement
|
direction: 'horizontal',
|
||||||
const containerRect = container.parentElement?.getBoundingClientRect()
|
minPercent: DEFAULTS.MIN_PANEL_WIDTH,
|
||||||
if (!containerRect) return
|
maxPercent: 100 - DEFAULTS.MIN_PANEL_WIDTH,
|
||||||
|
onResize: (percent) => {
|
||||||
const startLeftWidth = (panelWidth.value.left / 100) * containerRect.width
|
panelWidth.value = { left: percent, right: 100 - percent }
|
||||||
|
},
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
onResizeEnd: () => savePanelWidth(panelWidth.value),
|
||||||
const deltaX = e.clientX - startX
|
|
||||||
const newLeftWidth = Math.max(200, Math.min(containerRect.width - 200, startLeftWidth + deltaX))
|
|
||||||
const newLeftPercent = (newLeftWidth / containerRect.width) * 100
|
|
||||||
|
|
||||||
panelWidth.value = {
|
|
||||||
left: newLeftPercent,
|
|
||||||
right: 100 - newLeftPercent
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const onMouseUp = () => {
|
|
||||||
window.removeEventListener('mousemove', onMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onMouseUp)
|
|
||||||
// 保存调整后的宽度
|
|
||||||
savePanelWidth(panelWidth.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', onMouseMove)
|
|
||||||
window.addEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 生命周期 ==========
|
// ========== 生命周期 ==========
|
||||||
|
|
||||||
@@ -1184,11 +1234,15 @@ onMounted(() => {
|
|||||||
// 添加键盘快捷键
|
// 添加键盘快捷键
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
window.addEventListener('click', hideContextMenu)
|
window.addEventListener('click', hideContextMenu)
|
||||||
|
|
||||||
|
// 添加粘贴事件监听(剪贴板图片)
|
||||||
|
window.addEventListener('paste', handlePaste)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
window.removeEventListener('click', hideContextMenu)
|
window.removeEventListener('click', hideContextMenu)
|
||||||
|
window.removeEventListener('paste', handlePaste)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 键盘快捷键
|
// 键盘快捷键
|
||||||
@@ -1199,15 +1253,26 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// F5 刷新文件列表
|
// F5 刷新文件列表 + 重载当前预览文件
|
||||||
if (event.key === 'F5') {
|
if (event.key === 'F5') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (filePath.value) {
|
if (filePath.value) {
|
||||||
loadDirectory(filePath.value)
|
await loadDirectory(filePath.value)
|
||||||
|
// 如果有正在预览的文件,同时重新加载其内容(类似重新点击一次)
|
||||||
|
if (selectedFileItem.value && !selectedFileItem.value.isDir) {
|
||||||
|
await loadFileContent(selectedFileItem.value.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+H 打开历史记录面板
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'h') {
|
||||||
|
event.preventDefault()
|
||||||
|
toolbarRef.value?.toggleHistoryDropdown?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+Shift+C/D/E/F/G/H 快速打开对应盘符
|
// Ctrl+Shift+C/D/E/F/G/H 快速打开对应盘符
|
||||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey) {
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey) {
|
||||||
const driveLetter = event.key.toUpperCase()
|
const driveLetter = event.key.toUpperCase()
|
||||||
@@ -1266,6 +1331,13 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+N 新建文件夹(必须在 Ctrl+N 之前判断)
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'n') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleCreateDir()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+N 新建文件
|
// Ctrl+N 新建文件
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'n') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'n') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1273,13 +1345,6 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Shift+N 新建文件夹
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'n' && event.shiftKey) {
|
|
||||||
event.preventDefault()
|
|
||||||
handleCreateDir()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alt+← 后退到上一个目录
|
// Alt+← 后退到上一个目录
|
||||||
if (event.altKey && event.key === 'ArrowLeft') {
|
if (event.altKey && event.key === 'ArrowLeft') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1319,6 +1384,74 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 粘贴剪贴板图片到当前目录
|
||||||
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
|
// 忽略输入框内的粘贴
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||||
|
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
|
||||||
|
const imageItem = Array.from(items).find(item => item.type.startsWith('image/'))
|
||||||
|
if (imageItem) {
|
||||||
|
event.preventDefault()
|
||||||
|
await pasteImageToFile(imageItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将剪贴板图片保存为文件
|
||||||
|
const pasteImageToFile = async (item: DataTransferItem) => {
|
||||||
|
if (!filePath.value) {
|
||||||
|
Message.warning('请先选择目标目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) {
|
||||||
|
Message.error('无法获取剪贴板图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名:clipboard_YYYYMMDD_HHmmss.ext
|
||||||
|
const now = new Date()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||||
|
const ext = file.type.split('/')[1] || 'png'
|
||||||
|
const fileName = `clipboard_${ts}.${ext}`
|
||||||
|
const separator = getPathSeparator(filePath.value)
|
||||||
|
const fullPath = filePath.value + separator + fileName
|
||||||
|
|
||||||
|
// 转换为 base64 并保存
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async () => {
|
||||||
|
const result = reader.result as string
|
||||||
|
// 移除 data:image/xxx;base64, 前缀,只保留纯 base64 内容
|
||||||
|
const parts = result.split(',')
|
||||||
|
const base64Data = parts.length > 1 ? parts[1] : ''
|
||||||
|
if (!base64Data) {
|
||||||
|
Message.error('图片数据格式无效')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
|
||||||
|
try {
|
||||||
|
await saveBase64File(fullPath, base64Data)
|
||||||
|
Message.clear()
|
||||||
|
Message.success(`已保存: ${fileName}`)
|
||||||
|
loadDirectory(filePath.value)
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.clear()
|
||||||
|
Message.error('保存失败: ' + (err?.message || err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error('粘贴失败: ' + (err?.message || err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 渲染 Mermaid 图表
|
// 渲染 Mermaid 图表
|
||||||
watch(async () => {
|
watch(async () => {
|
||||||
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
|
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
|
||||||
@@ -1329,6 +1462,18 @@ watch(async () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 主题变化时重新渲染 mermaid 图表(跟随暗色/亮色)
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
watch(() => themeStore.isDark, async () => {
|
||||||
|
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
|
||||||
|
try {
|
||||||
|
// 等 DOM 更新完成后再重新渲染,确保 isDarkTheme() 能读到正确的主题属性
|
||||||
|
await nextTick()
|
||||||
|
await rerenderMermaidDiagrams()
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
534
web/src/components/MarkdownEditor.vue
Normal file
534
web/src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
<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 ref="editorContentRef" class="editor-content" :class="{ 'fullscreen': isFullscreen }">
|
||||||
|
<div class="editor-panel" :class="{ 'expanded': isEditorExpanded }" :style="{ width: editorWidthPercent + '%' }">
|
||||||
|
<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="handleResize"></div>
|
||||||
|
|
||||||
|
<div class="preview-panel" :class="{ 'expanded': isPreviewExpanded }" :style="{ width: (100 - editorWidthPercent) + '%' }">
|
||||||
|
<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 ref="previewRef" 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'
|
||||||
|
import { createResizeHandler } from '@/utils/resize'
|
||||||
|
|
||||||
|
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 editorWidthPercent = ref(50)
|
||||||
|
const editorContentRef = ref(null)
|
||||||
|
const previewRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
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.metaKey) && event.key === 's') {
|
||||||
|
event.preventDefault()
|
||||||
|
saveContent()
|
||||||
|
}
|
||||||
|
// Ctrl + / 切换预览
|
||||||
|
if ((event.ctrlKey || event.metaKey) && 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 handleResize = createResizeHandler(
|
||||||
|
() => editorContentRef.value,
|
||||||
|
() => editorWidthPercent.value,
|
||||||
|
{
|
||||||
|
direction: 'horizontal',
|
||||||
|
minPercent: 15,
|
||||||
|
maxPercent: 85,
|
||||||
|
minPixels: 100,
|
||||||
|
onResize: (percent) => { editorWidthPercent.value = percent },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 切换功能
|
||||||
|
const togglePreview = () => {
|
||||||
|
// 预览面板始终显示,保留快捷键兼容性
|
||||||
|
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 = () => {
|
||||||
|
// 强制重新渲染预览
|
||||||
|
if (previewRef.value) {
|
||||||
|
previewRef.value.style.opacity = '0'
|
||||||
|
nextTick(() => {
|
||||||
|
if (previewRef.value) previewRef.value.style.opacity = '1'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动保存定时器
|
||||||
|
let autoSaveTimer = null
|
||||||
|
|
||||||
|
// 监听内容变化:自动保存 + 调整高度
|
||||||
|
watch(markdownContent, () => {
|
||||||
|
// 自动保存
|
||||||
|
if (hasChanges.value) {
|
||||||
|
clearTimeout(autoSaveTimer)
|
||||||
|
autoSaveTimer = setTimeout(() => {
|
||||||
|
saveContent()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
// 调整高度
|
||||||
|
// computeRendered 是 computed ref,值变化即触发,无需 deep
|
||||||
|
nextTick(() => {
|
||||||
|
adjustTextareaHeight()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
adjustTextareaHeight()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearTimeout(autoSaveTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdownContent,
|
||||||
|
textarea,
|
||||||
|
hasChanges,
|
||||||
|
wordCount,
|
||||||
|
lineCount,
|
||||||
|
readingTime,
|
||||||
|
isFullscreen,
|
||||||
|
isEditorExpanded,
|
||||||
|
isPreviewExpanded,
|
||||||
|
handleInput,
|
||||||
|
handleKeydown,
|
||||||
|
saveContent,
|
||||||
|
onExportComplete,
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
<!-- 版本更新 -->
|
<!-- 版本更新 -->
|
||||||
<a-tab-pane key="update" title="版本更新">
|
<a-tab-pane key="update" title="版本更新">
|
||||||
<UpdatePanel />
|
<UpdatePanel @open-version-history="handleOpenVersionHistory" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
@@ -122,7 +122,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits(['update:modelValue', 'save'])
|
const emit = defineEmits(['update:modelValue', 'save', 'open-version-history'])
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
@@ -291,6 +291,11 @@ const handleReset = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开版本历史
|
||||||
|
const handleOpenVersionHistory = () => {
|
||||||
|
emit('open-version-history')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
||||||
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
||||||
import { useUpdateStore } from '../stores/update'
|
import { useUpdateStore } from '../stores/update'
|
||||||
|
import { marked } from '../utils/markedExtensions'
|
||||||
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -66,8 +68,8 @@ const showUpdateModal = () => {
|
|||||||
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
||||||
content: () => {
|
content: () => {
|
||||||
const elements = [
|
const elements = [
|
||||||
h('div', { style: { marginBottom: '12px' } }, [
|
h('div', { style: { marginBottom: '8px' } }, [
|
||||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '版本:'),
|
h('span', { style: { fontSize: '13px', color: 'var(--color-text-2)' } }, '版本:'),
|
||||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value),
|
h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value),
|
||||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
||||||
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
||||||
@@ -76,20 +78,23 @@ const showUpdateModal = () => {
|
|||||||
|
|
||||||
// 更新日志
|
// 更新日志
|
||||||
if (changelog.value) {
|
if (changelog.value) {
|
||||||
|
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
|
||||||
elements.push(
|
elements.push(
|
||||||
h('div', { style: { marginBottom: '12px' } }, [
|
h('div', { style: { marginBottom: '8px' } }, [
|
||||||
h('div', { style: { fontSize: '13px', color: 'var(--color-text-2)', marginBottom: '8px' } }, '更新内容:'),
|
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'),
|
||||||
h('div', {
|
h('div', {
|
||||||
style: {
|
style: {
|
||||||
fontSize: '13px',
|
fontSize: '12px',
|
||||||
color: 'var(--color-text-2)',
|
color: 'var(--color-text-2)',
|
||||||
lineHeight: '1.8',
|
lineHeight: '1.6',
|
||||||
padding: '12px',
|
padding: '10px 12px',
|
||||||
background: 'var(--color-fill-1)',
|
background: 'var(--color-fill-1)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
whiteSpace: 'pre-wrap'
|
maxHeight: '240px',
|
||||||
}
|
overflowY: 'auto'
|
||||||
}, changelog.value)
|
},
|
||||||
|
innerHTML: changelogHtml
|
||||||
|
})
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,7 +109,7 @@ const showUpdateModal = () => {
|
|||||||
}
|
}
|
||||||
if (metadata.length > 0) {
|
if (metadata.length > 0) {
|
||||||
elements.push(
|
elements.push(
|
||||||
h('div', { style: { marginBottom: '12px', fontSize: '13px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
h('div', { style: { marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
<!-- 当前版本信息 -->
|
<!-- 当前版本信息 -->
|
||||||
<a-card title="版本信息" :bordered="false">
|
<a-card title="版本信息" :bordered="false">
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="text" size="small" @click="$emit('open-version-history')">
|
||||||
|
<template #icon><icon-history /></template>
|
||||||
|
版本历史
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
@@ -24,7 +30,7 @@
|
|||||||
<div class="changelog-title">
|
<div class="changelog-title">
|
||||||
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="changelog">{{ updateInfo.changelog }}</div>
|
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" />
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
@@ -79,27 +85,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
|
|
||||||
<!-- 调试信息(始终显示) -->
|
|
||||||
<div style="font-size: 12px; color: #999; padding: 8px; background: var(--color-fill-2); margin-top: 16px; border-radius: 4px;">
|
|
||||||
<strong>调试信息:</strong>
|
|
||||||
<br>downloading = {{ downloading }}
|
|
||||||
<br>downloadProgress = {{ downloadProgress }}
|
|
||||||
<br>downloadStatus = {{ downloadStatus }}
|
|
||||||
<br>progressInfo = {{ progressInfo }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 下载进度 -->
|
<!-- 下载进度 -->
|
||||||
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
|
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
|
||||||
<div style="font-size: 11px; color: #999; margin-bottom: 8px;">
|
|
||||||
进度条已显示:downloadProgress={{ downloadProgress }}, downloading={{ downloading }}
|
|
||||||
</div>
|
|
||||||
<a-progress
|
<a-progress
|
||||||
:percent="downloadProgress"
|
:percent="downloadProgress"
|
||||||
:status="downloadStatus"
|
:status="downloadStatus"
|
||||||
/>
|
/>
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
|
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
||||||
<span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
|
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,11 +112,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { IconHistory } from '@arco-design/web-vue/es/icon'
|
||||||
import { useUpdateStore } from '../stores/update'
|
import { useUpdateStore } from '../stores/update'
|
||||||
|
import { marked } from '../utils/markedExtensions'
|
||||||
|
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
defineEmits(['open-version-history'])
|
||||||
|
|
||||||
// 使用更新管理 store
|
// 使用更新管理 store
|
||||||
const updateStore = useUpdateStore()
|
const updateStore = useUpdateStore()
|
||||||
@@ -136,13 +136,10 @@ const lastCheckTime = ref('-')
|
|||||||
const installResult = ref(null)
|
const installResult = ref(null)
|
||||||
const downloadedFile = ref(null)
|
const downloadedFile = ref(null)
|
||||||
|
|
||||||
// 工具函数
|
/** 渲染 changelog(Markdown → HTML) */
|
||||||
const formatFileSize = (bytes) => {
|
function renderChangelog(text: string): string {
|
||||||
return updateStore.formatFileSize(bytes)
|
if (!text) return ''
|
||||||
}
|
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text }
|
||||||
|
|
||||||
const formatSpeed = (bytesPerSecond) => {
|
|
||||||
return updateStore.formatSpeed(bytesPerSecond)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载当前版本
|
// 加载当前版本
|
||||||
@@ -229,7 +226,12 @@ const handleInstall = async () => {
|
|||||||
|
|
||||||
// 监听下载完成事件(本地覆盖:记录下载文件路径)
|
// 监听下载完成事件(本地覆盖:记录下载文件路径)
|
||||||
const onDownloadComplete = (event) => {
|
const onDownloadComplete = (event) => {
|
||||||
const data = typeof event === 'string' ? JSON.parse(event) : event
|
let data: any
|
||||||
|
try {
|
||||||
|
data = typeof event === 'string' ? JSON.parse(event) : event
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (data.success && data.file_path) {
|
if (data.success && data.file_path) {
|
||||||
downloadedFile.value = data.file_path
|
downloadedFile.value = data.file_path
|
||||||
@@ -280,29 +282,70 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.changelog-section {
|
.changelog-section {
|
||||||
margin-top: 16px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.changelog-title {
|
.changelog-title {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-2);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.changelog {
|
.changelog {
|
||||||
background: var(--color-fill-2);
|
background: var(--color-fill-1);
|
||||||
padding: 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: pre-wrap;
|
margin: 0;
|
||||||
margin: 8px 0;
|
max-height: 280px;
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.changelog :deep(h4) {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
margin: 8px 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(h4:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(ul) {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(li) {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(li::before) {
|
||||||
|
content: '·';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(code) {
|
||||||
|
background: var(--color-fill-3);
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog :deep(p) {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.download-progress {
|
.download-progress {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* 全局 Composables 导出
|
* 全局 Composables 导出
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './useLocalStorage'
|
|
||||||
export * from './useDebounce'
|
export * from './useDebounce'
|
||||||
export * from './useTablePage'
|
export * from './useTablePage'
|
||||||
export * from './useApiError'
|
export * from './useApiError'
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { useLocalStorage } from './useLocalStorage'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收藏夹 composable
|
* 收藏夹 composable
|
||||||
@@ -40,11 +39,27 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
|||||||
onRemove = () => {},
|
onRemove = () => {},
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 使用 localStorage composable 管理收藏列表
|
// 收藏列表
|
||||||
const { storedValue: favoriteFiles, load, save } = useLocalStorage(
|
const favoriteFiles = ref([])
|
||||||
storageKey,
|
|
||||||
[]
|
const load = () => {
|
||||||
)
|
try {
|
||||||
|
const stored = localStorage.getItem(storageKey)
|
||||||
|
if (stored) {
|
||||||
|
favoriteFiles.value = JSON.parse(stored)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载收藏列表失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = (data) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(data || favoriteFiles.value))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存收藏列表失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断文件/目录是否已收藏
|
* 判断文件/目录是否已收藏
|
||||||
@@ -66,10 +81,11 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
favoriteFiles.value.sort((a, b) => {
|
favoriteFiles.value.sort((a, b) => {
|
||||||
const timeA = a.created_at || 0
|
const timeA = a.addedAt || 0
|
||||||
const timeB = b.created_at || 0
|
const timeB = b.addedAt || 0
|
||||||
return timeB - timeA // 倒序:最新的在上面
|
return timeB - timeA // 倒序:最新的在上面
|
||||||
})
|
})
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,8 +122,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
|||||||
favoriteFiles.value.push({
|
favoriteFiles.value.push({
|
||||||
path: item.path,
|
path: item.path,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
is_dir: item.is_dir || false,
|
isDir: item.isDir || false,
|
||||||
created_at: Date.now(), // 添加时间戳(用于 getSortedFavorites)
|
addedAt: Date.now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
|
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
|
||||||
@@ -201,8 +217,8 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
|||||||
const getSortedFavorites = (order = 'desc') => {
|
const getSortedFavorites = (order = 'desc') => {
|
||||||
const sorted = [...favoriteFiles.value]
|
const sorted = [...favoriteFiles.value]
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
const timeA = a.created_at || 0
|
const timeA = a.addedAt || 0
|
||||||
const timeB = b.created_at || 0
|
const timeB = b.addedAt || 0
|
||||||
return order === 'desc' ? timeB - timeA : timeA - timeB
|
return order === 'desc' ? timeB - timeA : timeA - timeB
|
||||||
})
|
})
|
||||||
return sorted
|
return sorted
|
||||||
@@ -255,9 +271,27 @@ export function useFavoriteFiles(storageKey, options = {}) {
|
|||||||
return true
|
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(() => {
|
onMounted(() => {
|
||||||
load()
|
load()
|
||||||
|
migrateFieldNames(favoriteFiles.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
/**
|
|
||||||
* 文件编辑和保存逻辑 composable
|
|
||||||
*
|
|
||||||
* @module composables/useFileEdit
|
|
||||||
* @description 封装文件编辑、保存、草稿管理等逻辑
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
|
||||||
import { STORAGE_KEYS } from '@/utils/constants'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 草稿存储键
|
|
||||||
*/
|
|
||||||
const DRAFT_STORAGE_KEY = 'filesystem_draft_content'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件编辑 composable
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {Ref<string>} options.filePath - 当前文件路径
|
|
||||||
* @param {Ref<string>} options.fileContent - 文件内容
|
|
||||||
* @param {Function} options.onWriteFile - 写入文件的函数
|
|
||||||
* @param {Function} options.onReset - 重置内容的函数
|
|
||||||
* @returns {UseFileEditReturn} 文件编辑操作 API
|
|
||||||
*/
|
|
||||||
export function useFileEdit(options = {}) {
|
|
||||||
const {
|
|
||||||
filePath,
|
|
||||||
fileContent,
|
|
||||||
onWriteFile,
|
|
||||||
onReset,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// ========== 编辑状态 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否正在保存
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否是快捷键触发的保存
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isShortcutSave = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存成功提示消息
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
const saveSuccessMessage = ref('')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 原始文件内容(用于检测变更)
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
const originalContent = ref('')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否为编辑模式
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isEditMode = ref(localStorage.getItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE) === 'true')
|
|
||||||
|
|
||||||
// ========== 计算属性 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件内容是否已修改
|
|
||||||
*/
|
|
||||||
const isFileModified = computed(() => {
|
|
||||||
return originalContent.value !== undefined &&
|
|
||||||
originalContent.value !== fileContent.value
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 内容是否发生变化(用于按钮禁用判断)
|
|
||||||
*/
|
|
||||||
const contentChanged = computed(() => {
|
|
||||||
return fileContent.value !== '' &&
|
|
||||||
fileContent.value !== originalContent.value
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否可以保存文件
|
|
||||||
*/
|
|
||||||
const canSaveFile = computed(() => {
|
|
||||||
return isEditMode.value && contentChanged.value
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否可以重置内容
|
|
||||||
*/
|
|
||||||
const canResetContent = computed(() => {
|
|
||||||
return isEditMode.value &&
|
|
||||||
contentChanged.value &&
|
|
||||||
originalContent.value !== undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
// ========== 草稿管理 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存草稿到 localStorage
|
|
||||||
*/
|
|
||||||
const saveDraft = () => {
|
|
||||||
try {
|
|
||||||
const draft = {
|
|
||||||
content: fileContent.value,
|
|
||||||
path: filePath.value,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}
|
|
||||||
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
|
|
||||||
localStorage.setItem(DRAFT_STORAGE_KEY + '_time', Date.now().toString())
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[saveDraft] 保存草稿失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除草稿
|
|
||||||
*/
|
|
||||||
const clearDraft = () => {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
|
||||||
localStorage.removeItem(DRAFT_STORAGE_KEY + '_time')
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[clearDraft] 清除草稿失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载草稿
|
|
||||||
* @returns {Object|null} 草稿数据
|
|
||||||
*/
|
|
||||||
const loadDraft = () => {
|
|
||||||
try {
|
|
||||||
const draftStr = localStorage.getItem(DRAFT_STORAGE_KEY)
|
|
||||||
if (!draftStr) return null
|
|
||||||
|
|
||||||
const draft = JSON.parse(draftStr)
|
|
||||||
|
|
||||||
// 检查草稿是否过期(24小时)
|
|
||||||
const timeStr = localStorage.getItem(DRAFT_STORAGE_KEY + '_time')
|
|
||||||
if (timeStr) {
|
|
||||||
const time = parseInt(timeStr, 10)
|
|
||||||
const now = Date.now()
|
|
||||||
const hours = (now - time) / (1000 * 60 * 60)
|
|
||||||
|
|
||||||
if (hours > 24) {
|
|
||||||
clearDraft()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return draft
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[loadDraft] 加载草稿失败:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 保存操作 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示手动保存对话框
|
|
||||||
* @param {boolean} isShortcut - 是否是快捷键触发
|
|
||||||
*/
|
|
||||||
const showManualSaveDialog = (isShortcut) => {
|
|
||||||
isShortcutSave.value = isShortcut
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '保存文件',
|
|
||||||
content: `确定要保存文件 ${filePath.value} 吗?`,
|
|
||||||
okText: '保存',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: () => {
|
|
||||||
saveToFile(filePath.value, getFileName(filePath.value), isShortcut)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存到文件
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
* @param {string} fileName - 文件名
|
|
||||||
* @param {boolean} isShortcut - 是否是快捷键触发
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const saveToFile = async (targetPath, fileName, isShortcut) => {
|
|
||||||
isSaving.value = true
|
|
||||||
try {
|
|
||||||
const success = await onWriteFile(fileContent.value, targetPath, fileName, isShortcut)
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
originalContent.value = fileContent.value
|
|
||||||
clearDraft()
|
|
||||||
}
|
|
||||||
|
|
||||||
return success
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理保存内容
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const handleSaveContent = async () => {
|
|
||||||
if (!canSaveFile.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return await saveToFile(filePath.value, getFileName(filePath.value), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 另存为
|
|
||||||
*/
|
|
||||||
const handleSaveAs = async () => {
|
|
||||||
try {
|
|
||||||
// 简单实现:使用 prompt 获取路径
|
|
||||||
const targetPath = prompt('请输入保存路径:', filePath.value)
|
|
||||||
|
|
||||||
if (!targetPath) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = getFileName(targetPath)
|
|
||||||
return await saveToFile(targetPath, fileName, false)
|
|
||||||
} catch (error) {
|
|
||||||
Message.error(`保存对话框失败: ${error.message || error}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理写入文件(快捷键或按钮)
|
|
||||||
* @param {boolean} isShortcut - 是否是快捷键触发
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
const handleWriteFile = async (isShortcut = false) => {
|
|
||||||
if (!fileContent.value || !filePath.value) {
|
|
||||||
Message.warning('没有可保存的内容')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果内容未修改,快捷键保存时静默返回
|
|
||||||
if (!isFileModified.value && isShortcut) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 快捷键:静默保存
|
|
||||||
if (isShortcut) {
|
|
||||||
return await saveToFile(filePath.value, getFileName(filePath.value), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按钮:显示确认对话框
|
|
||||||
showManualSaveDialog(false)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 重置操作 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置内容到原始状态
|
|
||||||
*/
|
|
||||||
const resetContent = () => {
|
|
||||||
if (onReset) {
|
|
||||||
onReset()
|
|
||||||
} else {
|
|
||||||
fileContent.value = originalContent.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 编辑模式切换 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换编辑模式
|
|
||||||
*/
|
|
||||||
const toggleEditMode = () => {
|
|
||||||
isEditMode.value = !isEditMode.value
|
|
||||||
|
|
||||||
// 持久化
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.EDIT_MODE, isEditMode.value.toString())
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[toggleEditMode] 保存编辑模式失败:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进入编辑模式时,记录原始内容
|
|
||||||
if (isEditMode.value) {
|
|
||||||
originalContent.value = fileContent.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 工具函数 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从路径获取文件名
|
|
||||||
* @param {string} path - 文件路径
|
|
||||||
* @returns {string} 文件名
|
|
||||||
*/
|
|
||||||
const getFileName = (path) => {
|
|
||||||
if (!path) return ''
|
|
||||||
const parts = path.split(/[/\\]/)
|
|
||||||
return parts[parts.length - 1] || path
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 监听内容变化 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听文件内容变化,自动保存草稿
|
|
||||||
*/
|
|
||||||
watch(fileContent, () => {
|
|
||||||
if (fileContent.value && fileContent.value !== originalContent.value) {
|
|
||||||
saveDraft()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听文件路径变化,更新原始内容
|
|
||||||
*/
|
|
||||||
watch(filePath, () => {
|
|
||||||
originalContent.value = fileContent.value
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
isSaving,
|
|
||||||
isShortcutSave,
|
|
||||||
saveSuccessMessage,
|
|
||||||
originalContent,
|
|
||||||
isEditMode,
|
|
||||||
isFileModified,
|
|
||||||
canSaveFile,
|
|
||||||
canResetContent,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
saveDraft,
|
|
||||||
clearDraft,
|
|
||||||
loadDraft,
|
|
||||||
handleSaveContent,
|
|
||||||
handleSaveAs,
|
|
||||||
handleWriteFile,
|
|
||||||
resetContent,
|
|
||||||
toggleEditMode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} UseFileEditReturn
|
|
||||||
* @property {Ref<boolean>} isSaving - 是否正在保存
|
|
||||||
* @property {Ref<boolean>} isShortcutSave - 是否是快捷键触发
|
|
||||||
* @property {Ref<string>} saveSuccessMessage - 保存成功提示消息
|
|
||||||
* @property {Ref<string>} originalContent - 原始文件内容
|
|
||||||
* @property {Ref<boolean>} isEditMode - 是否为编辑模式
|
|
||||||
* @property {ComputedRef<boolean>} isFileModified - 文件内容是否已修改
|
|
||||||
* @property {ComputedRef<boolean>} canSaveFile - 是否可以保存文件
|
|
||||||
* @property {ComputedRef<boolean>} canResetContent - 是否可以重置内容
|
|
||||||
* @property {Function} saveDraft - 保存草稿
|
|
||||||
* @property {Function} clearDraft - 清除草稿
|
|
||||||
* @property {Function} loadDraft - 加载草稿
|
|
||||||
* @property {Function} handleSaveContent - 处理保存内容
|
|
||||||
* @property {Function} handleSaveAs - 另存为
|
|
||||||
* @property {Function} handleWriteFile - 处理写入文件
|
|
||||||
* @property {Function} resetContent - 重置内容
|
|
||||||
* @property {Function} toggleEditMode - 切换编辑模式
|
|
||||||
*/
|
|
||||||
@@ -1,603 +0,0 @@
|
|||||||
/**
|
|
||||||
* 文件预览逻辑 composable
|
|
||||||
*
|
|
||||||
* @module composables/useFilePreview
|
|
||||||
* @description 封装文件预览、HTML/Markdown 渲染、二进制文件信息显示等逻辑
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { marked } from '@/utils/markedExtensions'
|
|
||||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
|
||||||
import { getExt } from '@/utils/fileHelpers'
|
|
||||||
import { isOfficeFile } from '@/utils/fileTypeHelpers'
|
|
||||||
import { debugLog, debugWarn, debugError } from '@/utils/debugLog'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件预览 composable
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {Ref<string>} options.filePath - 当前文件路径
|
|
||||||
* @param {Ref<string>} options.fileContent - 文件内容
|
|
||||||
* @param {Ref<Array>} options.fileList - 文件列表
|
|
||||||
* @param {Function} options.onReadFile - 读取文件的函数
|
|
||||||
* @returns {UseFilePreviewReturn} 文件预览操作 API
|
|
||||||
*/
|
|
||||||
export function useFilePreview(options = {}) {
|
|
||||||
const {
|
|
||||||
filePath,
|
|
||||||
fileContent,
|
|
||||||
fileList,
|
|
||||||
onReadFile,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// ========== 预览状态 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览 URL
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
const previewUrl = ref('')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件服务器URL
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
const fileServerURL = ref('http://localhost:18765')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染后的 HTML/Markdown 内容
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
const rendered = ref('')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片加载状态
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const imageLoading = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片宽度
|
|
||||||
* @type {Ref<number>}
|
|
||||||
*/
|
|
||||||
const imageWidth = ref(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片高度
|
|
||||||
* @type {Ref<number>}
|
|
||||||
*/
|
|
||||||
const imageHeight = ref(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否显示图片预览
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isImageView = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否显示视频预览
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isVideoView = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否显示音频预览
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isAudioView = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否为 PDF 文件
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isPdfFile = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否为 HTML 文件
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isHtmlFile = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否为 Markdown 文件
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isMarkdownFile = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否为二进制文件信息展示
|
|
||||||
* @type {Ref<boolean>}
|
|
||||||
*/
|
|
||||||
const isBinaryFile = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTML 预览的 blob URL
|
|
||||||
* @type {Ref<string>}
|
|
||||||
*/
|
|
||||||
const htmlPreviewUrl = ref('')
|
|
||||||
|
|
||||||
// ========== 计算属性 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前文件名
|
|
||||||
*/
|
|
||||||
const currentFileName = computed(() => {
|
|
||||||
if (!filePath.value) return ''
|
|
||||||
const pathStr = typeof filePath.value === 'string' ? filePath.value : String(filePath.value || '')
|
|
||||||
const parts = pathStr.split(/[/\\]/)
|
|
||||||
return parts[parts.length - 1]
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前文件完整路径
|
|
||||||
*/
|
|
||||||
const currentFileFullPath = computed(() => filePath.value || '')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前图片尺寸
|
|
||||||
*/
|
|
||||||
const currentImageDimensions = computed(() => {
|
|
||||||
if (!imageWidth.value || !imageHeight.value) return ''
|
|
||||||
return `${imageWidth.value}×${imageHeight.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// ========== 图片预览 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览图片
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
*/
|
|
||||||
const previewImage = async (targetPath) => {
|
|
||||||
const pathToPreview = targetPath || filePath.value
|
|
||||||
if (!pathToPreview) return
|
|
||||||
|
|
||||||
resetPreviewState()
|
|
||||||
|
|
||||||
const ext = getExt(pathToPreview)
|
|
||||||
if (!FILE_EXTENSIONS.IMAGE.includes(ext)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imageLoading.value = true
|
|
||||||
isImageView.value = true
|
|
||||||
|
|
||||||
// 构建预览 URL
|
|
||||||
const encodedPath = encodeURIComponent(pathToPreview)
|
|
||||||
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片加载成功回调
|
|
||||||
* @param {Event} e - 加载事件
|
|
||||||
*/
|
|
||||||
const onImageLoad = (e) => {
|
|
||||||
imageLoading.value = false
|
|
||||||
imageWidth.value = e.naturalWidth || e.target?.width || 0
|
|
||||||
imageHeight.value = e.naturalHeight || e.target?.height || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片加载失败回调
|
|
||||||
*/
|
|
||||||
const onImageError = () => {
|
|
||||||
imageLoading.value = false
|
|
||||||
debugWarn('[onImageError] 图片加载失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 视频/音频/PDF 预览 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览媒体文件(视频/音频/PDF)
|
|
||||||
* @param {string} mediaType - 媒体类型 ('video' | 'audio' | 'pdf')
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
*/
|
|
||||||
const previewMedia = (mediaType, targetPath) => {
|
|
||||||
const pathToPreview = targetPath || filePath.value
|
|
||||||
if (!pathToPreview) return
|
|
||||||
|
|
||||||
resetPreviewState()
|
|
||||||
|
|
||||||
const encodedPath = encodeURIComponent(pathToPreview)
|
|
||||||
previewUrl.value = `${fileServerURL.value}/file?path=${encodedPath}`
|
|
||||||
|
|
||||||
if (mediaType === 'video') {
|
|
||||||
isVideoView.value = true
|
|
||||||
} else if (mediaType === 'audio') {
|
|
||||||
isAudioView.value = true
|
|
||||||
} else if (mediaType === 'pdf') {
|
|
||||||
isPdfFile.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览视频
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
*/
|
|
||||||
const previewVideo = (targetPath) => previewMedia('video', targetPath)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览音频
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
*/
|
|
||||||
const previewAudio = (targetPath) => previewMedia('audio', targetPath)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览 PDF
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
*/
|
|
||||||
const previewPdf = (targetPath) => previewMedia('pdf', targetPath)
|
|
||||||
|
|
||||||
// ========== HTML 预览 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取 HTML 文件中的样式
|
|
||||||
* @param {string} htmlContent - HTML 内容
|
|
||||||
* @param {string} basePath - 基础路径
|
|
||||||
* @returns {Promise<string>} 提取的 CSS 样式
|
|
||||||
*/
|
|
||||||
const extractHtmlStyles = async (htmlContent, basePath) => {
|
|
||||||
const linkRegex = /<link[^>]*href=(["'])([^"']+)\1[^>]*>/gi
|
|
||||||
const links = [...htmlContent.matchAll(linkRegex)]
|
|
||||||
|
|
||||||
if (links.length === 0) return ''
|
|
||||||
|
|
||||||
let linkCount = 0
|
|
||||||
const styles = []
|
|
||||||
|
|
||||||
for (const match of links) {
|
|
||||||
const linkTag = match[0]
|
|
||||||
const hrefMatch = match[2]?.match(/^https?:\/\//i)
|
|
||||||
|
|
||||||
const fullTag = match[0]
|
|
||||||
const href = match[2]
|
|
||||||
|
|
||||||
debugLog(`[extractHtmlStyles] 发现第 ${linkCount} 个 link 标签:`, fullTag)
|
|
||||||
|
|
||||||
const cssPath = href?.replace(/^\.\//, '').replace(/^\//, '')
|
|
||||||
debugLog('[extractHtmlStyles] 解析后 CSS 路径:', cssPath)
|
|
||||||
|
|
||||||
if (hrefMatch) {
|
|
||||||
debugLog('[extractHtmlStyles] 跳过外部 CSS:', hrefMatch[1])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog('[extractHtmlStyles] 正在读取 CSS 文件:', cssPath)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 从 HTML 文件所在目录读取 CSS
|
|
||||||
const cssFullPath = basePath + '/' + cssPath
|
|
||||||
const cssContent = await onReadFile(cssFullPath)
|
|
||||||
|
|
||||||
if (cssContent) {
|
|
||||||
const cssSize = cssContent.length
|
|
||||||
debugLog(`[extractHtmlStyles] 成功读取并转换 CSS: ${cssSize} 字符`)
|
|
||||||
|
|
||||||
// 转换 CSS 中的 URL 为 base64
|
|
||||||
const convertedCss = await convertCssUrls(cssContent, basePath)
|
|
||||||
styles.push(convertedCss)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
debugWarn('[extractHtmlStyles] 无法读取 CSS:', cssPath, error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
linkCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog(`处理完成: 找到 ${linkCount} 个 link 标签, 成功提取 ${styles.length} 个 CSS 文件`)
|
|
||||||
debugLog(`提取的 CSS 总大小: ${styles.join('\n\n').length} 字符`)
|
|
||||||
|
|
||||||
return styles.join('\n\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 转换 CSS 中的相对 URL 为 base64
|
|
||||||
* @param {string} css - CSS 内容
|
|
||||||
* @param {string} basePath - 基础路径
|
|
||||||
* @returns {Promise<string>} 转换后的 CSS
|
|
||||||
*/
|
|
||||||
const convertCssUrls = async (css, basePath) => {
|
|
||||||
const urlRegex = /url\((["']?)([^"')]+)\1\)/gi
|
|
||||||
|
|
||||||
return css.replace(urlRegex, async (match, quote, url) => {
|
|
||||||
// 跳过 data: URLs 和绝对 URLs
|
|
||||||
if (url.startsWith('data:') || /^https?:\/\//i.test(url)) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const imagePath = basePath + '/' + url.replace(/^\.\//, '')
|
|
||||||
const base64 = await fileToBase64(imagePath)
|
|
||||||
|
|
||||||
debugLog(`[convertCssUrls] ${url} -> base64`)
|
|
||||||
|
|
||||||
return `url("data:image/${getExt(imagePath)};base64,${base64}")`
|
|
||||||
} catch (err) {
|
|
||||||
debugWarn('[convertCssUrls] 失败:', imagePath, err.message)
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将文件转换为 base64
|
|
||||||
* @param {string} filePath - 文件路径
|
|
||||||
* @returns {Promise<string>} base64 字符串
|
|
||||||
*/
|
|
||||||
const fileToBase64 = async (filePath) => {
|
|
||||||
// 这里需要调用实际的文件读取 API
|
|
||||||
// 简化实现,返回空字符串
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览 HTML 文件
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
*/
|
|
||||||
const previewHtml = async (targetPath) => {
|
|
||||||
const pathToPreview = targetPath || filePath.value
|
|
||||||
if (!pathToPreview) return
|
|
||||||
|
|
||||||
resetPreviewState()
|
|
||||||
isHtmlFile.value = true
|
|
||||||
|
|
||||||
debugLog('开始处理 CSS')
|
|
||||||
debugLog('HTML 文件路径:', pathToPreview)
|
|
||||||
|
|
||||||
const basePath = pathToPreview.replace(/[^/\\]+$/, '')
|
|
||||||
|
|
||||||
try {
|
|
||||||
let htmlContent = fileContent.value
|
|
||||||
|
|
||||||
// 提取并转换 CSS
|
|
||||||
const styles = await extractHtmlStyles(htmlContent, basePath)
|
|
||||||
|
|
||||||
// 转换图片引用
|
|
||||||
const imgRegex = /<img[^>]*src=(["'])([^"']+)\1[^>]*>/gi
|
|
||||||
htmlContent = htmlContent.replace(imgRegex, (match, quote, src) => {
|
|
||||||
// 跳过 data: URLs 和绝对 URLs
|
|
||||||
if (src.startsWith('data:') || /^https?:\/\//i.test(src)) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog(`[previewHtml] ${src} -> base64`)
|
|
||||||
|
|
||||||
// 转换为绝对路径
|
|
||||||
const imagePath = basePath + src.replace(/^\.\//, '').replace(/^\//, '')
|
|
||||||
|
|
||||||
// 简化实现:使用 fileServerURL
|
|
||||||
const encodedPath = encodeURIComponent(imagePath)
|
|
||||||
const newSrc = `${fileServerURL.value}/file?path=${encodedPath}`
|
|
||||||
|
|
||||||
return match.replace(src, newSrc)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 移除本地脚本
|
|
||||||
htmlContent = htmlContent.replace(/<script[^>]*src=(["'])[^"']+\1[^>]*>/gi, (match, quote, src) => {
|
|
||||||
const srcMatch = match.match(/src=(["'])([^"']+)\1/i)
|
|
||||||
if (srcMatch) {
|
|
||||||
const srcValue = srcMatch[2]
|
|
||||||
if (!srcValue.startsWith('http')) {
|
|
||||||
debugLog(`[previewHtml] 移除本地脚本: ${srcValue}`)
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
})
|
|
||||||
|
|
||||||
// 清理遗漏的 CSS 链接
|
|
||||||
htmlContent = htmlContent.replace(/<link[^>]*rel=(["'])stylesheet\1[^>]*>/gi, (match) => {
|
|
||||||
const hrefMatch = match.match(/href=(["'])([^"']+)\1/i)
|
|
||||||
if (hrefMatch && !/^https?:\/\//i.test(hrefMatch[2])) {
|
|
||||||
debugLog(`[previewHtml] 清理遗漏的CSS链接: ${hrefMatch[2]}`)
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
})
|
|
||||||
|
|
||||||
// 构建最终 HTML
|
|
||||||
const finalHtml = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>${styles}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${htmlContent}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
|
||||||
// 创建 blob URL
|
|
||||||
const blob = new Blob([finalHtml], { type: 'text/html' })
|
|
||||||
htmlPreviewUrl.value = URL.createObjectURL(blob)
|
|
||||||
rendered.value = finalHtml
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[previewHtml] 处理失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Markdown 预览 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览 Markdown 文件
|
|
||||||
* @param {string} targetPath - 目标路径
|
|
||||||
*/
|
|
||||||
const previewMarkdown = async (targetPath) => {
|
|
||||||
const pathToPreview = targetPath || filePath.value
|
|
||||||
if (!pathToPreview) return
|
|
||||||
|
|
||||||
resetPreviewState()
|
|
||||||
isMarkdownFile.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
renderMarkdown(fileContent.value)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[renderMarkdown] 解析失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染 Markdown
|
|
||||||
* @param {string} markdown - Markdown 内容
|
|
||||||
*/
|
|
||||||
const renderMarkdown = (markdown) => {
|
|
||||||
try {
|
|
||||||
rendered.value = marked(markdown)
|
|
||||||
} catch (error) {
|
|
||||||
debugError('[renderMarkdown] 解析失败:', error)
|
|
||||||
rendered.value = '<p class="error">Markdown 解析失败</p>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 二进制文件信息 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取字符串显示宽度(用于对齐)
|
|
||||||
* @param {string} str - 字符串
|
|
||||||
* @returns {number} 显示宽度
|
|
||||||
*/
|
|
||||||
const getDisplayWidth = (str) => {
|
|
||||||
let width = 0
|
|
||||||
for (const char of str) {
|
|
||||||
if (char.match(/[\u4e00-\u9fa5]/)) {
|
|
||||||
width += 2
|
|
||||||
} else {
|
|
||||||
width += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按显示宽度填充
|
|
||||||
* @param {string} str - 字符串
|
|
||||||
* @param {number} targetWidth - 目标宽度
|
|
||||||
* @returns {string} 填充后的字符串
|
|
||||||
*/
|
|
||||||
const padByDisplayWidth = (str, targetWidth) => {
|
|
||||||
const currentWidth = getDisplayWidth(str)
|
|
||||||
const padding = Math.max(0, targetWidth - currentWidth)
|
|
||||||
return str + ' '.repeat(padding)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示二进制文件信息
|
|
||||||
* @param {string} ext - 文件扩展名
|
|
||||||
* @param {string} filePathParam - 文件路径
|
|
||||||
*/
|
|
||||||
const showBinaryFileInfo = (ext, filePathParam) => {
|
|
||||||
resetPreviewState()
|
|
||||||
isBinaryFile.value = true
|
|
||||||
|
|
||||||
const file = fileList.value.find(f => f.path === filePathParam)
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
const extUpper = ext.toUpperCase()
|
|
||||||
const extPadded = padByDisplayWidth(extUpper, 6)
|
|
||||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2)
|
|
||||||
const sizeStr = `${sizeMB} MB`.padStart(10, ' ')
|
|
||||||
|
|
||||||
rendered.value = `
|
|
||||||
<div class="binary-file-info">
|
|
||||||
<p>
|
|
||||||
<span class="file-type">${extPadded} 文件</span>
|
|
||||||
<span class="file-size">${sizeStr}</span>
|
|
||||||
</p>
|
|
||||||
<p class="file-name">${file.name}</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 工具函数 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置预览状态
|
|
||||||
*/
|
|
||||||
const resetPreviewState = () => {
|
|
||||||
isImageView.value = false
|
|
||||||
isVideoView.value = false
|
|
||||||
isAudioView.value = false
|
|
||||||
isPdfFile.value = false
|
|
||||||
isHtmlFile.value = false
|
|
||||||
isMarkdownFile.value = false
|
|
||||||
isBinaryFile.value = false
|
|
||||||
|
|
||||||
if (htmlPreviewUrl.value) {
|
|
||||||
URL.revokeObjectURL(htmlPreviewUrl.value)
|
|
||||||
htmlPreviewUrl.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
previewUrl.value = ''
|
|
||||||
rendered.value = ''
|
|
||||||
imageWidth.value = 0
|
|
||||||
imageHeight.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
previewUrl,
|
|
||||||
fileServerURL,
|
|
||||||
rendered,
|
|
||||||
imageLoading,
|
|
||||||
imageWidth,
|
|
||||||
imageHeight,
|
|
||||||
isImageView,
|
|
||||||
isVideoView,
|
|
||||||
isAudioView,
|
|
||||||
isPdfFile,
|
|
||||||
isHtmlFile,
|
|
||||||
isMarkdownFile,
|
|
||||||
isBinaryFile,
|
|
||||||
htmlPreviewUrl,
|
|
||||||
currentFileName,
|
|
||||||
currentFileFullPath,
|
|
||||||
currentImageDimensions,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
previewImage,
|
|
||||||
previewVideo,
|
|
||||||
previewAudio,
|
|
||||||
previewPdf,
|
|
||||||
previewHtml,
|
|
||||||
previewMarkdown,
|
|
||||||
renderMarkdown,
|
|
||||||
showBinaryFileInfo,
|
|
||||||
onImageLoad,
|
|
||||||
onImageError,
|
|
||||||
isOfficeFile,
|
|
||||||
resetPreviewState,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} UseFilePreviewReturn
|
|
||||||
* @property {Ref<string>} previewUrl - 预览 URL
|
|
||||||
* @property {Ref<string>} fileServerURL - 文件服务器URL
|
|
||||||
* @property {Ref<string>} rendered - 渲染后的内容
|
|
||||||
* @property {Ref<boolean>} imageLoading - 图片加载状态
|
|
||||||
* @property {Ref<number>} imageWidth - 图片宽度
|
|
||||||
* @property {Ref<number>} imageHeight - 图片高度
|
|
||||||
* @property {Ref<boolean>} isImageView - 是否显示图片预览
|
|
||||||
* @property {Ref<boolean>} isVideoView - 是否显示视频预览
|
|
||||||
* @property {Ref<boolean>} isAudioView - 是否显示音频预览
|
|
||||||
* @property {Ref<boolean>} isPdfFile - 是否为 PDF 文件
|
|
||||||
* @property {Ref<boolean>} isHtmlFile - 是否为 HTML 文件
|
|
||||||
* @property {Ref<boolean>} isMarkdownFile - 是否为 Markdown 文件
|
|
||||||
* @property {Ref<boolean>} isBinaryFile - 是否为二进制文件信息展示
|
|
||||||
* @property {Ref<string>} htmlPreviewUrl - HTML 预览的 blob URL
|
|
||||||
* @property {ComputedRef<string>} currentFileName - 当前文件名
|
|
||||||
* @property {ComputedRef<string>} currentFileFullPath - 当前文件完整路径
|
|
||||||
* @property {ComputedRef<string>} currentImageDimensions - 当前图片尺寸
|
|
||||||
* @property {Function} previewImage - 预览图片
|
|
||||||
* @property {Function} previewVideo - 预览视频
|
|
||||||
* @property {Function} previewAudio - 预览音频
|
|
||||||
* @property {Function} previewPdf - 预览 PDF
|
|
||||||
* @property {Function} previewHtml - 预览 HTML
|
|
||||||
* @property {Function} previewMarkdown - 预览 Markdown
|
|
||||||
* @property {Function} renderMarkdown - 渲染 Markdown
|
|
||||||
* @property {Function} showBinaryFileInfo - 显示二进制文件信息
|
|
||||||
* @property {Function} onImageLoad - 图片加载成功回调
|
|
||||||
* @property {Function} onImageError - 图片加载失败回调
|
|
||||||
* @property {Function} isOfficeFile - 判断是否为 Office 文件
|
|
||||||
* @property {Function} resetPreviewState - 重置预览状态
|
|
||||||
*/
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
50
web/src/composables/useVisibleDatabases.ts
Normal file
50
web/src/composables/useVisibleDatabases.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 可见数据库管理 Composable
|
||||||
|
* 封装 visible_databases 字段的解析和过滤逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析可见数据库 JSON 字符串
|
||||||
|
* @param jsonStr - JSON 字符串或 null
|
||||||
|
* @returns 解析后的数据库数组,解析失败返回空数组
|
||||||
|
*/
|
||||||
|
export function parseVisibleDatabases(jsonStr: string | null): string[] {
|
||||||
|
if (!jsonStr) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据可见数据库配置过滤数据库列表
|
||||||
|
* @param databases - 完整的数据库列表
|
||||||
|
* @param visibleJson - 可见数据库 JSON 字符串
|
||||||
|
* @returns 过滤后的数据库列表(如果未配置过滤则返回全部)
|
||||||
|
*/
|
||||||
|
export function filterDatabases(databases: string[], visibleJson: string | null): string[] {
|
||||||
|
const visible = parseVisibleDatabases(visibleJson)
|
||||||
|
return visible.length > 0 ? databases.filter(db => visible.includes(db)) : databases
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将数据库数组序列化为 JSON 字符串(空数组返回空字符串)
|
||||||
|
* @param databases - 数据库数组
|
||||||
|
* @returns JSON 字符串或空字符串
|
||||||
|
*/
|
||||||
|
export function serializeVisibleDatabases(databases: string[]): string {
|
||||||
|
return databases.length > 0 ? JSON.stringify(databases) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可见数据库管理 Composable
|
||||||
|
*/
|
||||||
|
export function useVisibleDatabases() {
|
||||||
|
return {
|
||||||
|
parse: parseVisibleDatabases,
|
||||||
|
filter: filterDatabases,
|
||||||
|
serialize: serializeVisibleDatabases,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user