Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90695d71d1 |
40
CHANGELOG.md
40
CHANGELOG.md
@@ -1,34 +1,5 @@
|
||||
# 更新日志
|
||||
|
||||
## [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
|
||||
|
||||
### 新增 ✨
|
||||
@@ -43,15 +14,15 @@
|
||||
|
||||
### 优化 🚀
|
||||
- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容
|
||||
- SQL 查询优化器 — 查询缓存、慢查询日志
|
||||
- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)
|
||||
- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持
|
||||
- Office/CSV 预览增强 — 本地文件服务器获取文件
|
||||
- Markdown 增强 — 本地文件链接支持、Shell 语法高亮
|
||||
- HTML 预览 — 改用 iframe src 替代 srcdoc
|
||||
- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复
|
||||
- 文件列表 UI 重构 — 统一渲染逻辑,提升滚动性能
|
||||
- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑
|
||||
- CSV 编辑模式优化 + PDF 导出重构
|
||||
- 拷贝功能优化
|
||||
- 拷贝功能优化 — 新增 ClipboardCopy composable
|
||||
|
||||
### 修复 🐛
|
||||
- Office 文件预览:修复类型检测与二进制误判
|
||||
@@ -68,9 +39,10 @@
|
||||
### 重构 🔧
|
||||
- CodeMirror 架构优化 — 统一导出避免多实例问题
|
||||
- 消除代码重复 — storage/connection_service 重构、useVisibleDatabases 抽取
|
||||
- 大规模死代码清理,显著减小包体积
|
||||
- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层(connection_service 279行)、audit_log、file_lock、recycle_bin、zip_helper、useFileEdit.js(-369行)、useFilePreview.js(-603行)、errorHandler.js(-63行)、DeviceTest 清理等
|
||||
- 配置加载超时保护(最多重试 30 次)
|
||||
- 正则表达式预编译、缓存读锁优化
|
||||
- 正则表达式预编译(query_optimizer)
|
||||
- 缓存读锁优化 + SHA-256 key hash
|
||||
- 禁止 Ctrl+滚轮缩放
|
||||
- Dockerfile 语法高亮支持
|
||||
- 滚动条样式修复
|
||||
|
||||
24
README.md
24
README.md
@@ -1,22 +1,10 @@
|
||||
# U-Desk v0.3.4
|
||||
# U-Desk v0.3.3
|
||||
|
||||
## 功能
|
||||
- **文件管理** — 本地文件浏览、编辑(CodeMirror 语法高亮+搜索)、预览(图片/视频/PDF/HTML/Markdown/Excel/Word/CSV)
|
||||
- **数据库客户端** — 多数据库连接管理、SQL 执行、查询历史、表结构管理
|
||||
- **Markdown 编辑器** — 独立编辑页面、实时预览、PDF 导出
|
||||
- **版本更新** — 自动检查更新、下载安装、changelog 渲染
|
||||
- **系统信息** — CPU/内存/磁盘硬件信息查询
|
||||
|
||||
## 技术栈
|
||||
- **后端**: Go + Wails v2 (桌面应用框架)
|
||||
- **前端**: Vue 3 + Arco Design + CodeMirror 6 + Pinia
|
||||
- **存储**: SQLite (GORM)
|
||||
- **本地文件服务器**: `localhost:8073`(CSS/JS 路径转换、HTML 预览)
|
||||
|
||||
## 开发
|
||||
```bash
|
||||
wails dev
|
||||
```
|
||||
- 数据库客户端
|
||||
- Markdown编辑器
|
||||
- PDF导出
|
||||
|
||||
## 更新
|
||||
- ✅ 文件服务器安全重构+编辑器增强+搜索排序+更新面板渲染
|
||||
- ✅ MD编辑器完成
|
||||
- ✅ PDF导出优化中
|
||||
72
app.go
72
app.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdruntime "runtime"
|
||||
@@ -29,6 +30,7 @@ type App struct {
|
||||
updateAPI *api.UpdateAPI
|
||||
configAPI *api.ConfigAPI
|
||||
pdfAPI *api.PdfAPI
|
||||
fileServer *http.Server
|
||||
filesystem *filesystem.FileSystemService
|
||||
isAlwaysOnTop bool
|
||||
}
|
||||
@@ -192,7 +194,7 @@ func (a *App) startFileServer() {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:18765")
|
||||
}
|
||||
|
||||
// Shutdown 应用关闭时调用
|
||||
@@ -413,7 +415,7 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
|
||||
folderGUIDs := map[string]string{
|
||||
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
|
||||
"documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}",
|
||||
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||
}
|
||||
for name, guid := range folderGUIDs {
|
||||
@@ -601,84 +603,68 @@ func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
|
||||
|
||||
// ========== 版本更新管理接口 ==========
|
||||
|
||||
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
|
||||
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
|
||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
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()
|
||||
return a.updateAPI.CheckUpdate()
|
||||
}
|
||||
|
||||
// GetCurrentVersion 获取当前版本号
|
||||
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return api.GetCurrentVersion()
|
||||
return a.updateAPI.GetCurrentVersion()
|
||||
}
|
||||
|
||||
// GetUpdateConfig 获取更新配置
|
||||
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return api.GetUpdateConfig()
|
||||
return a.updateAPI.GetUpdateConfig()
|
||||
}
|
||||
|
||||
// SetUpdateConfig 设置更新配置
|
||||
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
}
|
||||
|
||||
// DownloadUpdate 下载更新包
|
||||
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return api.DownloadUpdate(downloadURL)
|
||||
return a.updateAPI.DownloadUpdate(downloadURL)
|
||||
}
|
||||
|
||||
// InstallUpdate 安装更新包
|
||||
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return api.InstallUpdate(installerPath, autoRestart)
|
||||
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
|
||||
}
|
||||
|
||||
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// VerifyUpdateFile 验证更新文件哈希值
|
||||
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// startAutoUpdateCheck 启动自动更新检查
|
||||
@@ -767,7 +753,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
||||
|
||||
// GetFileServerURL 获取本地文件服务器的URL
|
||||
func (a *App) GetFileServerURL() string {
|
||||
return "http://localhost:8073"
|
||||
return "http://localhost:18765"
|
||||
}
|
||||
|
||||
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 130 KiB |
@@ -1 +1 @@
|
||||
{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- Markdown 编辑器: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- PDF 导出: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- 窗口置顶 + 收藏夹置顶\n- Excel/Word 文件预览支持\n- 数据库 UI 大幅改进: 查询历史、查询模板、SQL 工具栏、结果导出\n- 数据库可见性过滤与连接管理增强\n\n### 优化 🚀\n- MySQL 动态连接池重构(健康检查、性能权重、自适应扩缩容)\n- SQL 查询优化器(查询缓存、慢查询日志)\n- Redis Pipeline 支持\n- Wails 框架升级 + FileListPanel 重写\n- CSV 编辑模式优化 + 拷贝功能优化\n\n### 修复 🐛\n- Office 类型检测修复、CORS 跨域修复、大文件卡死修复\n\n### 安全修复 🔒\n- XSS 防护、PDF 路径穿越防护、HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化、大规模死代码清理(-1306行)", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 9801728, "sha256": "829c79a91c10277011159749110f4ebee5e3638a078e86850c03b1c9f09e184c", "force_update": false}
|
||||
{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- Markdown 编辑器: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- PDF 导出: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- 窗口置顶 + 收藏夹置顶\n- Excel/Word 文件预览支持\n- 数据库 UI 大幅改进: 查询历史、查询模板、SQL 工具栏、结果导出\n- 数据库可见性过滤与连接管理增强\n\n### 优化 🚀\n- MySQL 动态连接池重构(健康检查、性能权重、自适应扩缩容)\n- SQL 查询优化器(查询缓存、慢查询日志)\n- Redis Pipeline 支持\n- Wails 框架升级 + FileListPanel 重写\n- CSV 编辑模式优化 + 拷贝功能优化\n\n### 修复 🐛\n- Office 类型检测修复、CORS 跨域修复、大文件卡死修复\n\n### 安全修复 🔒\n- XSS 防护、PDF 路径穿越防护、HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化、大规模死代码清理(-1306行)", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 18396672, "sha256": "f0bdf8954b276f4bb45a69336f171bb2a481f7a7125fc3309aae5de2fbf0cf15", "force_update": false}
|
||||
@@ -1 +1 @@
|
||||
{"updated_at": "2026-04-13T23:45:00+08:00", "versions": [{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 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": ""}]}
|
||||
{"updated_at": "2026-04-13T23:45:00+08:00", "versions": [{"version": "0.3.3", "release_date": "2026-04-13", "changelog": "### 新增 ✨\n- **Markdown 编辑器**: 独立编辑页面、实时预览、字符/行数统计、Ctrl+S 保存、自动保存\n- **Markdown 文件页面**: 独立的 Markdown 文件查看与编辑界面\n- **PDF 导出**: 浏览器打印 + 后端 gofpdf/chromedp 多种导出方式\n- **窗口置顶**: 支持窗口始终置顶\n- **收藏夹置顶**: 收藏项支持置顶排序\n- **文件预览**: Excel/Word 文件预览支持\n- **数据库 UI 大幅改进**: 查询历史面板、查询模板面板、SQL 工具栏、结果导出(CSV)、SQL 格式化器\n- **数据库可见性过滤**: 连接管理增强、ConnectionForm 重写、统一错误处理模块\n\n### 优化 🚀\n- MySQL 动态连接池重构 — 健康检查、性能权重、自适应扩缩容\n- SQL 查询优化器 — 查询缓存、慢查询日志 (762 行)\n- Redis Pipeline — 批量命令、事务 MULTI/EXEC 支持\n- Wails 框架升级 + Mermaid 主题切换 + 代码高亮修复\n- FileListPanel 重写 (+511 行) — 删除 FileItemRow,统一列表渲染逻辑\n- CSV 编辑模式优化 + PDF 导出重构\n- 拷贝功能优化 — 新增 ClipboardCopy composable\n\n### 修复 🐛\n- Office 文件预览:修复类型检测与二进制误判\n- 本地文件服务器 CORS 跨域问题\n- 大文件点击卡死问题\n- 收藏夹 bug 修复\n\n### 安全修复 🔒\n- XSS 防护(PdfExportButton、MarkdownPreview HTML 消毒)\n- PDF 导出路径穿越防护\n- PDF 导出标题 HTML 注入防护\n\n### 重构 🔧\n- CodeMirror 架构优化 — 统一导出避免多实例问题\n- 消除代码重复 — storage/connection_service 重构\n- **大规模死代码清理 (-1306 行)**: 删除废弃 storage 层、audit_log、file_lock、recycle_bin、useFileEdit.js(-369行)、useFilePreview.js(-603行) 等\n- 配置加载超时保护、正则表达式预编译、禁止 Ctrl+滚轮缩放", "download_url": "https://c.1216.top/download/u-desk-0.3.3.exe", "file_size": 18396672, "sha256": "f0bdf8954b276f4bb45a69336f171bb2a481f7a7125fc3309aae5de2fbf0cf15"}, {"version": "0.3.2", "release_date": "2026-02-05", "changelog": "### 重构 🔧\n- CodeMirror 架构优化 - 统一导出避免多实例问题\n- 语言加载器优化 - 从动态 import 改为静态导入\n- 动态主题切换 - 使用 Compartment 实现无损切换\n\n### 优化 🚀\n- 编辑器性能 - 添加内容更新防抖\n- 亮色主题 - 改进代码编辑器亮色模式样式", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.3.0", "release_date": "2026-02-04", "changelog": "### 新增 ✨\n- Markdown 图表支持 - Mermaid 流程图、时序图、类图等\n- 代码语法高亮 - 支持 20+ 种常用编程语言\n- 文件列表优化 - 文件夹优先显示,同类型按名称排序", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.2.0", "release_date": "2026-01-28", "changelog": "### 新增 ✨\n- 应用配置管理 - 全新设置面板,支持自定义显示模块和默认启动页\n- 智能更新提醒 - 新增版本更新通知组件\n- 模块重命名 - 应用更名为 u-desk", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.5", "release_date": "2026-01-22", "changelog": "### 新增 ✨\n- 文件管理模块 - 文件浏览、编辑、操作功能\n- 版本更新管理 - 自动检查和下载更新\n- 系统信息查询 - CPU、内存、磁盘等硬件信息", "download_url": "", "file_size": 0, "sha256": ""}, {"version": "0.1.0", "release_date": "2026-01-18", "changelog": "### 新增 ✨\n- 数据库管理 - 支持多种数据库连接和查询功能", "download_url": "", "file_size": 0, "sha256": ""}]}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,44 +0,0 @@
|
||||
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: 53 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 131 KiB |
@@ -136,66 +136,40 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MigrateTabConfig 迁移旧配置(device 移除 + openclaw-manager 重命名)
|
||||
// MigrateTabConfig 迁移旧配置
|
||||
func (api *ConfigAPI) MigrateTabConfig() error {
|
||||
config, _ := api.configService.GetTabConfig()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
needMigrate := false
|
||||
|
||||
// 检查是否包含需要迁移的旧 key
|
||||
// 检查是否包含 device
|
||||
hasDevice := false
|
||||
for _, tab := range config.AvailableTabs {
|
||||
if tab.Key == "device" || tab.Key == "openclaw-manager" {
|
||||
needMigrate = true
|
||||
if tab.Key == "device" {
|
||||
hasDevice = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needMigrate {
|
||||
if !hasDevice {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 映射:旧 key → 新 key(不需要的移除)
|
||||
keyMap := map[string]string{
|
||||
"openclaw-manager": "version",
|
||||
// "device": "" // 直接过滤
|
||||
}
|
||||
|
||||
// 过滤掉 device
|
||||
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
||||
newVisible := make([]string, 0, len(config.VisibleTabs))
|
||||
seenKeys := map[string]bool{}
|
||||
|
||||
for _, tab := range config.AvailableTabs {
|
||||
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 {
|
||||
if tab.Key != "device" {
|
||||
newTabs = append(newTabs, tab)
|
||||
}
|
||||
}
|
||||
for _, key := range config.VisibleTabs {
|
||||
if newKey, ok := keyMap[key]; ok {
|
||||
if newKey != "" && !seenKeys[newKey] {
|
||||
newVisible = append(newVisible, newKey)
|
||||
}
|
||||
// newKey == "" 时跳过(如 device)
|
||||
} else {
|
||||
if key != "device" {
|
||||
newVisible = append(newVisible, key)
|
||||
}
|
||||
}
|
||||
|
||||
defaultTab := config.DefaultTab
|
||||
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
|
||||
defaultTab = newKey
|
||||
}
|
||||
if defaultTab == "device" {
|
||||
defaultTab = "file-system"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -49,35 +48,6 @@ var (
|
||||
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||
var attrRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
// 路径校验 sentinel error(用 errors.Is 匹配,不依赖字符串)
|
||||
var (
|
||||
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
|
||||
ErrPathTraversal = fmt.Errorf("path traversal detected")
|
||||
ErrPathUnsafe = fmt.Errorf("unsafe path")
|
||||
)
|
||||
|
||||
// validateFilePath 校验文件路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
// 返回清理后的绝对路径,或 sentinel error
|
||||
func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
||||
decodedPath, err := url.QueryUnescape(rawPath)
|
||||
if err != nil {
|
||||
return "", ErrPathInvalidEncoding
|
||||
}
|
||||
|
||||
if strings.Contains(decodedPath, "..") {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
|
||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
||||
filePath = filepath.Clean(filePath)
|
||||
|
||||
if !isSafePath(filePath) {
|
||||
return "", ErrPathUnsafe
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
|
||||
type LocalFileServer struct {
|
||||
server *http.Server
|
||||
@@ -105,7 +75,7 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
// 创建服务器(固定端口)
|
||||
server := &http.Server{
|
||||
Addr: "localhost:8073",
|
||||
Addr: "localhost:18765",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
@@ -120,7 +90,7 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
localFileServer = &LocalFileServer{
|
||||
server: server,
|
||||
addr: "localhost:8073",
|
||||
addr: "localhost:18765",
|
||||
}
|
||||
|
||||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
||||
@@ -155,6 +125,7 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
||||
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
|
||||
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||
@@ -162,24 +133,34 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
||||
// 🔒 修复:先进行URL解码,防止路径遍历攻击
|
||||
decodedPath, err := url.QueryUnescape(pathPart)
|
||||
if err != nil {
|
||||
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
|
||||
switch {
|
||||
case errors.Is(err, ErrPathInvalidEncoding):
|
||||
log.Printf("[LocalFileHandler] URL解码失败: %v", err)
|
||||
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("[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)
|
||||
|
||||
// 安全检查
|
||||
if !isSafePath(filePath) {
|
||||
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔒 文件类型白名单检查
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if !isAllowedFileType(ext) {
|
||||
@@ -478,30 +459,32 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
rawPath := r.URL.Query().Get("path")
|
||||
filePath := r.URL.Query().Get("path")
|
||||
var err error
|
||||
if filePath, err = url.QueryUnescape(filePath); err != nil {
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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):
|
||||
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||
|
||||
// 安全检查
|
||||
if !isSafePath(filePath) {
|
||||
log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath)
|
||||
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)
|
||||
// 检查路径遍历攻击
|
||||
if strings.Contains(filePath, "..") {
|
||||
log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath)
|
||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
content, err := os.ReadFile(filePath)
|
||||
|
||||
@@ -44,9 +44,9 @@ var defaultTabConfig = TabConfig{
|
||||
{Key: "file-system", Title: "文件管理", Enabled: true},
|
||||
{Key: "db-cli", Title: "数据库", Enabled: true},
|
||||
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
|
||||
{Key: "version", Title: "版本历史", Enabled: true},
|
||||
{Key: "openclaw-manager", Title: "OpenClaw", Enabled: true},
|
||||
},
|
||||
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "version"},
|
||||
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "openclaw-manager"},
|
||||
DefaultTab: "file-system",
|
||||
}
|
||||
|
||||
|
||||
12
web/package-lock.json
generated
12
web/package-lock.json
generated
@@ -25,7 +25,6 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
@@ -415,17 +414,6 @@
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz",
|
||||
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
|
||||
@@ -1 +1 @@
|
||||
c0e9e27e045c6118704c87fcf34a03de
|
||||
0e1fafcbb6b28922a38f6c5316932015
|
||||
@@ -101,13 +101,12 @@ import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
import {useUpdateStore} from './stores/update'
|
||||
import {useConfigStore, type AppConfig} from './stores/config'
|
||||
import {useConfigStore} from './stores/config'
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
|
||||
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
||||
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移
|
||||
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||
const showSettings = ref(false)
|
||||
@@ -126,7 +125,7 @@ const appConfig = computed(() => configStore.appConfig)
|
||||
const visibleTabs = computed(() => configStore.visibleTabs)
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (config: AppConfig) => {
|
||||
const handleSaveConfig = async (config) => {
|
||||
try {
|
||||
await configStore.saveConfig(config)
|
||||
showSettings.value = false
|
||||
@@ -149,7 +148,7 @@ const loadConfig = async () => {
|
||||
}
|
||||
|
||||
// 获取组件
|
||||
const getComponent = (key: string) => {
|
||||
const getComponent = (key) => {
|
||||
const components = {
|
||||
'file-system': FileSystem,
|
||||
'db-cli': DbCli,
|
||||
@@ -377,9 +376,4 @@ watch(activeTab, (newTab) => {
|
||||
.arco-tooltip {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,25 +3,34 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, onMounted, watch, onBeforeUnmount, computed, nextTick } from 'vue'
|
||||
import {
|
||||
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
|
||||
EditorState, Compartment,
|
||||
defaultKeymap, history,
|
||||
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
|
||||
oneDark,
|
||||
openSearchPanel, search
|
||||
oneDark
|
||||
} from '@/utils/codemirrorExports'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
|
||||
|
||||
// ==================== 主题定义 ====================
|
||||
|
||||
// 亮色主题的基础样式
|
||||
const lightTheme = EditorView.theme({
|
||||
'&': { backgroundColor: '#ffffff' },
|
||||
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
||||
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
||||
'.cm-line': { caretColor: '#000' },
|
||||
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
||||
'.cm-cursor': { borderLeftColor: '#000' }
|
||||
})
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, required: true },
|
||||
fileExtension: { type: String, default: '' },
|
||||
filePath: { type: String, default: '' },
|
||||
fileMtime: { type: String, default: '' }
|
||||
fileExtension: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -32,36 +41,6 @@ const themeStore = useThemeStore()
|
||||
const editorContainer = ref(null)
|
||||
let view = null
|
||||
|
||||
// 滚动位置缓存:LRU 最多 5 份,每份 3 分钟过期
|
||||
const MAX_SCROLL_CACHE = 5
|
||||
const SCROLL_CACHE_TTL = 3 * 60 * 1000 // 3 分钟
|
||||
const fileScrollPositions = new Map() // filePath → { scrollTop, anchor, timestamp }
|
||||
let currentFilePath = ''
|
||||
let saveScrollTimer = null
|
||||
|
||||
// 清理过期缓存 + LRU 淘汰,保持最多 MAX_SCROLL_CACHE 条
|
||||
const cleanScrollCache = () => {
|
||||
const now = Date.now()
|
||||
// 清理过期的
|
||||
for (const [key, val] of fileScrollPositions) {
|
||||
if (now - val.timestamp > SCROLL_CACHE_TTL) {
|
||||
fileScrollPositions.delete(key)
|
||||
}
|
||||
}
|
||||
// LRU:超出上限时删除最旧的
|
||||
if (fileScrollPositions.size > MAX_SCROLL_CACHE) {
|
||||
let oldestKey = null
|
||||
let oldestTime = Infinity
|
||||
for (const [key, val] of fileScrollPositions) {
|
||||
if (val.timestamp < oldestTime) {
|
||||
oldestTime = val.timestamp
|
||||
oldestKey = key
|
||||
}
|
||||
}
|
||||
if (oldestKey) fileScrollPositions.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Compartment 实现动态切换,避免重建编辑器
|
||||
const themeCompartment = new Compartment()
|
||||
const languageCompartment = new Compartment()
|
||||
@@ -108,9 +87,6 @@ const createExtensions = () => {
|
||||
keymap.of(defaultKeymap),
|
||||
bracketMatching(),
|
||||
|
||||
// 查找替换(Ctrl+F / Ctrl+H)
|
||||
search(),
|
||||
|
||||
// 内容更新监听(带防抖)
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
@@ -122,7 +98,7 @@ const createExtensions = () => {
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '13px' },
|
||||
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
||||
'.cm-content': { padding: '8px' },
|
||||
'.cm-content': { padding: '8px', minHeight: '100%' },
|
||||
'.cm-line': { padding: '0 0' },
|
||||
'&.cm-focused': { outline: 'none' }
|
||||
}),
|
||||
@@ -167,12 +143,6 @@ const createEditor = (docContent = '') => {
|
||||
|
||||
view = new EditorView({ state, parent: editorContainer.value })
|
||||
|
||||
// 滚动时防抖保存位置
|
||||
view.scrollDOM.addEventListener('scroll', () => {
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||
}, { passive: true })
|
||||
|
||||
// 初始化语言
|
||||
initLanguage()
|
||||
}
|
||||
@@ -193,10 +163,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (emitTimeout) clearTimeout(emitTimeout)
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
if (view?.scrollDOM) {
|
||||
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
|
||||
if (emitTimeout) {
|
||||
clearTimeout(emitTimeout)
|
||||
}
|
||||
view?.destroy()
|
||||
view = null
|
||||
@@ -204,64 +172,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
// 保存当前文件滚动位置(防抖)
|
||||
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
|
||||
|
||||
// 监听外部内容变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (view && newValue !== view.state.doc.toString()) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' },
|
||||
selection: { anchor: 0 }
|
||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -298,6 +214,6 @@ watch(() => props.fileExtension, () => {
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-content) {
|
||||
/* 不设 height,让 CodeMirror 虚拟滚动自行计算文档高度 */
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -155,8 +155,6 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -220,8 +218,6 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -288,8 +284,6 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -343,8 +337,6 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -438,7 +430,7 @@ const htmlPreviewUrl = computed(() => {
|
||||
return ''
|
||||
}
|
||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||
return `http://localhost:8073/localfs/html-preview?path=${encodedPath}`
|
||||
return `http://localhost:18765/localfs/html-preview?path=${encodedPath}`
|
||||
})
|
||||
|
||||
// 计算属性:判断文件是否在当前目录
|
||||
@@ -783,11 +775,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
||||
// 处理 HTML iframe 发送的消息(链接点击)
|
||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null', // about:blank 或 data: URL
|
||||
'http://localhost:8073', // 本地文件服务器
|
||||
'http://localhost:18765', // 本地文件服务器
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<span class="panel-title">📋 文件列表</span>
|
||||
<div class="panel-header-right">
|
||||
<span class="panel-count">{{ config.fileList.length }} 项</span>
|
||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '150px' }">
|
||||
<a-button size="mini" type="text" class="settings-btn">
|
||||
<icon-more />
|
||||
</a-button>
|
||||
@@ -31,18 +31,6 @@
|
||||
: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>
|
||||
@@ -113,14 +101,6 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 列 key → 排序字段映射
|
||||
const colSortMap: Record<string, string> = {
|
||||
icon: 'type',
|
||||
name: 'name',
|
||||
time: 'modified_time',
|
||||
size: 'size'
|
||||
}
|
||||
|
||||
// ========== 列配置(支持显隐 + 排序) ==========
|
||||
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
|
||||
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
|
||||
@@ -159,7 +139,6 @@ function loadColSettings(): ColumnConfig[] {
|
||||
}
|
||||
|
||||
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
||||
// 默认显示表头(localStorage 无值时兼容旧行为)
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
|
||||
|
||||
// 手动持久化(避免 deep watch 频繁写入)
|
||||
@@ -403,25 +382,6 @@ defineExpose({ focusEditingItem })
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
/* 列项排序图标 */
|
||||
.col-sort-icon {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-4);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.col-sort-icon:hover {
|
||||
background: var(--color-fill-2);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.col-sort-active {
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
.file-list-wrapper {
|
||||
flex: 1;
|
||||
|
||||
@@ -27,22 +27,6 @@
|
||||
</div>
|
||||
<!-- 正常模式:面包屑导航 -->
|
||||
<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
|
||||
:path="config.filePath"
|
||||
@navigate="handleGoToPath"
|
||||
@@ -65,17 +49,45 @@
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<!-- 搜索框 -->
|
||||
<a-input-search
|
||||
:model-value="config.searchKeyword"
|
||||
placeholder="搜索文件..."
|
||||
size="small"
|
||||
class="toolbar-search"
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleSearchInput"
|
||||
@keyup.escape="handleClearSearch"
|
||||
/>
|
||||
<!-- 快捷路径下拉 -->
|
||||
<a-dropdown v-if="!config.isBrowsingZip">
|
||||
<a-button size="small">
|
||||
<template #icon>
|
||||
<icon-forward />
|
||||
</template>
|
||||
快捷访问
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="shortcut in config.commonPaths"
|
||||
:key="shortcut.path"
|
||||
@click="handleGoToPath(shortcut.path)"
|
||||
>
|
||||
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
||||
{{ (shortcut.name || '').substring(2) }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 历史记录下拉 -->
|
||||
<a-dropdown>
|
||||
<a-button size="small">
|
||||
<template #icon>
|
||||
<icon-history />
|
||||
</template>
|
||||
历史
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="path in config.pathHistory.slice(0, 10)"
|
||||
:key="path"
|
||||
@click="handleGoToPath(path)"
|
||||
>
|
||||
{{ path }}
|
||||
</a-doption>
|
||||
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button
|
||||
@@ -89,29 +101,6 @@
|
||||
刷新
|
||||
</a-button>
|
||||
|
||||
<!-- 历史记录下拉(仅图标,Ctrl+H) -->
|
||||
<a-dropdown
|
||||
v-model:popup-visible="historyPopupVisible"
|
||||
>
|
||||
<a-tooltip content="历史记录 (Ctrl+H)" position="left">
|
||||
<a-button size="small">
|
||||
<template #icon><icon-history /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<template #content>
|
||||
<div class="history-dropdown-content">
|
||||
<a-doption
|
||||
v-for="path in config.pathHistory.slice(0, 10)"
|
||||
:key="path"
|
||||
@click="handleGoToPath(path)"
|
||||
>
|
||||
<span class="history-path-text">{{ path }}</span>
|
||||
</a-doption>
|
||||
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
||||
</div>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 切换侧边栏 -->
|
||||
<a-button
|
||||
size="small"
|
||||
@@ -143,7 +132,6 @@ const props = defineProps<Props>()
|
||||
interface Emits {
|
||||
(e: 'update:filePath', path: string): void
|
||||
(e: 'update:showSidebar', show: boolean): void
|
||||
(e: 'update:searchKeyword', keyword: string): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'exitZip'): void
|
||||
(e: 'goToPath', path: string): void
|
||||
@@ -153,9 +141,6 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
|
||||
const historyPopupVisible = ref(false)
|
||||
|
||||
// 事件处理
|
||||
const handleGoToPath = (path: string) => {
|
||||
emit('goToPath', path)
|
||||
@@ -185,28 +170,8 @@ const handleToggleSidebar = () => {
|
||||
emit('update:showSidebar', !props.config.showSidebar)
|
||||
}
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
emit('update:searchKeyword', keyword)
|
||||
}
|
||||
|
||||
const handleSearchInput = (keyword: string) => {
|
||||
emit('update:searchKeyword', keyword)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
emit('update:searchKeyword', '')
|
||||
}
|
||||
|
||||
// 切换历史记录下拉面板(供父组件 Ctrl+H 调用)
|
||||
const toggleHistoryDropdown = () => {
|
||||
historyPopupVisible.value = !historyPopupVisible.value
|
||||
}
|
||||
|
||||
const { copied, copy: copyPath } = useClipboardCopy()
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ toggleHistoryDropdown })
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.filePath)
|
||||
}
|
||||
@@ -237,11 +202,6 @@ const handleCopyPath = async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
@@ -320,19 +280,4 @@ const handleCopyPath = async () => {
|
||||
overflow: hidden;
|
||||
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>
|
||||
|
||||
@@ -13,11 +13,10 @@ import {
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import { useFileOperations } from './useFileOperations'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface UseFileEditOptions {
|
||||
currentFilePath?: import('vue').Ref<FileItem | null>
|
||||
currentDirectory?: import('vue').Ref<string>
|
||||
currentFilePath?: any
|
||||
currentDirectory?: any
|
||||
}
|
||||
|
||||
// 文件大小限制(5MB)
|
||||
@@ -47,6 +46,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||
const fileVersion = ref(0)
|
||||
|
||||
// 最后一次文件加载的时间戳,用于过滤过期更新
|
||||
const lastLoadTime = ref(0)
|
||||
|
||||
// 使用文件操作 composable
|
||||
const { readFile, writeFile } = useFileOperations({
|
||||
onSuccess: (operation, data) => {
|
||||
@@ -79,7 +81,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
||||
*/
|
||||
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
|
||||
const isBinaryFileByExt = (filepath: any): boolean | null => {
|
||||
const path = getFilePath(filepath)
|
||||
const ext = getExt(path)
|
||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||
@@ -183,6 +185,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 增加文件版本号,使之前的过期更新失效
|
||||
fileVersion.value++
|
||||
|
||||
// 记录加载时间戳,用于过滤过期更新
|
||||
lastLoadTime.value = Date.now()
|
||||
|
||||
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
||||
// 新内容加载完成后会直接替换旧内容
|
||||
|
||||
@@ -451,33 +456,6 @@ ${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
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置文件内容
|
||||
*/
|
||||
@@ -523,10 +501,14 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容)
|
||||
* 更新文件内容
|
||||
* 注意:需要确保更新后 fileContent 和 originalContent 保持正确的同步关系
|
||||
*/
|
||||
const updateContent = (content: string, expectedVersion?: number) => {
|
||||
// 如果提供了期望的版本号,检查是否匹配
|
||||
// 这用于防止快速切换文件时,旧文件的防抖更新覆盖新文件的内容
|
||||
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
||||
// 版本不匹配,这是一个过期的更新,忽略它
|
||||
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
||||
expected: expectedVersion,
|
||||
current: fileVersion.value,
|
||||
@@ -535,9 +517,25 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
return
|
||||
}
|
||||
|
||||
// 额外检查:如果更新是在文件加载后的短时间内,可能是过期更新
|
||||
// 防抖时间是 150ms,我们使用 300ms 的安全边际
|
||||
const timeSinceLoad = Date.now() - lastLoadTime.value
|
||||
if (timeSinceLoad < 300) {
|
||||
console.debug('[useFileEdit] 忽略过期更新(时间窗口内):', {
|
||||
timeSinceLoad,
|
||||
content: content.substring(0, 50)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 确保只有在内容真正改变时才更新
|
||||
if (fileContent.value !== content) {
|
||||
fileContent.value = content
|
||||
}
|
||||
|
||||
// 自动保存草稿(防抖)
|
||||
// 实际实现应该使用防抖函数
|
||||
// saveDraft()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -558,6 +556,12 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
return filePath.startsWith(currentDirectory.value)
|
||||
}
|
||||
|
||||
// 监听文件内容变化,自动保存草稿
|
||||
watch(fileContent, () => {
|
||||
// 实际实现应该使用防抖
|
||||
// saveDraft()
|
||||
}, { deep: true })
|
||||
|
||||
// 监听文件路径变化,清除草稿
|
||||
watch(currentFilePath, (newPath, oldPath) => {
|
||||
if (newPath !== oldPath) {
|
||||
@@ -600,7 +604,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
// 其他
|
||||
resetContent,
|
||||
clearContent,
|
||||
updateFilePath,
|
||||
setEditorHeight,
|
||||
|
||||
// 文件类型检查
|
||||
|
||||
@@ -29,17 +29,8 @@ export interface UseFilePreviewOptions {
|
||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
|
||||
// 文件服务器 URL(优先从后端获取,降级到默认值)
|
||||
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(硬编码,与旧版本保持一致)
|
||||
const fileServerURL = 'http://localhost:18765'
|
||||
|
||||
// 预览 URL
|
||||
const previewUrl = ref('')
|
||||
@@ -54,7 +45,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
||||
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}`
|
||||
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +188,12 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
// 文件类型判断(同步,基于扩展名)
|
||||
getFileType,
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
isAudioFile,
|
||||
isPdfFile,
|
||||
isHtmlFile,
|
||||
isMarkdownFile,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div class="file-system-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<Toolbar
|
||||
ref="toolbarRef"
|
||||
:config="toolbarConfig"
|
||||
@update:file-path="handleFilePathUpdate"
|
||||
@update:show-sidebar="handleSidebarToggle"
|
||||
@@ -11,7 +10,6 @@
|
||||
@go-to-path="handleGoToPath"
|
||||
@open-file="handleOpenFile"
|
||||
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
||||
@update:search-keyword="handleSearchKeywordUpdate"
|
||||
@show-message="handleShowMessage"
|
||||
/>
|
||||
|
||||
@@ -154,7 +152,6 @@ const isEditableWithPreview = (filename: string): boolean => {
|
||||
const fileList = ref<FileItem[]>([])
|
||||
const fileLoading = ref(false)
|
||||
const selectedFileItem = ref<FileItem | null>(null)
|
||||
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||
|
||||
// 排序状态(带 localStorage 持久化)
|
||||
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
||||
@@ -190,18 +187,6 @@ const editingFileName = ref('')
|
||||
// 侧边栏
|
||||
const showSidebar = ref(true)
|
||||
|
||||
// 搜索
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 过滤后的文件列表(基于搜索关键词)
|
||||
const filteredFileList = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) return fileList.value
|
||||
return fileList.value.filter(item =>
|
||||
item.name.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
// 面板宽度(带 localStorage 持久化)
|
||||
const restorePanelWidth = (): { left: number; right: number } => {
|
||||
try {
|
||||
@@ -286,7 +271,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
|
||||
})
|
||||
|
||||
// 文件编辑
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
useFileEdit({
|
||||
currentFilePath: selectedFileItem,
|
||||
currentDirectory: filePath
|
||||
@@ -310,8 +295,7 @@ const toolbarConfig = computed(() => ({
|
||||
fileLoading: fileLoading.value,
|
||||
showSidebar: showSidebar.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
searchKeyword: searchKeyword.value
|
||||
sortOrder: sortOrder.value
|
||||
}))
|
||||
|
||||
// 侧边栏配置
|
||||
@@ -323,7 +307,7 @@ const sidebarConfig = computed(() => ({
|
||||
|
||||
// 文件列表面板配置
|
||||
const fileListPanelConfig = computed(() => ({
|
||||
fileList: filteredFileList.value,
|
||||
fileList: fileList.value,
|
||||
fileLoading: fileLoading.value,
|
||||
selectedFileItem: selectedFileItem.value,
|
||||
editingFilePath: editingFilePath.value,
|
||||
@@ -379,8 +363,7 @@ const fileEditorPanelConfig = computed(() => {
|
||||
imageLoading: imageLoading.value,
|
||||
currentImageDimensions: currentImageDimensions.value,
|
||||
currentFileExtension,
|
||||
isBinaryFile: isBinaryFileRef.value,
|
||||
fileMtime: selectedFileItem.value?.modified_time || ''
|
||||
isBinaryFile: isBinaryFileRef.value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -399,10 +382,6 @@ const handleRefresh = async () => {
|
||||
await loadDirectory(filePath.value)
|
||||
}
|
||||
|
||||
const handleSearchKeywordUpdate = (keyword: string) => {
|
||||
searchKeyword.value = keyword
|
||||
}
|
||||
|
||||
const handleGoToPath = async (path: string) => {
|
||||
await navigate(path)
|
||||
}
|
||||
@@ -640,12 +619,24 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 标记是否需要重命名后仅更新路径(内容不变,零闪烁)
|
||||
let needUpdatePath = false
|
||||
// 如果重命名的是当前打开的文件,先关闭编辑器和预览
|
||||
if (selectedFileItem.value?.path === oldPath) {
|
||||
// 如果是文件(不是文件夹),才需要关闭编辑器
|
||||
if (!selectedFileItem.value.isDir) {
|
||||
// 清空编辑器内容
|
||||
await clearContent()
|
||||
|
||||
// 如果重命名的是当前打开的文件
|
||||
if (selectedFileItem.value?.path === oldPath && !selectedFileItem.value.isDir) {
|
||||
needUpdatePath = true
|
||||
// 清空预览URL
|
||||
if (previewUrl.value) {
|
||||
previewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 取消选中状态
|
||||
selectedFileItem.value = null
|
||||
|
||||
// 等待文件句柄释放(文件需要更长时间)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
}
|
||||
|
||||
const renamedFile = await fileOps.rename(oldPath, trimmedName)
|
||||
@@ -659,13 +650,6 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
}
|
||||
|
||||
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
||||
|
||||
// 仅更新路径关联,不重新加载内容(编辑器内容不变,零闪烁)
|
||||
if (needUpdatePath && !renamedFile.isDir) {
|
||||
selectedFileItem.value = renamedFile
|
||||
updateFilePath(newPath)
|
||||
updatePreviewUrl(newPath)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 提取错误信息
|
||||
let errorMsg = error?.message || error?.toString() || '未知错误'
|
||||
@@ -1253,23 +1237,12 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
return
|
||||
}
|
||||
|
||||
// F5 刷新文件列表 + 重载当前预览文件
|
||||
// F5 刷新文件列表
|
||||
if (event.key === 'F5') {
|
||||
event.preventDefault()
|
||||
if (filePath.value) {
|
||||
await loadDirectory(filePath.value)
|
||||
// 如果有正在预览的文件,同时重新加载其内容(类似重新点击一次)
|
||||
if (selectedFileItem.value && !selectedFileItem.value.isDir) {
|
||||
await loadFileContent(selectedFileItem.value.path)
|
||||
loadDirectory(filePath.value)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+H 打开历史记录面板
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'h') {
|
||||
event.preventDefault()
|
||||
toolbarRef.value?.toggleHistoryDropdown?.()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
||||
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -68,8 +66,8 @@ const showUpdateModal = () => {
|
||||
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
||||
content: () => {
|
||||
const elements = [
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('span', { style: { fontSize: '13px', color: 'var(--color-text-2)' } }, '版本:'),
|
||||
h('div', { style: { marginBottom: '12px' } }, [
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '版本:'),
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value),
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
||||
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
||||
@@ -78,23 +76,20 @@ const showUpdateModal = () => {
|
||||
|
||||
// 更新日志
|
||||
if (changelog.value) {
|
||||
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'),
|
||||
h('div', { style: { marginBottom: '12px' } }, [
|
||||
h('div', { style: { fontSize: '13px', color: 'var(--color-text-2)', marginBottom: '8px' } }, '更新内容:'),
|
||||
h('div', {
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-text-2)',
|
||||
lineHeight: '1.6',
|
||||
padding: '10px 12px',
|
||||
lineHeight: '1.8',
|
||||
padding: '12px',
|
||||
background: 'var(--color-fill-1)',
|
||||
borderRadius: '4px',
|
||||
maxHeight: '240px',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
innerHTML: changelogHtml
|
||||
})
|
||||
whiteSpace: 'pre-wrap'
|
||||
}
|
||||
}, changelog.value)
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -109,7 +104,7 @@ const showUpdateModal = () => {
|
||||
}
|
||||
if (metadata.length > 0) {
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||
h('div', { style: { marginBottom: '12px', fontSize: '13px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="changelog-title">
|
||||
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
||||
</div>
|
||||
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" />
|
||||
<div class="changelog">{{ updateInfo.changelog }}</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
@@ -92,8 +92,8 @@
|
||||
:status="downloadStatus"
|
||||
/>
|
||||
<div class="progress-info">
|
||||
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
||||
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span>
|
||||
<span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
|
||||
<span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,8 +118,6 @@ import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { IconHistory } from '@arco-design/web-vue/es/icon'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
|
||||
// Emits
|
||||
defineEmits(['open-version-history'])
|
||||
@@ -136,10 +134,13 @@ const lastCheckTime = ref('-')
|
||||
const installResult = ref(null)
|
||||
const downloadedFile = ref(null)
|
||||
|
||||
/** 渲染 changelog(Markdown → HTML) */
|
||||
function renderChangelog(text: string): string {
|
||||
if (!text) return ''
|
||||
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text }
|
||||
// 工具函数
|
||||
const formatFileSize = (bytes) => {
|
||||
return updateStore.formatFileSize(bytes)
|
||||
}
|
||||
|
||||
const formatSpeed = (bytesPerSecond) => {
|
||||
return updateStore.formatSpeed(bytesPerSecond)
|
||||
}
|
||||
|
||||
// 加载当前版本
|
||||
@@ -282,70 +283,29 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.changelog-section {
|
||||
margin-top: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.changelog-title {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.changelog {
|
||||
background: var(--color-fill-1);
|
||||
padding: 10px 12px;
|
||||
background: var(--color-fill-2);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
max-height: 280px;
|
||||
white-space: pre-wrap;
|
||||
margin: 8px 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.changelog :deep(h4) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin: 8px 0 3px;
|
||||
}
|
||||
|
||||
.changelog :deep(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.changelog :deep(ul) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.changelog :deep(li) {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.changelog :deep(li::before) {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
color: var(--color-text-4);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.changelog :deep(code) {
|
||||
background: var(--color-fill-3);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.changelog :deep(p) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.download-progress {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -15,7 +15,7 @@ interface TabConfig {
|
||||
/**
|
||||
* 应用配置类型
|
||||
*/
|
||||
export interface AppConfig {
|
||||
interface AppConfig {
|
||||
tabs: TabConfig[]
|
||||
visibleTabs: string[]
|
||||
defaultTab: string
|
||||
|
||||
@@ -115,8 +115,6 @@ export interface ToolbarConfig {
|
||||
sortBy: string
|
||||
/** 排序方向 */
|
||||
sortOrder: string
|
||||
/** 搜索关键词 */
|
||||
searchKeyword: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,8 +199,6 @@ export interface FileEditorPanelConfig {
|
||||
currentFileExtension: string
|
||||
/** 是否为二进制文件 */
|
||||
isBinaryFile: boolean
|
||||
/** 文件修改时间(用于检测外部变更) */
|
||||
fileMtime: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,4 @@ export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
export { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
// 查找替换
|
||||
export { openSearchPanel, closeSearchPanel, search, searchKeymap, SearchQuery } from '@codemirror/search'
|
||||
|
||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||
|
||||
@@ -38,21 +38,6 @@ export const escapeHtml = (str) => {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量 HTML 消毒(用于渲染远程 Markdown 等不可信 HTML 片段)
|
||||
* 移除 script/iframe/object/embed 标签和 on* 事件属性
|
||||
*/
|
||||
export const sanitizeHtml = (html) => {
|
||||
if (!html) return ''
|
||||
return String(html)
|
||||
.replace(/<script\b[^<]*(?:<\/script>|$)/gi, '')
|
||||
.replace(/<iframe\b[^<]*(?:<\/iframe>|$)/gi, '')
|
||||
.replace(/<object\b[^<]*(?:<\/object>|$)/gi, '')
|
||||
.replace(/<embed\b[^>]*\/?>/gi, '')
|
||||
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||
.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名(路径安全)
|
||||
* @param {string} path - 文件路径
|
||||
|
||||
Reference in New Issue
Block a user