23 Commits
Author SHA1 Message Date
lxy 066ac2b339 修复: 连接地址拼接兼容IPv6主机
JoinHostPort替换fmt拼接,IPv6裸地址缺方括号导致拨号失败
2026-10-03 10:18:31 +08:00
lxy c7978a5bfd 修复: 版本更新与设置面板走查问题
- 更新链路: 检查记录远端SHA256,下载完成事件回传,安装时带哈希校验
- 续传: 服务器忽略Range返回200时截断重下防追加损坏,文件名去query
- 定时器: 自动检查可重入,配置变更免重启生效,关闭即停,下载加互斥
- 前端: 支持跳过此版本并按版本去重,手动检查不受门禁限制,启动未就绪延时重试
- 更新面板: 新增自动检查开关/间隔/地址配置与进度条状态映射
- 版本历史: cache参数强制刷新与加载超时重试兜底
- 配置: 侧边栏分区全链路持久化,保存请求复用api类型防字段丢失
- 侧边栏: 区块定位改显式类名,不再依赖排序位置
- 绑定: 重新生成并与镜像目录同步
2026-10-03 10:18:27 +08:00
lxy 81e245eade 更新: 站点子仓指针至 0.5.0 版 2026-10-01 20:28:40 +08:00
lxy 37cbc39154 更新: 版本号 0.4.2 → 0.5.0 2026-10-01 20:28:40 +08:00
lxy e28ae653b0 修复: webview尺寸收窄卡住与兜底失效闪退
- 窗口尺寸变化后webview收窄卡住根修
- 尺寸兜底失效致闪退与修复空转加固
- 跨服务器预览残留清理
2026-10-01 01:02:39 +08:00
lxy b9c0786c02 新增: 资源管理器文件粘贴到文件列表
- 后端 ReadClipboardFiles 读系统剪贴板 CF_HDROP 拿路径列表
- Ctrl+V 优先粘贴系统剪贴板文件,复用拖入导入链路(SFTP 上传/本机复制/目录递归)
- 补齐设计文档记录的历史缺口(Wails 限制未做)
2026-09-29 18:47:09 +08:00
lxy 4746f8cac2 优化: 收藏置顶项背景加重并加服务器一键全连
- 置顶项背景改用 --color-primary-light-1 对齐服务器列表焦点色
- 服务器管理菜单新增一键全连,全连后恢复原焦点
- 修复连 B 失败后焦点回落 A 状态点误染红(改用 per-profile 状态)
2026-09-28 14:42:22 +08:00
lxy b488354369 新增: 服务器列表右键菜单
- 右键弹出连接编辑删除,复用既有操作链路
- 本地项仅连接,远程项含编辑删除
- 与三点菜单和设置面板互斥展开
2026-09-28 13:53:37 +08:00
lxy 6c59f81b44 优化: 收藏栏宽度可调与操作菜单收敛
- 侧边栏宽度拖拽调整180-420并持久化
- 收藏操作收敛为三点菜单加右键菜单,移除悬停按钮组
- 右键与三点菜单互斥,打开其一收起另一
- 置顶项背景改清晰浅蓝
2026-09-28 13:18:16 +08:00
lxy 15dedfc563 新增: 收藏展示名自定义与列表默认展开
- 收藏项支持重命名,displayName空值回退原名
- 悬停提示改两行,路径加来源连接名区分同名收藏
- 收藏夹区块默认展开
2026-09-28 12:18:55 +08:00
lxy e8ccb4280b 优化: PDF导出成功后打开所在位置选中文件
- 导出落盘后explorer选中PDF,不再只靠一闪而过的弹窗
- 成功文案明确指向导出位置
- explorer用WINDIR绝对路径,不依赖PATH
2026-09-28 09:33:53 +08:00
lxy fa3c3234b9 修复: Markdown导出file URL转义并加导出诊断插桩
- Markdown通道file URL整体转义,中文空格临时路径可导航
- bindings同步删除已清理的目录选择死代码
- 导出PDF链路插桩日志落.pdf-export.log,临时诊断定位后移除
2026-09-28 09:17:04 +08:00
lxy 2002bb8cca 修复: PDF导出浏览器探测补全与链路加固
- 浏览器探测补按用户安装路径LOCALAPPDATA
- fs-agent远程HTML导出先拉取到本地临时目录
- Markdown通道空结果守卫,杜绝0字节PDF误报成功
- 清理死参数与无调用死代码
2026-09-27 11:13:50 +08:00
lxy e87ffe1d0c 新增: HTML预览导出PDF并兼容Edge环境
- chromedp 通道新增源文件入口,file:// 加载原始HTML
- 相对资源在原目录天然解析,printBackground 保背景色
- 预览工具栏新增导出按钮,默认文件名取当前HTML名
- 远程文件复用站点缓存路径,本地远程同一命令
- 浏览器探测扩Chrome+Edge双查,Markdown通道重复探测归一
2026-09-17 16:24:46 +08:00
lxy 54b3f03d1f 更新: 站点子仓指针至 0.4.2 版 2026-09-16 00:13:20 +08:00
lxy 1db2b8ff70 更新: 版本号 0.4.1 → 0.4.2
- CHANGELOG 与内部日志补 0.4.2 修复段
- 功能清单与站点版本引用同步
- bindings 随 MovePath 注释再生成
2026-09-16 00:12:54 +08:00
lxy 56fe0d9b82 修复: 文件系统初始化守卫与传输收尾等七处
- filesystemSvc 守卫17绑定方法,异步初始化完成前返回明确错误
- MovePath 跨盘剪切建 move emitter,失败清理目标盘残留
- SFTP 空目录下载/上传补 MkdirAll 落地
- drawio 下载失败等待者立即返回,不再空轮询30分钟
- batch 高亮补 %1 与 %%i 规则
- 错误文案转换抽 errMessage 共享函数,11处归一
- log.Printf 统一 slog.Warn
2026-09-15 23:49:01 +08:00
lxy d211365856 修复: 连接回退池校验与进度重试累计等四处
- connect 失败回退目标校验仍在池中,防幻影本地列表
- SFTP 下载/上传进度跨重试累计,重试入口重置基线
- OSS 签名 base64 改标准字母表,URL 字母表致校验必败
- 剪切粘贴同目录 no-op 清剪贴板,防源文件被改名副本
2026-09-15 23:26:06 +08:00
lxy 0475d55bff 重构: 连接切换链收敛与代码清理
- 切换连接统一switchConnection入口,删watcher与抑制机制
- 删除活跃服务器后回落本地并加载
- 侧边栏与指示器点击统一上抛,重复点击可刷新
- Wails事件解析归一,查重谓词抽公共
- 删空壳别名文件与过期文档截图
2026-09-15 22:26:36 +08:00
lxy 6ea9d9ac99 修复: SFTP连接首拉不显示与传输挂起
- watcher握手期伪触发致幻影本地列表,根因修复
- 连接探测2.5s超时竞速,握手10s预算
- 30s保活自动掐死半开连接,取连接时剔除已断开
- 副本命名与进度计数抽公共,下载缓存清理去重
2026-09-15 22:26:22 +08:00
lxy e7a2c5148e 新增: 文件操作增强与预览扩展
- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
2026-09-15 22:26:12 +08:00
lxy f1bc5b49f1 更新: 版本号 0.4.0 → 0.4.1 2026-05-16 20:46:14 +08:00
lxy 767311fc04 重构: App拆分10域文件+并发安全+结构化日志+依赖升级 2026-05-16 20:45:14 +08:00
129 changed files with 7446 additions and 4108 deletions
+81 -2
View File
@@ -2,9 +2,88 @@
> 本文档记录所有技术细节,包括代码重构、构建优化等内部改动
## [0.5.0] - 2026-05-01 (fs-only-v3)
## [0.5.0] - 2026-10-01
### Wails v3 迁移 🏗️
- **webview 尺寸兜底** (app_window_fix.go): wails alpha.92 WM_SIZE 竞态(resizePending 丢消息+过期 bounds 重放)致 webview 停在中间尺寸;WndProcInterceptor 挂 WM_SIZE 校验 + 3s 周期兜底双路,GetClientRect/SetWindowPos 直铺 Chrome_WidgetWin_0。坑:user32 只导出无后缀名(带 W 的 proc 找不到会 panic);EnumChildWindows 回调须包级变量传目标(param 传栈地址恒不匹配);windowsgui 下 goroutine panic 静默杀进程须 recover
- **剪贴板文件粘贴** (app_clipboard.go): CF_HDROP(15) + LockOSThread + OpenClipboard 重试 20×10ms,DragQueryFileW 解析路径后复用拖入链路 handleExternalDrop
- **预览联动**: switchConnection/handleGoToPath 后 syncPreviewToActiveProfile 按 profileId 过滤预览 Tab,跨服务器切换不再残留他机预览
- **PDF 导出**: HTML 预览导出 PDF,成功后 explorer /select 定位文件;插桩日志 ~/.u-desk/logs/pdf-export.log 为临时诊断,后续可清理
- **收藏栏**: 宽度拖拽 180-420 持久化、展示名自定义、置顶浅蓝(--arcoblue-1)、双菜单互斥;Sidebar stateDotClass 改 per-profile getProfileState(全局 state 不带 profile 维度会误染红)
- **一键全连**: connectAllServers 用 Promise.allSettled 并发连接可见非 local 项,完成后恢复原焦点
- **绑定坑**: wails3 generate bindings 必须带 -clean=true -ts(否则 app.ts 被覆盖为 .js)
---
## [0.4.2] - 2026-09-15
- **初始化守卫**: 17 个 Wails 绑定方法加未就绪守卫,启动瞬间操作返回明确提示
- **MovePath**: 跨盘剪切改 emitter 上报传输进度,失败自动清理残留文件
- **SFTP 空目录**: 下载/上传目标 MkdirAll,空目录正确创建不丢失
- **drawio 预览**: 首次下载失败时唤醒全部等待者,立即提示
- **重试进度**: 网络重试后进度基线重置,失败条目显示错误状态
- **连接回退**: 回退池校验,连接失败不回落本地磁盘列表
- **OSS 签名**: base64 StdEncoding 兼容性修复
- **errMessage 归一**: 错误消息统一提取
- **slog 统一**: 日志输出统一 log/slog
---
## [0.4.1] - 2026-05-01 ~ 05-16 (fs-only-v3)
---
### 2026-05-16 增量更新
#### App 结构体拆分 🏗️
app.go 从 1281 行拆为 10 个域文件:
| 文件 | 方法数 | 职责 |
|------|--------|------|
| `app.go` | 5 | 结构体定义、生命周期、请求类型 |
| `app_config.go` | 12 | 配置管理、BGM、Profile CRUD |
| `app_filesystem.go` | 23 | 文件操作、回收站、文件监听 |
| `app_sftp.go` | 17 | SFTP 连接管理 |
| `app_oss.go` | 17 | OSS 对象存储 |
| `app_window.go` | 8 | 窗口控制 |
| `app_system.go` | 7 | 系统信息 |
| `app_update.go` | 11 | 版本更新管理 |
| `app_pdf.go` | 2 | PDF 导出 |
| `app_windows.go` | 4 | Windows 平台专用(`//go:build windows`) |
#### 结构化日志 📝
`fmt.Print*` → `log/slog`(45 处):app.go、filesystem/service.go、storage/download_cache.go
#### Bug 修复 🐛
- **预览状态级联失败**: `clearContent()` 未重置 `isBinaryFile`,模板 `v-if` 链被阻断 → 加 `isBinaryFile.value = false`
- **Tab 切换状态不同步**: `activateTab` 未更新 `filePath` → 加 `getParentPath` + `loadDirectory`
- **AsyncCodeEditor 永久死亡**: `defineAsyncComponent` 无 `onError` → 添加重试(限 2 次)
- **FileLock 误创建文件**: `O_RDWR|O_CREAT` → `O_RDWR`,`mode 0666` → `0`
- **OSS 目录重命名**: `c.Delete()` 返回值未检查,目录标记删除添加容错
#### 并发安全 🔒
- **RecycleBin**: 所有公开方法添加 `sync.Mutex`,`ListEntries()` 返回深拷贝副本
#### 缓存优化 📦
- **contentDetectCache**: 添加 200 条上限,超限全量清空
#### 依赖升级 ⬆️
Wails alpha.80→alpha.92, echo v4.15.1→v4.15.2, go-sqlite v1.22, modernc/sqlite v1.50, go-git v5.19
#### 代码审查 (15/17 修复)
🔴 构建标签 · 死参数 · 重复逻辑 · 并发锁 | 🟡 错误处理 · 方法归属 · 日志 | ⚪ 注释 · 去重
**暂不处理**: ⚪⑬ fallback 路径 · ⚪⑯ ensure 风格 · ⚪⑰ ensure 模式
---
### 2026-05-01 Wails v3 迁移
#### 框架升级
- **Wails v2.12 → v3 alpha.80**: 全面迁移至 Wails v3 架构
+64 -29
View File
@@ -1,40 +1,75 @@
# 更新日志
## [0.5.0] - 2026-05-05 (fs-only-v3)
## [0.5.0] - 2026-10-01
### 新增 ✨
- **云 OSS 存储**: 七牛云/阿里云双厂商支持,AK/SK 认证连接
- **多桶导航**: 根目录自动列出所有桶,点进桶浏览文件,桶级客户端懒创建+缓存
- **OSS 全功能 CRUD**: 列目录/读文件/写文件/创建/删除/重命名/预签名URL
- **GBK 编码自动转换**: 文件预览智能检测编码(UTF-8/GBK),解决 LRC 等中文文件乱码
- **桶图标 🪣**: OSS 桶与普通文件夹图标区分
- **连接对话框 OSS 分类**: 「云OSS」父分类 + 厂商子选择(七牛云/阿里云)
- **Sidebar 折叠架构**: 收藏夹和帮助文档独立区块,各自支持折叠/展开
- **帮助文档区块**: 静态快捷键参考面板,默认展示
- **收藏夹内部滚动**: 收藏内容多时列表区域独立滚动,帮助区块固定底部不溢出
- **预览导出 PDF**: HTML/Markdown 预览一键导出为 PDF,成功后自动打开所在文件夹并选中文件
- **剪贴板粘贴文件**: 资源管理器复制的文件/文件夹可直接粘贴进文件列表(本机=复制,SFTP=上传)
- **收藏展示名自定义**: 收藏项可自定义显示名称,收藏列表默认展开
- **服务器右键菜单**: 服务器列表支持右键菜单,连接/管理操作直达
- **一键全连**: 服务器管理菜单新增"一键全连",并发连接全部服务器
### 重构 🔧
- **Wails v3 迁移**: 从 Wails v2 升级至 v3 alpha.80,全面重构项目架构
- **前端目录规范化**: `web/` → `frontend/`,对齐 Wails v3 标准目录结构
- **跨平台构建配置**: 新增 Android/iOS/Linux/macOS/Docker 构建模板(Taskfile.yml)
- **v3 Bindings**: 自动生成的 TypeScript 绑定替代手动维护的 wailsjs
### 优化 🚀
- **收藏置顶项**: 置顶背景加深为清晰浅蓝,一眼区分置顶与普通收藏
- **收藏栏宽度可调**: 拖拽调整侧边栏收藏区宽度并记忆
### 修复 🐛
- **MP3 误报加载失败**: 音频 @canplay 清除错误状态 + previewUrl watcher 重置
- **启动路径恢复错误**: 本地模式跳过 Linux/OSS 路径残留,避免 `open /bucket` 报错
- **阿里云签名修复**: ListFiles 签名不含 list 查询参数(prefix/delimiter/marker/max-keys 非子资源)
- **阿里云 XML 解析**: `<Contents>` 直接映射文件字段,修正 `Contents.Object` 嵌套错误
- **阿里云 LastModified**: 宽容时间解析(4 种格式兼容)
- **临时文件白名单放行**: OSS/SFTP 预览文件绕过文件类型限制
- **custom.js 404**: AssetOptions Middleware 拦截返回空响应,消除控制台报错
- **longPressTimer TypeError**: `const` → `let` 修复重复赋值错误
- **Arco Tabs padding**: 覆盖默认 16px padding-top
- **DevTools 可用性**: production 构建带 devtools tag + 延迟 OpenDevTools() 调用
- **窗口内容收窄**: 切换服务器/目录时偶发的内容区收窄卡住不再出现,自动恢复满宽
- **Markdown 导出**: file URL 转义修正,含空格/特殊字符路径导出正常
- **PDF 导出稳定性**: 浏览器探测补全,导出失败立即提示不再无响应
### 变更说明
- 分支: `feature/fs-only` → `fs-only-v3`
- 入口: main.go 新增 Middleware 中间件模式
- build/config.yml executes 流水线对齐官方模板(once → background → blocking → primary)
---
## [0.4.2] - 2026-09-15
### 修复 🐛
- **启动初始化**: 启动瞬间操作文件不再无响应,未就绪时给出明确提示
- **跨盘剪切**: 大目录移动显示传输进度,失败自动清理残留文件
- **SFTP 空目录**: 空目录下载/上传后正确创建,不再静默丢失
- **drawio 预览**: 首次下载失败后立即提示,不再长时间无响应
- **进度可靠性**: 网络重试后进度不再虚跳,失败条目正确显示错误状态
- **连接回退**: 连接失败后不再错误回落到本地磁盘列表
- **编码细节**: OSS 签名兼容性修复,batch 脚本 %1/%%i 变量高亮
---
## [0.4.1] - 2026-05-16 (fs-only-v3)
### 新增 ✨
- **云 OSS 存储**: 七牛云/阿里云双厂商,AK/SK 认证,桶级客户端懒创建+缓存
- **OSS 全功能 CRUD**: 列目录/读文件/写文件/创建/删除/重命名/预签名 URL
- **OSS 区域自动嗅探**: 连接时自动探测桶区域,无需手动填写
- **七牛云桶管理**: 查询桶域名、真实区域、设置访问权限(公开/私有)
- **HTML 网站预览**: OSS/SFTP 上的 HTML 文件自动下载关联资源(JS/CSS/图片),逐级嗅探网站根目录,完整预览静态站点
- **BGM 音乐播放器**: 编辑器底部控制条,支持播放/暂停/上下首/进度拖拽/四种播放模式(单曲/顺序/随机/循环),播放列表持久化,失败自动跳过
- **LRC 歌词同步显示**: 播放时自动查找同名 .lrc 文件,实时高亮滚动歌词,点击歌词行跳转
- **全局热键**: Ctrl+Shift+B 系统级快捷键,应用最小化时快速唤起
- **文件变更监听**: 文件外部被修改时自动刷新,避免编辑冲突
- **下载缓存**: 远程文件预览缓存到本地 SQLite(24 小时过期),重启后仍有效
- **远程服务器监控**: 侧边栏显示磁盘/CPU/内存使用率,超 90% 红色警告,15 秒自动刷新
- **连接指示器快速导航**: 悬停弹出根目录子文件夹列表,可递归展开,快速跳转
- **侧边栏自定义**: 设置面板支持服务器/收藏夹/帮助区块拖拽排序和显隐控制
### 优化 🚀
- **侧边栏折叠架构**: 服务器、收藏夹、帮助独立可折叠区块,收藏夹内部独立滚动
- **七牛云目录层级**: ListFiles 支持 delimiter,正确展示文件夹层级而非平铺
- **七牛云签名 URL**: 修正私有空间下载/预览签名格式
- **七牛云下载域名**: 智能解析策略:临时域名→自定义域名→CDN 兜底,失败自动重试
- **GBK 编码自动转换**: 文件预览智能检测编码(UTF-8/GBK),中文文件不再乱码
- **SFTP 功能对齐**: 补全粘贴图片、网站预览、系统监控、常用路径等能力
- **预览缓存迁移**: 前端 LRU → 后端 SQLite,重启后缓存仍有效
- **Wails v3 迁移**: 框架升级至 v3 alpha.92,核心依赖全面更新
### 修复 🐛
- **二进制文件后预览失效**: 打开二进制文件后图片/视频无法预览
- **跨服务器 Tab 切换**: 面包屑和文件列表未同步更新
- **代码编辑器加载失败**: 异步组件失败后自动重试
- **OSS 收藏打开失败**: provider 字段映射修正
- **阿里云 OSS 签名/解析**: 文件列表获取失败、时间格式不兼容等问题
- **启动路径错误**: 本地模式跳过无效的 OSS 路径残留
- **OSS 目录重命名**: 单文件失败不中断整体操作
- **文件锁定检查**: 不再误创建空文件
- **长期运行内存增长**: 文件类型检测缓存添加上限
---
+35 -30
View File
@@ -4,52 +4,57 @@
## 功能
- 文件浏览 / 编辑 / 预览(文本、Markdown、图片、Office、PDF)
- 收藏夹管理(折叠/展开、拖拽排序、置顶)
- Markdown 编辑器(实时预览、语法高亮、Mermaid 图表)
- 远程文件服务器连接
- 主题切换(亮色/暗色)
- 版本更新检查
- **本地文件管理**: 浏览、编辑、重命名、删除、回收站
- **SFTP 远程连接**: SSH 密钥/密码认证,文件上传下载
- **云 OSS 存储**: 七牛云/阿里云,多桶浏览
- **多格式预览**: 图片/视频/音频/PDF/Office/HTML
- **Markdown 编辑器**: 实时预览、Mermaid 图表、语法高亮
- **收藏夹管理**: 折叠/展开、拖拽排序、置顶
- **BGM 播放**: 音频播放列表、全局热键 Ctrl+Shift+B
- **PDF 导出**: Markdown/HTML 导出为 PDF
- **版本更新检查**: 自动检测新版本
- **主题切换**: 亮色/暗色
## 技术栈
| 层 | 技术 |
|---|------|
| 桌面框架 | Wails v3 (alpha.80) |
| 后端 | Go 1.22+ |
| 桌面框架 | Wails v3 (alpha.82) |
| 后端 | Go 1.24 |
| 前端 | Vue 3 + TypeScript |
| UI 组件库 | Arco Design Vue |
| 编辑器 | CodeMirror 6 |
| 构建 | Vite 7 + Taskfile |
| 数据库 | SQLite (配置/历史) |
| 文件服务 | 内置 HTTP 文件服务器 (端口 2652) |
## 项目结构
```
├── main.go # 入口:窗口配置、中间件、DevTools
├── app.go # 应用逻辑:文件系统、更新检查等
├── internal/ # 内部模块
│ ├── filesystem/ # 文件操作、锁、预览服务
│ └── api/ # API 处理器
├── frontend/ # 前端代码 (Vue 3)
│ ├── src/
│ │ ├── components/FileSystem/ # 文件管理主组件
│ │ ├── stores/ # Pinia 状态管理
│ │ ├── api/ # 后端调用封装
│ │ └── utils/ # 工具函数
│ └── vite.config.js
├── main.go # 入口:窗口配置、中间件
├── app.go # App 结构体定义、生命周期、请求类型
├── app_*.go # 按功能域拆分(config/sftp/oss/...)
├── internal/
│ ├── api/ # Wails 绑定 API 层
│ ├── filesystem/ # 文件系统服务 + HTTP 文件服务器
│ ├── ossdrv/ # OSS 驱动(七牛/阿里云)
│ ├── sftp/ # SFTP 客户端
│ ├── storage/ # SQLite 存储
│ └── service/ # 业务服务
├── frontend/
│ └── src/
│ ├── components/FileSystem/ # 文件管理主组件
│ ├── api/ # 后端调用封装
│ ├── composables/ # Vue Composables
│ └── stores/ # Pinia 状态管理
├── build/ # 构建配置(跨平台)
│ ├── config.yml # Wails 项目配置
│ └── windows/ # Windows 构建脚本
└── configs/ # 运行时配置
```
## 开发
```bash
# 安装依赖
wails3 task common:install:frontend:deps
# 启动开发模式(热重载)
# 启动开发模式
wails3 dev
# 生产构建
@@ -59,16 +64,16 @@ wails3 build
### 构建标签
- `production` — 生产模式,使用嵌入的 frontend dist
- `devtools` — 在生产构建中保留 DevTools(F12)
- `devtools` — 在生产构建中保留 DevTools
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| Ctrl+B | 切换侧边栏 |
| Ctrl+H | 历史记录 |
| Ctrl+F | 聚焦搜索 |
| Ctrl+Shift+B | BGM 播放栏 |
| F11 | 全屏预览 |
## 版本历史
详见 [CHANGELOG.md](./CHANGELOG.md)
详见 [CHANGELOG.md](./CHANGELOG.md) | 内部技术细节见 [CHANGELOG.internal.md](./CHANGELOG.internal.md)
+77 -1058
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
//go:build windows
package main
import (
"runtime"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
var (
procIsClipboardFormatAvailable = windows.NewLazySystemDLL("user32.dll").NewProc("IsClipboardFormatAvailable")
procOpenClipboard = windows.NewLazySystemDLL("user32.dll").NewProc("OpenClipboard")
procCloseClipboard = windows.NewLazySystemDLL("user32.dll").NewProc("CloseClipboard")
procGetClipboardData = windows.NewLazySystemDLL("user32.dll").NewProc("GetClipboardData")
procDragQueryFileW = windows.NewLazySystemDLL("shell32.dll").NewProc("DragQueryFileW")
)
// cfHDrop 剪贴板文件列表格式(资源管理器复制文件/文件夹),Win32 稳定 ABI 值
const cfHDrop = 15
// ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
// 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
func (a *App) ReadClipboardFiles() ([]string, error) {
// OpenClipboard 与 CloseClipboard 必须同线程,否则剪贴板死锁
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if r, _, _ := procIsClipboardFormatAvailable.Call(cfHDrop); r == 0 {
return nil, nil
}
// 剪贴板是全局互斥资源,短重试避开其他进程的瞬时占用
opened := false
for i := 0; i < 20; i++ {
if r, _, _ := procOpenClipboard.Call(0); r != 0 {
opened = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !opened {
return nil, syscall.EBUSY
}
defer procCloseClipboard.Call()
h, _, _ := procGetClipboardData.Call(cfHDrop)
if h == 0 {
return nil, nil
}
count, _, _ := procDragQueryFileW.Call(h, 0xFFFFFFFF, 0, 0)
if count == 0 {
return nil, nil
}
paths := make([]string, 0, count)
for i := uintptr(0); i < count; i++ {
n, _, _ := procDragQueryFileW.Call(h, i, 0, 0)
if n == 0 {
continue
}
buf := make([]uint16, n+1)
procDragQueryFileW.Call(h, i, uintptr(unsafe.Pointer(&buf[0])), n+1)
paths = append(paths, syscall.UTF16ToString(buf))
}
return paths, nil
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"time"
"u-desk/internal/api"
"u-desk/internal/common"
"u-desk/internal/service"
"u-desk/internal/storage"
"u-desk/internal/storage/models"
"gorm.io/gorm"
)
// ========== 应用配置接口 ==========
// GetAppConfig 获取应用配置
func (a *App) GetAppConfig() (map[string]interface{}, error) {
if a.configAPI == nil {
return nil, fmt.Errorf("配置服务正在初始化中")
}
return a.configAPI.GetAppConfig()
}
// SaveAppConfig 保存应用配置
// 直接复用 api.SaveAppConfigRequest,避免 main 包副本转发时丢字段
func (a *App) SaveAppConfig(req api.SaveAppConfigRequest) (map[string]interface{}, error) {
if a.configAPI == nil {
return nil, fmt.Errorf("配置服务正在初始化中")
}
oldConfig, _ := a.configAPI.GetAppConfig()
var oldVisibleTabs []string
if success, ok := oldConfig["success"].(bool); ok && success {
if data, ok := oldConfig["data"].(map[string]interface{}); ok {
if vtInterface, ok := data["visibleTabs"].([]interface{}); ok {
oldVisibleTabs = common.InterfaceSliceToStringSlice(vtInterface)
}
}
}
result, err := a.configAPI.SaveAppConfig(req)
if err != nil {
return result, err
}
if success, ok := result["success"].(bool); ok && success {
a.handleNewlyEnabledModules(oldVisibleTabs, req.VisibleTabs)
}
return result, nil
}
func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
list, err := a.ensureProfileSvc().ListProfiles()
if err != nil {
return nil, err
}
var result []map[string]interface{}
data, _ := json.Marshal(list)
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("解析连接配置失败: %w", err)
}
return result, nil
}
func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interface{}, error) {
p := &models.ConnectionProfile{
Name: req.Name, Host: req.Host, Port: req.Port,
Username: req.Username, Password: req.Password,
KeyPath: req.KeyPath, Type: req.Type, Provider: req.Provider, Token: req.Token,
AccessKey: req.AccessKey, SecretKey: req.SecretKey,
Bucket: req.Bucket, Region: req.Region, Endpoint: req.Endpoint,
}
if req.LastConnected != nil {
t := time.Unix(*req.LastConnected, 0)
p.LastConnected = &t
}
if req.ID != nil {
p.ID = *req.ID
}
if err := a.ensureProfileSvc().SaveProfile(p); err != nil {
return nil, err
}
return map[string]interface{}{"id": float64(p.ID), "success": true}, nil
}
func (a *App) DeleteConnectionProfile(id uint) error {
return a.ensureProfileSvc().DeleteProfile(id)
}
// BgmGetPlaylist 获取播放列表
func (a *App) BgmGetPlaylist() ([]BgmPlaylistItem, error) {
db := storage.GetDB()
if db == nil {
return nil, fmt.Errorf("数据库未初始化")
}
var rows []models.BgmPlaylist
if err := db.Order("sort ASC").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("查询播放列表失败: %w", err)
}
items := make([]BgmPlaylistItem, len(rows))
for i, r := range rows {
items[i] = BgmPlaylistItem{Name: r.Name, Path: r.Path, ProfileID: r.ProfileID}
}
return items, nil
}
// BgmSavePlaylist 全量保存播放列表(前端调用时传完整列表)
func (a *App) BgmSavePlaylist(items []BgmPlaylistItem) error {
db := storage.GetDB()
if db == nil {
return fmt.Errorf("数据库未初始化")
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("DELETE FROM bgm_playlist").Error; err != nil {
return err
}
sort := uint(0)
for _, item := range items {
if item.Name == "" || item.Path == "" {
continue
}
if err := tx.Create(&models.BgmPlaylist{Name: item.Name, Path: item.Path, ProfileID: item.ProfileID, Sort: sort}).Error; err != nil {
return err
}
sort++
}
return nil
})
}
func (a *App) ensureProfileSvc() *service.ProfileService {
a.mu.Lock()
defer a.mu.Unlock()
if a.profileSvc == nil {
a.profileSvc = service.NewProfileService()
}
return a.profileSvc
}
// getVisibleTabs 获取配置中的可见 Tabs
func (a *App) getVisibleTabs() []string {
config, err := a.configAPI.GetAppConfig()
if err != nil {
slog.Warn("读取配置失败,使用默认配置", "error", err)
return common.DefaultVisibleTabs
}
success, ok := config["success"].(bool)
if !ok || !success {
slog.Warn("配置读取失败,使用默认配置")
return common.DefaultVisibleTabs
}
data, ok := config["data"].(map[string]interface{})
if !ok {
return common.DefaultVisibleTabs
}
visibleTabsInterface, ok := data["visibleTabs"].([]interface{})
if !ok {
return common.DefaultVisibleTabs
}
visibleTabs := common.InterfaceSliceToStringSlice(visibleTabsInterface)
if len(visibleTabs) == 0 {
return common.DefaultVisibleTabs
}
return visibleTabs
}
// handleNewlyEnabledModules 处理新启用的模块
func (a *App) handleNewlyEnabledModules(oldTabs, newTabs []string) {
newlyEnabled := common.Difference(newTabs, oldTabs)
if len(newlyEnabled) == 0 {
return
}
slog.Info("检测到新启用的模块", "modules", newlyEnabled)
for _, tab := range newlyEnabled {
switch tab {
case common.TabFileSystem:
a.initFilesystemModule()
case common.TabDevice:
slog.Info("设备测试模块已启用")
}
}
}
// initModulesByConfig 根据配置初始化模块
func (a *App) initModulesByConfig(visibleTabs []string) error {
if common.Contains(visibleTabs, common.TabFileSystem) {
a.initFilesystemModule()
if a.filesystem == nil {
return fmt.Errorf("文件系统服务初始化失败")
}
} else {
slog.Info("跳过文件系统模块(未启用)")
}
return nil
}
+374
View File
@@ -0,0 +1,374 @@
package main
import (
"fmt"
"log/slog"
"path/filepath"
"time"
"u-desk/internal/filesystem"
"github.com/wailsapp/wails/v3/pkg/application"
)
// filesystemSvc 返回文件系统服务。初始化在启动 goroutine 异步进行,
// 完成前前端触发文件操作时返回明确错误而非 nil panic
func (a *App) filesystemSvc() (*filesystem.FileSystemService, error) {
if a.filesystem == nil {
return nil, fmt.Errorf("文件系统服务尚未初始化完成,请稍后重试")
}
return a.filesystem, nil
}
// EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
// 通过 drawio-download-progress 事件推送下载进度。
func (a *App) EnsureDrawioViewer() (map[string]interface{}, error) {
err := filesystem.EnsureDrawioViewer(func(progress, speed float64, downloaded, total int64) {
a.mainWindow.EmitEvent("drawio-download-progress", map[string]interface{}{
"progress": progress,
"speed": speed,
"downloaded": downloaded,
"total": total,
})
})
return map[string]interface{}{
"ready": err == nil,
"version": filesystem.DrawioViewerVersion(),
}, err
}
// ReadFile 读取文件
func (a *App) ReadFile(path string) (string, error) {
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ReadFile(path)
}
// WatchFile 开始监听指定文件的变化,变化时发送 file-changed 事件
func (a *App) WatchFile(path string) error {
if a.fileWatcher == nil {
return fmt.Errorf("文件监听器未初始化")
}
return a.fileWatcher.WatchFile(path)
}
// UnwatchFile 停止监听文件变化
func (a *App) UnwatchFile() {
if a.fileWatcher != nil {
a.fileWatcher.UnwatchFile()
}
}
// WriteFile 写入文件
func (a *App) WriteFile(req WriteFileRequest) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.WriteFile(req.Path, req.Content)
}
// SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
func (a *App) SaveBase64File(req SaveBase64FileRequest) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.SaveBase64File(req.Path, req.Content)
}
// ListDir 列出目录
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.ListDir(path)
}
// CreateDir 创建目录
func (a *App) CreateDir(path string) (*filesystem.FileOperationResult, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.CreateDir(path)
}
// CreateFile 创建文件
func (a *App) CreateFile(path string) (*filesystem.FileOperationResult, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.CreateFile(path)
}
// DeletePath 删除文件或目录
func (a *App) DeletePath(path string) (*filesystem.FileOperationResult, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.DeletePath(path)
}
// RenamePath 重命名文件或目录
func (a *App) RenamePath(req RenamePathRequest) (*filesystem.FileOperationResult, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.RenamePath(req.OldPath, req.NewPath)
}
// CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
func (a *App) CopyPath(src, dst string) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
emitter := newTransferEmitter(a, "copy", filepath.Base(filepath.FromSlash(src)))
err = fs.CopyPath(src, dst, func(copied, total int64) {
emitter.progress(emitter.name, copied, total)
})
emitter.finish(err)
return err
}
// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
func (a *App) MovePath(src, dst string) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
emitter := newTransferEmitter(a, "move", filepath.Base(filepath.FromSlash(src)))
err = fs.MovePath(src, dst, func(copied, total int64) {
emitter.progress(emitter.name, copied, total)
})
emitter.finish(err)
return err
}
// PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
func (a *App) PickDirectory() (string, error) {
dialog := application.Get().Dialog.OpenFileWithOptions(&application.OpenFileDialogOptions{
CanChooseDirectories: true,
CanChooseFiles: false,
CanCreateDirectories: true,
Title: "选择目录",
})
return dialog.PromptForSingleSelection()
}
// ========== 传输进度事件(下载/上传/本地复制共用) ==========
// transferProgressInterval 进度事件最小推送间隔(节流)
const transferProgressInterval = 100 * time.Millisecond
// transferEmitter 封装 transfer-progress 事件的节流推送
type transferEmitter struct {
app *App
id string
name string
lastEmit time.Time
}
// newTransferID 生成传输任务标识
func newTransferID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
// newTransferEmitter 创建进度推送器并发送首帧 running 事件
func newTransferEmitter(a *App, prefix, name string) *transferEmitter {
te := &transferEmitter{app: a, id: newTransferID(prefix), name: name}
te.emit(name, 0, 0, "")
return te
}
// emit 推送一帧进度(errMsg 非空表示失败)
func (t *transferEmitter) emit(name string, copied, total int64, errMsg string) {
payload := map[string]interface{}{
"id": t.id,
"name": name,
"copied": copied,
"total": total,
"status": "running",
}
if errMsg != "" {
payload["status"] = "error"
payload["error"] = errMsg
}
t.app.emitEvent("transfer-progress", payload)
}
// progress 流式进度回调(100ms 节流;目录传输时 name 为当前文件名)
func (t *transferEmitter) progress(name string, copied, total int64) {
t.name = name
now := time.Now()
if now.Sub(t.lastEmit) < transferProgressInterval {
return
}
t.lastEmit = now
t.emit(name, copied, total, "")
}
// finish 结束传输:成功发 done,失败发 error(各一帧,不节流)
func (t *transferEmitter) finish(err error) {
if err != nil {
t.emit(t.name, 0, 0, err.Error())
return
}
payload := map[string]interface{}{
"id": t.id,
"name": t.name,
"status": "done",
}
t.app.emitEvent("transfer-progress", payload)
}
// GetFileInfo 获取文件信息
func (a *App) GetFileInfo(path string) (map[string]interface{}, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetFileInfo(path)
}
// OpenPath 使用系统默认程序打开文件或目录
func (a *App) OpenPath(path string) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.OpenPath(path)
}
// ========== Zip 文件操作接口 ==========
// ListZipContents 列出 zip 文件内容
func (a *App) ListZipContents(zipPath string) ([]map[string]interface{}, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.ListZipContents(zipPath)
}
// ExtractFileFromZip 从 zip 文件中提取单个文件内容
func (a *App) ExtractFileFromZip(zipPath, filePath string) (string, error) {
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ExtractFileFromZip(zipPath, filePath)
}
// ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
func (a *App) ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
fs, err := a.filesystemSvc()
if err != nil {
return "", err
}
return fs.ExtractFileFromZipToTemp(zipPath, filePath)
}
// GetZipFileInfo 获取 zip 文件中特定文件的信息
func (a *App) GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetZipFileInfo(zipPath, filePath)
}
// ResolveShortcut 解析快捷方式文件,返回目标路径信息
func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
targetPath, err := fs.ResolveShortcut(lnkPath)
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, err
}
fileInfo, err := fs.GetFileInfo(targetPath)
if err != nil {
return map[string]interface{}{
"success": true,
"targetPath": targetPath,
"targetExists": false,
"targetAccessible": false,
}, nil
}
return map[string]interface{}{
"success": true,
"targetPath": targetPath,
"targetExists": true,
"targetAccessible": true,
"targetInfo": fileInfo,
}, nil
}
// startFileServer 启动文件服务器
func (a *App) startFileServer() {
if _, err := filesystem.StartLocalFileServer(); err != nil {
slog.Error("文件服务器启动失败", "error", err)
return
}
slog.Info("文件服务器已启动", "addr", "http://"+filesystem.GetLocalFileServerAddr())
}
// ========== 文件服务器接口 ==========
// GetFileServerURL 获取本地文件服务器的URL
func (a *App) GetFileServerURL() string {
return fmt.Sprintf("http://%s", filesystem.GetLocalFileServerAddr())
}
// DetectFileTypeByContent 通过文件内容检测文件类型
func (a *App) DetectFileTypeByContent(path string) (map[string]interface{}, error) {
return filesystem.DetectFileTypeByContentSimple(path)
}
// initFilesystemModule 延迟初始化文件系统模块
func (a *App) initFilesystemModule() {
if a.filesystem != nil {
slog.Info("文件系统模块已初始化,跳过")
return
}
slog.Info("延迟初始化文件系统模块")
fsConfig := filesystem.DefaultConfig()
var err error
a.filesystem, err = filesystem.NewFileSystemService(fsConfig)
if err != nil {
slog.Error("文件系统模块初始化失败", "error", err)
return
}
// startFileServer 内部 listen 同步(极快)、Serve 异步,无需再包 goroutine
a.startFileServer()
slog.Info("文件系统模块初始化完成")
}
// ========== 审计日志接口 ==========
// GetAuditLogs 获取审计日志
func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetAuditLogs(limit)
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"u-desk/internal/filesystem"
osssvc "u-desk/internal/ossdrv"
)
// ========== OSS 接口 ==========
func (a *App) ensureOssService() *osssvc.Service {
a.mu.Lock()
defer a.mu.Unlock()
if a.ossService == nil {
a.ossService = osssvc.NewService()
}
return a.ossService
}
// OssConnect 连接 OSS 存储
func (a *App) OssConnect(req OssConnectRequest) (string, error) {
if err := a.ensureOssService().GetManager().Connect(req.Provider, req.AccessKey, req.SecretKey, req.Endpoint); err != nil {
return "", err
}
return req.Provider, nil
}
// OssDisconnect 断开 OSS 连接
func (a *App) OssDisconnect(connID string) error {
osssvc.GetManager().Disconnect(connID)
return nil
}
// OssListDir OSS 列出目录
func (a *App) OssListDir(connID string, prefix string) ([]map[string]interface{}, error) {
return a.ensureOssService().ListDir(connID, prefix)
}
// OssDownloadToTemp OSS 下载到临时文件
func (a *App) OssDownloadToTemp(connID string, key string) (string, error) {
return a.ensureOssService().DownloadToTemp(connID, key)
}
// OssDownloadSiteForPreview OSS 下载 HTML 及其引用的资源到临时目录
func (a *App) OssDownloadSiteForPreview(connID string, key string) (string, error) {
return a.ensureOssService().DownloadSiteForPreview(connID, key)
}
// OssDownloadToTempCached 带缓存的 OSS 下载(命中缓存直接返回本地路径)
func (a *App) OssDownloadToTempCached(connID string, key string, fileSize int64, modTime string) (string, error) {
return a.ensureOssService().DownloadToTempCached(connID, key, fileSize, modTime)
}
// OssReadFile OSS 读取文件
func (a *App) OssReadFile(connID string, key string) (string, error) {
return a.ensureOssService().ReadFile(connID, key)
}
// OssWriteFile OSS 写入文件
func (a *App) OssWriteFile(connID string, key string, content string) error {
return a.ensureOssService().WriteFile(connID, key, content)
}
// OssWriteBase64File OSS 写入 base64 编码文件
func (a *App) OssWriteBase64File(connID string, key string, base64Content string) error {
return a.ensureOssService().WriteBase64File(connID, key, base64Content)
}
// OssGetFileInfo OSS 获取文件信息
func (a *App) OssGetFileInfo(connID string, key string) (map[string]interface{}, error) {
return a.ensureOssService().GetFileInfo(connID, key)
}
// OssCreateDir OSS 创建目录
func (a *App) OssCreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
return a.ensureOssService().CreateDir(connID, dirPath)
}
// OssCreateFile OSS 创建文件
func (a *App) OssCreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
return a.ensureOssService().CreateFile(connID, filePath)
}
// OssDeletePath OSS 删除
func (a *App) OssDeletePath(connID string, key string) (*filesystem.FileOperationResult, error) {
return a.ensureOssService().DeletePath(connID, key)
}
// OssRenamePath OSS 重命名
func (a *App) OssRenamePath(req OssRenamePathRequest) (*filesystem.FileOperationResult, error) {
return a.ensureOssService().RenamePath(req.ConnID, req.OldPath, req.NewPath)
}
// OssGetCommonPaths OSS 获取常用路径
func (a *App) OssGetCommonPaths(connID string) (map[string]string, error) {
return a.ensureOssService().GetCommonPaths(connID)
}
// OssGetSignedURL OSS 获取预签名 URL
func (a *App) OssGetSignedURL(connID string, key string) (string, error) {
return a.ensureOssService().GetSignedURL(connID, key)
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"fmt"
"u-desk/internal/api"
)
// requirePdfAPI 检查 pdfAPI 是否已初始化
func (a *App) requirePdfAPI() (*api.PdfAPI, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.pdfAPI == nil {
return nil, fmt.Errorf("PDF导出功能正在初始化中")
}
return a.pdfAPI, nil
}
// ExportPDF 导出PDF文件
func (a *App) ExportPDF(content string, title string, fileName string, fontSize int, pageWidth int, pageHeight int) (map[string]interface{}, error) {
pdfAPI, err := a.requirePdfAPI()
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, err
}
req := api.PdfExportRequest{
Content: content,
Title: title,
FileName: fileName,
FontSize: fontSize,
PageWidth: pageWidth,
PageHeight: pageHeight,
}
result, err := 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
}
// ExportHtmlFileToPDF 将HTML文件导出为PDF
func (a *App) ExportHtmlFileToPDF(path string, saveName string) (map[string]interface{}, error) {
pdfAPI, err := a.requirePdfAPI()
if err != nil {
return map[string]interface{}{
"success": false,
"message": err.Error(),
}, err
}
result, err := pdfAPI.ExportHtmlFileToPDF(path, saveName)
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
}
+39
View File
@@ -0,0 +1,39 @@
package main
// ========== 回收站接口 ==========
// GetRecycleBinEntries 获取回收站条目
func (a *App) GetRecycleBinEntries() ([]map[string]interface{}, error) {
fs, err := a.filesystemSvc()
if err != nil {
return nil, err
}
return fs.GetRecycleBinEntries()
}
// RestoreFromRecycleBin 从回收站恢复文件
func (a *App) RestoreFromRecycleBin(recyclePath string) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.RestoreFromRecycleBin(recyclePath)
}
// DeletePermanently 永久删除回收站中的文件
func (a *App) DeletePermanently(recyclePath string) error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.DeletePermanently(recyclePath)
}
// EmptyRecycleBin 清空回收站
func (a *App) EmptyRecycleBin() error {
fs, err := a.filesystemSvc()
if err != nil {
return err
}
return fs.EmptyRecycleBin()
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"fmt"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"u-desk/internal/filesystem"
"u-desk/internal/sftp"
)
// ========== SFTP 接口 ==========
func (a *App) ensureSftpService() *sftp.Service {
a.mu.Lock()
defer a.mu.Unlock()
if a.sftpService == nil {
a.sftpService = sftp.NewService()
}
return a.sftpService
}
// SftpConnect 建立 SFTP 连接,返回连接标识符 connID
func (a *App) SftpConnect(req SftpConnectRequest) (string, error) {
config := &sftp.Config{
Host: req.Host,
Port: req.Port,
Username: req.Username,
Password: req.Password,
KeyPath: req.KeyPath,
KeyPassphrase: req.KeyPassphrase,
}
if config.Port == 0 {
config.Port = 22
}
if config.Timeout == 0 {
config.Timeout = 15 * time.Second
}
svc := a.ensureSftpService()
_, err := svc.GetManager().Connect(config)
if err != nil {
return "", sftp.ToUserMessage(err)
}
connID := sftp.ConnID(config.Host, config.Port)
return connID, nil
}
// SftpDisconnect 断开 SFTP 连接
func (a *App) SftpDisconnect(connID string) error {
parts := strings.SplitN(connID, ":", 2)
if len(parts) < 2 {
return fmt.Errorf("无效的连接标识符")
}
host := parts[0]
port, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("无效的端口号")
}
sftp.GetManager().Disconnect(host, port)
return nil
}
// SftpListDir SFTP 列出目录
func (a *App) SftpListDir(connID string, dirPath string) ([]map[string]interface{}, error) {
return a.ensureSftpService().ListDir(connID, dirPath)
}
// SftpReadFile SFTP 读取文件内容
func (a *App) SftpReadFile(connID string, filePath string) (string, error) {
return a.ensureSftpService().ReadFile(connID, filePath)
}
// SftpWriteFile SFTP 写入文件
func (a *App) SftpWriteFile(req SftpWriteFileRequest) error {
return a.ensureSftpService().WriteFile(req.SessionID, req.Path, req.Content)
}
// SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
func (a *App) SftpWriteBase64File(sessionID, filePath, base64Content string) error {
return a.ensureSftpService().WriteBase64File(sessionID, filePath, base64Content)
}
// SftpGetFileInfo SFTP 获取文件信息
func (a *App) SftpGetFileInfo(connID string, filePath string) (map[string]interface{}, error) {
return a.ensureSftpService().GetFileInfo(connID, filePath)
}
// SftpCreateDir SFTP 创建目录
func (a *App) SftpCreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
return a.ensureSftpService().CreateDir(connID, dirPath)
}
// SftpCreateFile SFTP 创建文件
func (a *App) SftpCreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
return a.ensureSftpService().CreateFile(connID, filePath)
}
// SftpDeletePath SFTP 删除文件或目录
func (a *App) SftpDeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) {
return a.ensureSftpService().DeletePath(connID, filePath)
}
// SftpRenamePath SFTP 重命名文件或目录
func (a *App) SftpRenamePath(req SftpRenamePathRequest) (*filesystem.FileOperationResult, error) {
return a.ensureSftpService().RenamePath(req.SessionID, req.OldPath, req.NewPath)
}
// SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
func (a *App) SftpDownloadToTemp(connID string, remotePath string) (string, error) {
return a.ensureSftpService().DownloadToTemp(connID, remotePath)
}
// SftpDownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录
func (a *App) SftpDownloadSiteForPreview(connID string, remotePath string) (string, error) {
return a.ensureSftpService().DownloadSiteForPreview(connID, remotePath)
}
// SftpDownloadToTempCached 带缓存的 SFTP 下载(命中缓存直接返回本地路径)
func (a *App) SftpDownloadToTempCached(connID string, remotePath string, fileSize int64, modTime string) (string, error) {
return a.ensureSftpService().DownloadToTempCached(connID, remotePath, fileSize, modTime)
}
// SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径
func (a *App) SftpDownloadToFile(connID string, remotePath string, localDstDir string) (string, error) {
emitter := newTransferEmitter(a, "download", path.Base(remotePath))
localPath, err := a.ensureSftpService().DownloadToFile(connID, remotePath, localDstDir, emitter.progress)
emitter.finish(err)
return localPath, err
}
// SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
func (a *App) SftpUploadFromFile(connID string, localPath string, remoteDir string) error {
emitter := newTransferEmitter(a, "upload", filepath.Base(localPath))
err := a.ensureSftpService().UploadFromFile(connID, localPath, remoteDir, emitter.progress)
emitter.finish(err)
return err
}
// SftpGetCommonPaths 获取 SFTP 远程主机常用路径
func (a *App) SftpGetCommonPaths(connID string) (map[string]string, error) {
return a.ensureSftpService().GetCommonPaths(connID)
}
// SftpGetSystemInfo 获取 SFTP 远程主机系统信息(CPU/内存/磁盘)
func (a *App) SftpGetSystemInfo(connID string) (map[string]interface{}, error) {
return a.ensureSftpService().GetSystemInfo(connID)
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"os"
"strings"
"u-desk/internal/system"
)
// GetSystemInfo 获取系统信息
func (a *App) GetSystemInfo() (map[string]interface{}, error) {
return system.GetSystemInfo()
}
// GetCPUInfo 获取 CPU 信息
func (a *App) GetCPUInfo() (map[string]interface{}, error) {
return system.GetCPUInfo()
}
// GetMemoryInfo 获取内存信息
func (a *App) GetMemoryInfo() (map[string]interface{}, error) {
return system.GetMemoryInfo()
}
// GetDiskInfo 获取磁盘信息
func (a *App) GetDiskInfo() ([]map[string]interface{}, error) {
return system.GetDiskInfo()
}
func (a *App) GetLocalSystemInfo() (map[string]interface{}, error) {
info := make(map[string]interface{})
cpuInfo, err := system.GetCPUInfo()
if err == nil && cpuInfo != nil {
if v, ok := cpuInfo["usage"].(string); ok {
info["cpu_usage"] = v
}
}
memInfo, err := system.GetMemoryInfo()
if err == nil && memInfo != nil {
if v, ok := memInfo["usage"].(string); ok {
info["mem_usage"] = v
}
}
diskInfos, err := system.GetDiskInfo()
if err == nil && len(diskInfos) > 0 {
if v, ok := diskInfos[0]["usage"].(string); ok {
info["disk_usage"] = v
}
}
return info, nil
}
// GetCommonPaths 获取常用系统路径
func (a *App) GetCommonPaths() (map[string]string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
paths := map[string]string{
"home": homeDir,
}
for k, v := range resolveFolderPaths(homeDir) {
paths[k] = v
}
return paths, nil
}
// GetEnvVars 获取环境变量
func (a *App) GetEnvVars() (map[string]string, error) {
envVars := make(map[string]string)
for _, env := range os.Environ() {
if key, value, found := strings.Cut(env, "="); found {
envVars[key] = value
}
}
return envVars, nil
}
+211
View File
@@ -0,0 +1,211 @@
package main
import (
"fmt"
"log/slog"
"time"
"u-desk/internal/api"
)
// ========== 版本更新管理接口 ==========
// requireUpdateAPI 检查 updateAPI 是否已初始化
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
}
return a.updateAPI, nil
}
// CheckUpdate 检查更新
func (a *App) CheckUpdate() (map[string]interface{}, error) {
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return api.CheckUpdate()
}
// GetCurrentVersion 获取当前版本号
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return api.GetCurrentVersion()
}
// GetUpdateConfig 获取更新配置
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return api.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
}
return api.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
}
return api.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
}
return api.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
}
return api.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
}
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
}
// startAutoUpdateCheck 启动自动更新检查(可重入:先停止旧定时器再按当前配置新建)
func (a *App) startAutoUpdateCheck() {
updateAPI, err := a.requireUpdateAPI()
if err != nil {
return
}
config, err := updateAPI.GetUpdateConfig()
if err != nil {
slog.Warn("获取更新配置失败", "error", err)
return
}
success, ok := config["success"].(bool)
if !ok || !success {
return
}
configData, ok := config["data"].(map[string]interface{})
if !ok {
return
}
autoCheckEnabled, ok := configData["auto_check_enabled"].(bool)
if !ok || !autoCheckEnabled {
// 关闭自动检查时停止存量定时器
a.stopUpdateTicker()
return
}
interval, ok := configData["check_interval_minutes"].(int)
if !ok || interval <= 0 {
interval = 5
}
a.mu.Lock()
if a.updateCheckStop != nil {
close(a.updateCheckStop)
}
if a.updateTicker != nil {
a.updateTicker.Stop()
}
stop := make(chan struct{})
a.updateCheckStop = stop
a.updateTicker = time.NewTicker(time.Duration(interval) * time.Minute)
ticker := a.updateTicker
a.mu.Unlock()
go a.checkUpdate()
go func() {
for {
select {
case <-ticker.C:
a.checkUpdate()
case <-stop:
return
}
}
}()
}
// stopUpdateTicker 停止自动更新检查定时器及其 goroutine
func (a *App) stopUpdateTicker() {
a.mu.Lock()
defer a.mu.Unlock()
if a.updateCheckStop != nil {
close(a.updateCheckStop)
a.updateCheckStop = nil
}
if a.updateTicker != nil {
a.updateTicker.Stop()
a.updateTicker = nil
}
}
// resetAutoUpdateCheck 重置自动更新检查(更新配置变更后调用,免重启生效)
func (a *App) resetAutoUpdateCheck() {
a.stopUpdateTicker()
a.startAutoUpdateCheck()
}
// checkUpdate 执行更新检查
func (a *App) checkUpdate() {
defer func() {
if r := recover(); r != nil {
slog.Error("自动检查更新发生错误", "error", r)
}
}()
a.mu.Lock()
api := a.updateAPI
a.mu.Unlock()
if api == nil {
return
}
result, err := api.CheckUpdate()
if err != nil {
return
}
success, ok := result["success"].(bool)
if !ok || !success {
return
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return
}
hasUpdate, ok := data["has_update"].(bool)
if ok && hasUpdate && a.mainWindow != nil {
a.mainWindow.EmitEvent("update-available", data)
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// ========== 窗口控制方法 ==========
// WindowMinimize 最小化窗口
func (a *App) WindowMinimize() {
if a.mainWindow != nil {
a.mainWindow.Minimise()
}
}
// WindowMaximize 最大化/还原窗口
func (a *App) WindowMaximize() {
if a.mainWindow == nil {
return
}
if a.mainWindow.IsMaximised() {
a.mainWindow.UnMaximise()
} else {
a.mainWindow.Maximise()
}
}
// WindowClose 关闭窗口
func (a *App) WindowClose() {
application.Get().Quit()
}
// WindowIsMaximized 检查窗口是否最大化
func (a *App) WindowIsMaximized() bool {
if a.mainWindow != nil {
return a.mainWindow.IsMaximised()
}
return false
}
// WindowToggleAlwaysOnTop 切换窗口置顶
func (a *App) WindowToggleAlwaysOnTop() bool {
if a.mainWindow == nil {
return false
}
a.mu.Lock()
a.isAlwaysOnTop = !a.isAlwaysOnTop
a.mu.Unlock()
a.mainWindow.SetAlwaysOnTop(a.isAlwaysOnTop)
return a.isAlwaysOnTop
}
// SetWindowTitleBarColor 设置原生标题栏颜色 + 主题模式(0x00BBGGRR 格式)
func (a *App) SetWindowTitleBarColor(color uint32, isDark bool) {
if a.mainWindow == nil {
return
}
hwnd := uintptr(a.mainWindow.NativeWindow())
if hwnd == 0 {
return
}
setWindowTitleBar(hwnd, color, isDark)
}
// Reload 重新加载窗口(用于菜单项)
func (a *App) Reload() {
if a.mainWindow != nil {
a.mainWindow.Reload()
}
}
// ClearCache 清理本地缓存(用于菜单项)
func (a *App) ClearCache() {
if a.mainWindow != nil {
a.mainWindow.EmitEvent("clear-cache")
}
}
+133
View File
@@ -0,0 +1,133 @@
//go:build windows
package main
import (
"strings"
"sync/atomic"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// Chrome_WidgetWin 系列类名是 WebView2 的宿主窗口层级
const webviewHostClassFragment = "Chrome_WidgetWin"
// wmSize Win32 窗口尺寸变化消息
const wmSizeMsg uint32 = 0x0005
var (
// user32 对这三个 API 只导出无后缀名(无 A/W 变体,带 W 后缀找不到 proc 会 panic)
procGetClientRectFix = windows.NewLazySystemDLL("user32.dll").NewProc("GetClientRect")
procSetWindowPosFix = windows.NewLazySystemDLL("user32.dll").NewProc("SetWindowPos")
procGetAncestorFix = windows.NewLazySystemDLL("user32.dll").NewProc("GetAncestor")
)
// 修复去重:同一时刻只允许一个校验在跑(回调写共享切片要求互斥)
var webviewFixRunning atomic.Bool
// 当前校验的目标主窗口(枚举回调过滤直属子窗口用)
var webviewTargetHwnd uintptr
// webviewHosts 收集结果容器;enumChildWebviewHosts 回调写入
var webviewHosts []windows.HWND
// 包级变量:syscall.NewCallback 指向的 Go 闭包必须存活于整个进程生命周期
var enumChildWebviewHosts = syscall.NewCallback(func(h uintptr, _ uintptr) uintptr {
// GA_PARENT(1):只收直属子窗口,避免动到更深层的 WebView2 内部窗口
parent, _, _ := procGetAncestorFix.Call(h, 1)
if parent != webviewTargetHwnd {
return 1
}
var buf [256]uint16
if _, err := windows.GetClassName(windows.HWND(h), &buf[0], int32(len(buf))); err != nil {
return 1
}
if strings.Contains(syscall.UTF16ToString(buf[:]), webviewHostClassFragment) {
webviewHosts = append(webviewHosts, windows.HWND(h))
}
return 1
})
// checkAndFixFill 校验 WebView2 宿主是否铺满窗口客户区,未铺满则强制铺满。
// wails alpha 的 WM_SIZE 处理存在竞态:主线程忙时消息被 resizePending 标志丢弃,
// 迟到执行的重铺又使用闭包捕获的过期 bounds,webview 会停在中间尺寸且不再有
// 新消息来纠正,表现为整个应用内容收窄、四周露出窗口底色。
func checkAndFixFill(hwnd uintptr) {
if hwnd == 0 {
return
}
var rc struct{ Left, Top, Right, Bottom int32 }
if r, _, _ := procGetClientRectFix.Call(hwnd, uintptr(unsafe.Pointer(&rc))); r == 0 {
return
}
cw, ch := int(rc.Right-rc.Left), int(rc.Bottom-rc.Top)
if cw <= 0 || ch <= 0 {
return
}
webviewTargetHwnd = hwnd
webviewHosts = webviewHosts[:0]
windows.EnumChildWindows(windows.HWND(hwnd), enumChildWebviewHosts, nil)
for _, h := range webviewHosts {
// SWP_NOZORDER(0x0004):只改尺寸位置,不干预 wails 管理的层级
procSetWindowPosFix.Call(uintptr(h), 0, 0, 0, uintptr(cw), uintptr(ch), 0x0004)
}
}
// runGuarded 统一 panic 兜底:windowsgui 下 goroutine panic 会静默杀进程
func runGuarded(fn func()) {
defer func() { recover() }()
fn()
}
// fixWebviewFill WM_SIZE 路径:等 resize 序列结束、wails 内部去抖落定后校验一次
func fixWebviewFill(hwnd uintptr) {
if !webviewFixRunning.CompareAndSwap(false, true) {
return
}
go func() {
defer webviewFixRunning.Store(false)
runGuarded(func() {
time.Sleep(150 * time.Millisecond)
checkAndFixFill(hwnd)
})
}()
}
// StartWebviewFillWatch 周期兜底:覆盖"无新 WM_SIZE 的迟到重放"场景
// (主线程忙时排队的过期重绘在窗口静止时才执行,此时不会产生任何 WM_SIZE)
func StartWebviewFillWatch() {
go runGuarded(func() {
for {
time.Sleep(3 * time.Second)
if webviewFixRunning.CompareAndSwap(false, true) {
runGuarded(func() {
checkAndFixFill(findMainWindowHwnd())
})
webviewFixRunning.Store(false)
}
}
})
}
// findMainWindowHwnd 找本进程的可见主窗口
func findMainWindowHwnd() uintptr {
var found uintptr
pid := windows.GetCurrentProcessId()
cb := syscall.NewCallback(func(h uintptr, _ uintptr) uintptr {
var winPid uint32
windows.GetWindowThreadProcessId(windows.HWND(h), &winPid)
if winPid == pid && windows.IsWindowVisible(windows.HWND(h)) {
found = h
return 0
}
return 1
})
runGuarded(func() {
windows.EnumWindows(cb, nil)
})
return found
}
+75
View File
@@ -0,0 +1,75 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"strings"
"golang.org/x/sys/windows/registry"
"github.com/wailsapp/wails/v3/pkg/w32"
)
// getWindowsSpecialFolder 从注册表读取 Windows 特殊文件夹的真实路径
func getWindowsSpecialFolder(guid 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 ""
}
path := os.ExpandEnv(val)
if _, err := os.Stat(path); err != nil {
return ""
}
return path
}
// getWindowsDrivePaths 返回所有可用盘符路径
func getWindowsDrivePaths() map[string]string {
paths := make(map[string]string)
for _, drive := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
path := string(drive) + ":\\"
if _, err := os.Stat(path); err == nil {
key := string(drive)
paths[key] = path
}
}
return paths
}
// setWindowTitleBar 设置原生标题栏颜色 + 主题模式
func setWindowTitleBar(hwnd uintptr, color uint32, isDark bool) {
w32.SetTheme(hwnd, isDark)
w32.SetTitleBarColour(hwnd, color)
}
// resolveFolderPaths 解析特殊文件夹路径
func resolveFolderPaths(homeDir string) map[string]string {
paths := make(map[string]string)
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); p != "" {
paths[name] = p
} else {
paths[name] = filepath.Join(homeDir, strings.ToUpper(name[:1])+name[1:])
}
}
for k, v := range getWindowsDrivePaths() {
paths["root_"+k] = v
}
return paths
}
+664
View File
@@ -0,0 +1,664 @@
# 功能决策记录
> 日常开发中对各功能做的细节取舍(功能粒度,补架构决策之下的实现层)。聚焦「为什么这么定」。
> 创建:2026-06-12 | 维护:随开发追加
>
> 来源:历史会话挖掘 + 当前会话。覆盖 u-desk 0.3.x → 0.4.x(fs-only-v3)开发期。
---
## 启动 / 关闭速度
### ServiceStartup 非关键路径并行化
- **决策**:SQLite + ConfigAPI 前置同步,PdfAPI/FileSystemService/CleanupExpiredCache/CleanupSiteCacheDirs 移入 goroutine 并行。
- **原因/取舍**:前置依赖必须先就绪;非关键子系统串行会阻塞 Wails 窗口加载前端,并行后首屏不受拖累。
- **状态**:✅ 已落地(`app.go` ServiceStartup)
### 审计日志懒初始化 ensureReady
- **决策**:审计日志从启动全量初始化改为首条写入时才 `ensureReady`(开文件 + 起 flush 协程)。
- **原因/取舍**:原 `NewAuditLogger` 启动即建目录/开文件/起协程拖慢启动。懒初始化零启动开销;用 mutex+initialized 标志替代 `sync.Once`,因 sync.Once 消费后不再执行、初始化失败后后续 Log 静默丢失。
- **状态**:✅ 已落地(`audit_log.go`)
### 回收站 metadata 延迟加载
- **决策**:回收站 `metadata.json` 从启动加载改为首次访问时 `ensureLoaded`。
- **原因/取舍**:用户可能整个会话不用回收站;延迟加载减启动 IO 和内存。
- **状态**:✅ 已落地(`recycle_bin.go`)
### startFileServer 同步启动
- **决策**:`startFileServer` 从 `go a.startFileServer()` 改为同步调用。
- **原因/取舍**:该函数只注册 HTTP handler 不阻塞;异步启动反而有 handler 未注册完成时前端已请求的竞态。
- **状态**:✅ 已落地(`app_filesystem.go`)
### loadFromDB 延迟 500ms 自动连接
- **决策**:`loadFromDB` 中系统信息采集 + 自动连接包进 `setTimeout(500)`。
- **原因/取舍**:远程 profile 自动连接并发网络 IO 阻塞首屏;延迟 500ms 让空壳 UI 先渲染。代价:首屏 500ms 内远程不可用,且与 session restore 存在同 profile 双连接竞争(见连接管理域)。
- **状态**:✅ 已落地(`connection-manager.ts`)
### session restore 延迟到 rAF
- **决策**:会话恢复(路径/连接)延迟到 `requestAnimationFrame`。
- **原因/取舍**:`onMounted` 恢复阻塞 Vue 首帧渲染;rAF 让首帧先绘制再恢复。
- **状态**:✅ 已落地(`index.vue` onMounted)
### Markdown / highlight.js 动态懒加载
- **决策**:`marked` + `highlight.js` 从静态 import 改为按需动态 import。
- **原因/取舍**:两库 ~200KB+,全量导入阻塞首屏;文件预览是按需功能不应拖慢所有用户。代价:首次开 MD 有原文闪烁。
- **状态**:✅ 已落地(`markedExtensions.ts`)
### 启动后 3s 延迟静默检查更新
- **决策**:启动后 `setTimeout(3000)` 静默检查更新。
- **原因/取舍**:启动即发网络请求增加首屏时间;延迟 3s 让 UI 先渲染,静默不弹窗。
- **状态**:✅ 已落地(`App.vue`)
### ServiceShutdown errgroup 并行 + 5s 总超时
- **决策**:关闭用 errgroup 并行关 4 个子系统,共享 5s 总超时。
- **原因/取舍**:原串行关闭耗时叠加;并行后总耗时取最慢子系统,5s 兜底防无限挂起。
- **状态**:✅ 已落地(`app.go` ServiceShutdown)
### 热键轮询递增间隔
- **决策**:热键轮询从固定 200ms 改为 50→100→200ms 递增。
- **原因/取舍**:首检快 150ms(50ms 捕获),后续逐步放宽降 CPU。
- **状态**:✅ 已落地(`app.go`)
---
## 文件删除 / 回收站
### 回收站优先策略
- **决策**:删除文件时**先尝试移入回收站**,移动失败才永久删除。
- **原因/取舍**:旧逻辑「先永久删除再移回收站」——文件已删,移回收站必然失败,回收站形同虚设。改为先移后删保证误删可恢复。永久删除仅在跨卷/权限失败时降级兜底。
- **状态**:✅ 已落地(`filesystem/service.go`)
### isInRecycleBin 用 HasPrefix
- **决策**:判断路径是否在回收站内用 `strings.HasPrefix` 而非切片前缀比较。
- **原因/取舍**:旧 `cleanPath[:len(cleanBinPath)]` 当 `len(cleanPath) < len(cleanBinPath)` 时切片越界 panic。HasPrefix 自带长度判断。
- **状态**:✅ 已落地
### 回收站 entries 并发安全
- **决策**:`RecycleBin.entries` 加 `sync.Mutex`,`ListEntries` 返回副本。
- **原因/取舍**:`autoCleanup` 后台协程与用户操作并发读写 entries 存在竞争;返回副本避免外部遍历被 cleanup 修改 panic。
- **状态**:✅ 已落地(`recycle_bin.go`)
### 随机字符串加索引偏移
- **决策**:`generateRandomString` 每个字符对 nanoseed 加索引 `i` 偏移再取模。
- **原因/取舍**:同一次调用内多字符同纳秒生成、seed 相同导致全串重复字符。加索引打破局部相同。非密码学随机,简单偏移足够。
- **状态**:✅ 已落地(`recycle_bin.go`)
### IsFileLocked 去 O_CREAT
- **决策**:`IsFileLocked` 去掉 `O_CREAT` 改纯只读探测。
- **原因/取舍**:`O_CREAT` 检查锁时会意外创建空文件——目标已删时被「复活」成 0 字节误导。纯探测不应有写入语义。
- **状态**:✅ 已落地(`file_lock.go`)
---
## 远程 HTML 站点预览
### 确定性站点缓存目录
- **决策**:远程 HTML 预览用 `common.SiteCacheDir`(SHA-256 哈希 transport+connID+path+size+modTime)生成确定性路径,命中直接返回;SFTP 与 OSS 统一。
- **原因/取舍**:旧 OSS 方案每次 `os.MkdirTemp` 全量重下,二次打开仍慢。确定性哈希让同文件二次打开命中缓存,size+modTime 进哈希源文件变动自动失效。取舍:路径可预测(非随机名),去重收益远大于此。
- **状态**:✅ 已落地(`common/utils.go` + `ossdrv`/`sftp` DownloadSiteForPreview)
### 资源下载范围演进
- **决策**:全量下载 → 仅补充已发现资源所在目录 → `supplementDir` 限制 50 文件 / 单文件 5MB。
- **原因/取舍**:用户指出「网站可能 100G」全量不可行;改为只补充资源所在目录(webpack chunk 等)。最终加 50 文件 + 5MB 上限防失控,SFTP/OSS 统一。
- **状态**:✅ 已落地(`sftp/service.go`、`ossdrv/service.go`)
### OSS 补充分页
- **决策**:OSS `supplementDir` 列目录循环翻页(`IsTruncated` + `NextMarker`),取完或达上限止。
- **原因/取舍**:旧 `MaxKeys:200` 单次调用,目录超 200 文件直接截断漏下载。分页保完整 + 上限防失控。
- **状态**:✅ 已落地(`ossdrv/service.go`)
### 资源下载并发策略
- **决策**:相对路径资源 8 并发、绝对路径资源串行。
- **原因/取舍**:绝对路径 siteRoot 嗅探是共享状态需顺序探测;相对路径无依赖可并行。20 资源从 20×RTT 降到 ~3 轮;8 并发信号量防连接数爆炸。
- **状态**:✅ 已落地(`sftp`/`ossdrv` DownloadSiteForPreview)
### 绝对路径 siteRoot 向上嗅探
- **决策**:绝对路径 `/assets/js/main.js` 的根含义通过从 HTML 所在目录逐级向上尝试下载直到桶根来确定,非简单去 `/`。
- **原因/取舍**:不同 HTML 的 `/` 可能指向不同根(多站点共存);简单相对 HTML 目录在嵌套场景出错(`pages/about.html` 引 `/assets/` 应到桶根而非 pages/assets)。
- **状态**:✅ 已落地(`ossdrv` resolveAbsoluteResourcePath)
### siteRoot 不跨 HTML 共享
- **决策**:每个 HTML 独立嗅探 siteRoot,不跨调用共享。
- **原因/取舍**:同桶多个不同网站的 `/` 可能指不同根;共享会跨站污染。
- **状态**:✅ 已落地
### 路径重写:正则 → JS setter 拦截
- **决策**:用 JS 拦截脚本覆盖 `HTMLScriptElement.src` 等 setter 重写绝对路径,替代 `locationPathRegex` 正则替换。
- **原因/取舍**:正则替换 `location.pathname` 在 inline JS 字符串内破坏引号致 SyntaxError;正则无法处理 webpack 动态 `import()` chunk。setter 拦截覆盖静态 + 动态两种。
- **状态**:✅ 已落地(`asset_handler.go` injectPathInterceptor)
### 拦截脚本 base 用 baseDir
- **决策**:拦截脚本 base 直接用 HTML 文件所在目录,不用 `findSiteRootDir` 嗅探的网站根。
- **原因/取舍**:SFTP 场景 `findSiteRootDir` 返回临时目录根,实际网站根在子目录;用 baseDir 与 `transformHtmlResourcePaths` 解析一致避免层级错位。
- **状态**:✅ 已落地(`asset_handler.go`/`sftp/service.go`)
### CSS MIME 按扩展名显式设置
- **决策**:文件服务器按扩展名设 Content-Type(CSS → `text/css`),不依赖 `http.DetectContentType`。
- **原因/取舍**:`DetectContentType` 对 CSS 返回 `text/plain`,浏览器 strict MIME checking 拒绝应用样式。
- **状态**:✅ 已落地(`asset_handler.go`)
### 资源探测用 try-download
- **决策**:用 try-download 探测资源存在性,而非先 `GetFileInfo`/`Exists`。
- **原因/取舍**:七牛 `GetFileInfo` 可能走签名域名返回 EOF,接口无 `Exists`;直接尝试下载更稳妥,失败即跳过。
- **状态**:✅ 已落地(`ossdrv` tryDownloadResource)
### OSS HTML 预览路由:/proxy → /localfs
- **决策**:OSS 的 HTML 预览从 `/proxy/html-preview`(远程代理路由)改为先下载到临时目录再用 `/localfs/html-preview`。
- **原因/取舍**:本地文件服务器只注册了 `/localfs/html-preview`,`/proxy/` 仅在 agent server;OSS 路径不是本地 FS 路径,代理路由无法处理。
- **状态**:✅ 已落地(`useFilePreview.ts` htmlPreviewUrl)
### 仅 HTML 类型走整站流程
- **决策**:仅 HTML 预览走独立整站下载流程,其他类型(图片/视频/音频)走 `resolveWithTransport()` 单文件。
- **原因/取舍**:其他类型已通过 `downloadForPreview`+`buildLocalUrl` 走 `/localfs/`;只有 HTML 需下载关联资源。
- **状态**:✅ 已落地(`useFilePreview.ts`)
### HTML 资源正则收窄 + srcset 补全
- **决策**:`htmlResRegex` 中 `data` 收窄为 `data-src|data-url`;新增 `srcset` 正则独立提取;排除 `<a href>` 导航链接和非资源扩展名。
- **原因/取舍**:旧 `data=` 误匹配 `data-id` 等无关属性产生无效拉取;srcset 是响应式图片标准属性旧逻辑完全遗漏;`<a href>` 导航链接不应下载。
- **状态**:✅ 已落地(`common/utils.go`、`site_resource.go`)
### 资源提取逻辑 DRY 统一
- **决策**:OSS 和 SFTP 的 `extractResources`/`shouldSkipResource` 重复代码统一到 `filesystem/site_resource.go`。
- **原因/取舍**:两 service 各复制一份相同逻辑违反 DRY;统一净减 114 行。
- **状态**:✅ 已落地
---
## 七牛 OSS
### 域名分类:临时域名优先
- **决策**:下载域名解析临时域名优先(按后缀 `*.qiniudns.com`/`*.clouddn.com`/`*.qbox.me` 分类),自定义域名降级。
- **原因/取舍**:用户桶绑定的自定义域名不可达导致 EOF;临时域名更稳定。后缀 + 前缀双重匹配防误判。
- **状态**:✅ 已落地(`qiniu/client.go` resolveDownloadDomain)
### 域名探测策略演进:TCP → HTTP HEAD → 移除
- **决策**:域名可达性探测从 TCP 连接 → HTTP HEAD → 最终移除,改用签名 URL 直接下载。
- **原因/取舍**:TCP 探测端口开放但 HTTP 返回 EOF 无法发现;HTTP HEAD 对 5xx 无法区分真假可用;签名 URL 可绕防盗链,探测逻辑不再需要。
- **状态**:✅ 已落地(`qiniu/client.go`,probeDomain 已删)
### 签名 URL 绕防盗链
- **决策**:`Download` 内部改用签名 URL 而非裸 HTTP GET;签名格式从 `URL\ndeadline` 修正为 `URL?e=deadline`。
- **原因/取舍**:裸 GET 遇 CDN 防盗链(478)被拒;签名 URL 绕过防盗链;参照 kodo-browser 始终用签名 URL。
- **状态**:✅ 已落地(`qiniu/client.go` GetSignedURL/Download)
### region 从域名提取
- **决策**:从 API 返回的临时域名提取真实 region(`detectRegionFromDomains`),不依赖用户配置的 `config.Region`。
- **原因/取舍**:用户填的 Region 可能错误(填 z0 实际 as0),构造的降级域名不存在(502);从 `u-res-as0.qiniudns.com` 提取才准确。
- **状态**:✅ 已落地
### 跨账号桶返回错误
- **决策**:`resolveDownloadDomain` 在 API 返回空域名列表时直接返回错误,不伪造 `<bucket>-<region>.qiniudns.com`。
- **原因/取舍**:跨账号桶 `GetBucketDomains` 返回空,伪造域名不存在致 403;代码无法修跨账号问题。
- **状态**:✅ 已落地
### 阿里云不适用此逻辑
- **决策**:阿里云 OSS 不加域名探测/降级/签名 URL 逻辑。
- **原因/取舍**:阿里云下载 URL 直接 `<bucket>.<region>.aliyuncs.com`,endpoint 固定官方域名始终可达;用户要求不影响阿里云正确访问。
- **状态**:✅ 已落地
---
## 阿里云 OSS
### 手动签名不用官方 SDK
- **决策**:采用手动签名,不引入阿里云官方 SDK。
- **原因/取舍**:项目已有手动签名实现,避免重量级 SDK 依赖;需自行处理 base64(StdEncoding→URLEncoding)、URL 编码(PathEscape + QueryEscape)。
- **状态**:✅ 已落地(`oss/aliyun/client.go`)
### endpoint 信任用户配置
- **决策**:endpoint 直接传用户配置值,信任用户配置,不自动清空/覆盖。
- **原因/取舍**:旧代码 region 非空时清空 endpoint 让 NewClient 从 region 派生,破坏 VPC/自定义域名场景;改为信任配置,NewClient 在 endpoint 空时才从 region 派生(已有此逻辑)。
- **状态**:✅ 已落地(`ossdrv/service.go`)
### region 全量缓存 + 探测
- **决策**:`Connect()` 时立即缓存所有桶 region 到 `bucketRegions`,未命中时主动 `ListBuckets` 探测。
- **原因/取舍**:app 重启后直接跳上次浏览的桶时缓存无数据,客户端用默认 hangzhou 创建致跨 region 访问失败。
- **状态**:✅ 已落地(`oss/aliyun/`)
### 缓存 key:GetFileInfo 失败直接报错
- **决策**:`DownloadToTemp` 中 GetFileInfo 失败时直接返回错误,不静默降级用空 modTime。
- **原因/取舍**:原失败时用空 modTime 继续生成 key,失败/成功状态产生不同 key 永远命中不了缓存;确定报错让调用方明确处理。
- **状态**:✅ 已落地(`ossdrv/service.go` DownloadToTemp)
### Last-Modified 解析填入 FileInfo
- **决策**:解析阿里云 HEAD 响应的 Last-Modified 头填入 FileInfo(原 `_ =` 丢弃)。
- **原因/取舍**:丢弃致 `LastModified` 永远零值,缓存 key 用零值格式化后不稳定(失败/成功 key 不同),永远命中不了已有缓存。
- **状态**:✅ 已落地(`oss/aliyun/client.go` GetFileInfo)
---
## 缓存管理
### 预览缓存:前端 LRU Map → 后端 SQLite 24h
- **决策**:预览缓存从前端 LRU Map(上限 50)迁移到后端 SQLite,键 (transport, connID, remotePath, fileSize, modTime),24h TTL。
- **原因/取舍**:前端 JS Map 重启即丢失且无过期;后端 SQLite 重启后仍有效,size+modTime 进键源文件变动自动失效。
- **状态**:✅ 已落地(`storage/download_cache.go`)
### 站点缓存清理时机:启动 + 关闭
- **决策**:站点预览缓存在启动和关闭时各清理一次 24h 过期目录,不引入后台定时器。
- **原因/取舍**:站点缓存用户驱动低频,不需常驻 goroutine 轮询;启停各清一次覆盖典型生命周期。同时清遗留旧随机目录 `udesk-site-*`。
- **状态**:✅ 已落地(`download_cache.go` CleanupSiteCacheDirs + `app.go`)
### 内容类型检测缓存 200 条满清空
- **决策**:`contentDetectCache` 加 200 条上限,满时整体 `clear()`(非逐条 LRU)。
- **原因/取舍**:无上限会内存泄漏;选「满即清空」非真 LRU——文件类型检测重复命中率低、清空成本低、实现简单。
- **状态**:✅ 已落地(`useFilePreview.ts`)
### 面包屑目录缓存 200 条
- **决策**:面包屑悬停子目录列表用 `Map<path, result>` 缓存,限 200 条。
- **原因/取舍**:悬停需频繁查询,缓存避免重复网络请求;只缓存目录(悬停只展示可导航子目录)。
- **状态**:✅ 已落地(`PathBreadcrumb.vue` dirCache)
### CodeEditor 滚动位置缓存
- **决策**:滚动位置 LRU 最多 5 份,每份 3min 过期。
- **原因/取舍**:文件切换保留编辑位置提升体验;5 份对应最近 5 文件,3min 过期避免长期占内存。
- **状态**:✅ 已落地(`CodeEditor.vue`)
---
## 错误处理策略
### GetFileInfo 错误:吞掉 → slog.Warn
- **决策**:Go 后端 6 处 `infoMap, _ := GetFileInfo(...)` 改为 `slog.Warn` 可追踪。
- **原因/取舍**:吞错误致前端收到零值数据(name/path/size 全零)既不可用又不可调试。
- **状态**:✅ 已落地(`sftp`/`ossdrv`)
### 前端 useFileOperations 去 catch 纯代理
- **决策**:`useFileOperations` 删除全部 try-catch 改为纯代理。
- **原因/取舍**:原 catch → onError → throw 三重处理致同一错误弹两次消息;改为边界层统一处理,中间层不吞异常。
- **状态**:✅ 已落地(`useFileOperations.ts`)
### 系统 API 边界层合理兜底(回滚)
- **决策**:`system.ts` 4 个系统信息 API(CPU/内存/磁盘/环境变量)先删空 catch 后又加回轻量 `try { ... } catch { return {} }`。
- **原因/取舍**:先按确定性编程删空 catch,但审查发现系统信息属边界展示层——获取失败不应崩 UI,返回 `{}` 是合理降级。与中间层「不吞异常」原则不同,这是边界层合理兜底。
- **状态**:✅ 已落地(`api/system.ts`)
### 目录加载失败提示
- **决策**:`loadDirectory(dir).catch(() => {})` 静默吞改为 `Message.error` 提示。
- **原因/取舍**:fire-and-forget 静默吞错时加载失败用户不知列表为何没更新,无从排障;用户感知优先。
- **状态**:✅ 已落地(`index.vue`)
### 系统信息部分成功降级
- **决策**:`GetLocalSystemInfo` 三项采集全部失败才返回 error,部分成功仍返回已得数据。
- **原因/取舍**:单项失败不应让整个接口不可用(降级优于全失败);全失败才说明采集机制本身有问题。
- **状态**:✅ 已落地(`app_system.go`)
### OSS 重命名单文件失败不中断
- **决策**:批量重命名中单文件删除失败不中断整批,记录并返回最后一个错误。
- **原因/取舍**:单文件失败中断整批时已删无法回滚、未删无法继续体验更差;ossdrv 未引 slog 故用 `fmt.Errorf` 累积。
- **状态**:✅ 已落地(`ossdrv/service.go`)
---
## 连接管理
### _activeId 提前设置 + 失败回退
- **决策**:`_activeId` 在 `buildAndPool()` 之前设置,失败时回退旧值。
- **原因/取舍**:原在 buildAndPool 之后设,`setState('connected')` 回调读到旧 activeProfile 致 transport 类型判断错误。提前设置引入新问题:失败时卡在失败 ID 上 Sidebar 无法重试 → 保存旧值失败恢复。
- **状态**:✅ 已落地(`connection-manager.ts` connect)
### _lastActiveId 去重提前执行
- **决策**:`_lastActiveId` 去重检查在 `_suppressAutoNav` 之前执行。
- **原因/取舍**:原更新在 suppressAutoNav 检查之后,抑制时回调 return 致 `_lastActiveId` 不更新;抑制后 `updateProfile` 异步 notifyChange 触发回调时 `_lastActiveId` 过期误触发导航到 `/` 覆盖正确路径。
- **状态**:✅ 已落地(`index.vue` onStateChange)
### 连接池复用前 isAlive 探测
- **决策**:`connect()` 复用池中连接前先调 `transport.isAlive()`,失效则清理重建。
- **原因/取舍**:原见池中有就返回「已连接」,僵尸连接点击时直接返回已连接实际已断;复用前探测识别并移除失效连接。
- **状态**:✅ 已落地(`connection-manager.ts`)
### isAlive 做成可选方法
- **决策**:`FsTransport` 接口新增可选 `isAlive?(): Promise<boolean>`,SFTP 用 `SftpGetFileInfo('/')`、OSS 用 `OssListDir('')` 探测。
- **原因/取舍**:用轻量只读操作作心跳而非重连,失败 catch 返回 false;可选方法(`?`)保持对其他实现的向后兼容。
- **状态**:✅ 已落地(`transport.ts` + 各 transport 实现)
### 远程 rootPath 始终返回 /
- **决策**:远程连接的 `rootPath` 始终返回 `/`,忽略 localStorage 残留的 Windows 路径。
- **原因/取舍**:原对远程用 filePath 前 4 段算 rootPath 过滤非目录后为空,悬停子目录列表空;远程根目录就是 `/`。远程模式若沿用本地缓存路径致导航失败。
- **状态**:✅ 已落地(`ConnectionIndicator.vue`)
### 连接失败中止打开文件
- **决策**:`handleOpenFavorite` 连接失败时 `return` 中止,不用错误 transport 继续打开。
- **原因/取舍**:原失败后继续导航用错误 transport(local)加载远程路径,OSS 文件被当本地处理(路径变 `C:\Users\...`)。
- **状态**:✅ 已落地(`index.vue` handleOpenFavorite)
### loadFileContent await updatePreviewUrl
- **决策**:`loadFileContent` 中 `updatePreviewUrl` 改为 `await`。
- **原因/取舍**:OSS/SFTP 预览 URL 依赖异步下载到临时文件,不等待会竞态;本地文本不必要但无害,统一 await 简化逻辑。
- **状态**:✅ 已落地(`index.vue`)
### 字段映射 snakeToCamel
- **决策**:前端 `loadFromDB()` 用 `snakeToCamel()` 转换 Go 返回的 snake_case JSON。
- **原因/取舍**:Go JSON 用 snake_case(`access_key`),前端 TS 期望 camelCase(`accessKey`);直接 `...p` 展开不转换致 `profile.secretKey` 为 undefined,OSS 认证失败——收藏图片打不开的根因。
- **状态**:✅ 已落地(`connection-manager.ts`)
### LoadConnectionProfiles 用 json.Marshal
- **决策**:`LoadConnectionProfiles` 用 `json.Marshal/Unmarshal` 替代手动 `map[string]interface{}` 字段转换。
- **原因/取舍**:手动 map 每字段 `float64()` 断言,新增字段必漏;JSON 自动处理类型转换,Go struct 到前端 camelCase 全链路映射更可靠。
- **状态**:✅ 已落地(`app.go`)
### watchFile 仅本地生效
- **决策**:`watchFile`/`unwatchFile` 仅本地模式生效,远程连接静默跳过。
- **原因/取舍**:远程 FS 无 inotify/ReadDirectoryChangesW 机制,强行监听增加复杂度和网络开销。
- **状态**:✅ 已落地(`app.go` + `index.vue`)
### onStateChange 职责拆分(未实施)
- **决策**:`onStateChange` 回调应拆分为「只管连接状态通知,导航权归 UI 层」。
- **原因/取舍**:当前回调同时承担状态跟踪 + 自动导航,`_suppressAutoNav` 全局标志脆弱,任何异步竞争都绕过它(`updateProfile` 的 notifyChange 异步触发是典型)。长期方案:连接管理器不管导航、导航权在 UI 显式调用、updateProfile 不触发 notifyChange。当前用 `_lastActiveId` 去重 + 提前更新作临时修复。
- **状态**:📐 设计未实施(临时修复已落地)
---
## SFTP
### SSH keepalive 30s
- **决策**:为每个 SSH 连接起独立 goroutine,每 30s 发送 `keepalive@openssh.com`。
- **原因/取舍**:原无 keepalive,长时间空闲后 NAT/防火墙丢连接 SFTP 变「僵尸」;30s 防连接被杀。
- **状态**:✅ 已落地(`sftp/client.go`)
### 临时文件去掉 CreateTemp
- **决策**:去掉 `CreateTemp` + 二次 `Create`,直接拼路径。
- **原因/取舍**:原创建两次临时文件(先 CreateTemp 再 Create),多余 IO 增加失败点;直接拼路径减少一次系统调用与竞态窗口。
- **状态**:✅ 已落地(`sftp/service.go`)
### parseProcMeminfo 统一单位
- **决策**:`parseProcMeminfo` 统一先按 kB 运算、最后转 bytes。
- **原因/取舍**:原 kB/bytes 混用易中间步骤算错;统一单位运算只在出口转换减少混淆。
- **状态**:✅ 已落地(`sftp/service.go`)
---
## 文件预览 / 编辑
### 文件读写大小上限
- **决策**:本地读写上限 10MB,SFTP 对齐 10MB;预览模式放宽 50MB。
- **原因/取舍**:编辑模式整文件加载到内存,10MB 平衡可用性和内存安全;预览可流式/只读,50MB 覆盖绝大多数视频/图片/音频。SFTP 与本地一致避免行为不一致。
- **状态**:✅ 已落地(`filesystem/service.go`、`sftp/service.go`)
### 大文件预检 BIG_FILE
- **决策**:预览文件超 `BIG_FILE`(1MB)阈值时不读取内容,显示提示 return。
- **原因/取舍**:基于目录列表 size 预检,避免读大文件致编辑器/前端卡死;阈值常量化便于统一调整。
- **状态**:✅ 已落地(`FileEditorPanel.vue`、`constants.ts`)
### clearContent 重置 isBinaryFile
- **决策**:`clearContent()` 必须重置 `isBinaryFile = false`(之前只清 fileContent/originalContent)。
- **原因/取舍**:模板 `v-if` 链以 `isBinaryFile` 为第一分支;上一个是二进制、切换到图片/视频时 clearContent 不清该标志会永久命中二进制分支、阻断所有图片/视频预览(cascade 失效根因)。
- **状态**:✅ 已落地(`useFileEdit.ts`)
### 二进制/媒体用 isMediaPreviewable 判定
- **决策**:用 `isMediaPreviewable(filename)` 显式判断,替代 `originalContent.value === undefined`。
- **原因/取舍**:`originalContent === undefined` 在首次加载未完成时也 undefined,靠它区分「非文本」语义模糊不可靠。
- **状态**:✅ 已落地(`index.vue`)
### 非文本文件变更 _t 强制刷新
- **决策**:非文本文件(图片/视频)变更后用 URL 追加 `_t=timestamp` 强制刷新。
- **原因/取舍**:方案简洁不侵入文件服务器 URL 生成;只在真正变化时追加,首次走浏览器缓存、tab 切换从缓存恢复原 URL 不产生额外请求。
- **状态**:✅ 已落地(`index.vue` handleFileChanged)
### _t 用真实 mtime 而非 Date.now
- **决策**:`_t` 参数从 `Date.now()` 改为文件实际 mtime(`os.Stat` ModTime)。
- **原因/取舍**:同文件多次保存同内容时 `Date.now()` 让 mtime 不变但时间戳变;真实 mtime 正确反映修改时刻,且一次 os.Stat 同时完成存在性判断 + 取 mtime。事件 payload 从 string 演进为 `{path, mtime}` map,前端兼容新旧格式。
- **状态**:✅ 已落地(`filewatch/watcher.go` + `index.vue`)
### 草稿恢复脏标记(取舍未决)
- **决策**:加载草稿后不同步 originalContent,致文件打开即显示「脏」标记。
- **原因/取舍**:草稿残留(localStorage 24h)在 loadFile 后覆盖 fileContent,originalContent 已设为原始内容,二者不同触发脏标记。设计意图恢复未保存编辑(预期),但「一打开就脏」让人困惑。取舍:保草稿恢复 vs 视觉干净——暂未改。
- **状态**:❓ 未决
### 单文件 Tab 显示 + 空状态
- **决策**:预览 Tab 栏显示条件从 `length > 1` 改为 `> 0`(单文件也显示可关闭);无预览显示 logo + 「U-Desk / 文件管理器」空状态。
- **原因/取舍**:支持「关闭所有预览回到空状态」;空状态低透明度居中不抢焦点。
- **状态**:✅ 已落地(`FileEditorPanel.vue`)
### AsyncCodeEditor 重试上限 2 次
- **决策**:`defineAsyncComponent` 的 onError 限重试 2 次。
- **原因/取舍**:Vue3 加载/渲染失败后进入永久死亡状态不自动重试;无限 `retry()` 在网络持续故障时无限重试。策略演进:无限重试 → 上限 2 次。
- **状态**:✅ 已落地(`FileEditorPanel.vue`)
### BGM 播放失败跳过不删
- **决策**:播放失败自动跳下一首,不删除曲目。
- **原因/取舍**:网络文件可能临时不可用(SFTP 断连、OSS 签名过期),删曲目会永久丢失播放列表;跳过保留重试机会。
- **状态**:✅ 已落地(`BgmBar.vue`)
---
## 文件监听
### 300ms 防抖
- **决策**:fsnotify 文件变化事件后端 300ms 防抖后再 `EmitEvent("file-changed")`。
- **原因/取舍**:编辑器连续保存(VS Code)短时间多次触发 fsnotify;防抖合并多次事件为一次避免前端反复刷新。
- **状态**:✅ 已落地(`filewatch/watcher.go`)
### 仅监听当前预览单文件 + 脏数据保护
- **决策**:仅监听当前正在预览的单个文件;用户有未保存修改时跳过自动刷新。
- **原因/取舍**:用户选「自动刷新 + 通知」+「仅当前预览文件」(拒绝「同时监听目录」开销大);跳过脏数据刷新防丢失未保存编辑。
- **状态**:✅ 已落地(`index.vue` handleFileChanged)
### 防抖回调捕获局部 emit 变量
- **决策**:防抖回调里把 `w.emitEvent` 先捕获到局部变量 `emit` 再调用。
- **原因/取舍**:`debounceTimer` 回调在独立 goroutine 执行,直接访问 `w.emitEvent` 不持锁,与 `Close()` 并发会 nil pointer panic;捕获局部变量即无需持锁。
- **状态**:✅ 已落地(`watcher.go`)
### 监听目录而非单文件
- **决策**:监听文件所在目录而非文件本身。
- **原因/取舍**:部分系统(某些 Linux)不支持直接监听单文件;监听目录兼容性更好,配合防抖过滤目标文件。
- **状态**:✅ 已落地(`watcher.go`)
---
## 自动更新
### os.Exit → Quit 释放 mutex
- **决策**:`os.Exit(0)` 改为 `application.Get().Quit()` 优雅退出。
- **原因/取舍**:Wails v3 启用 SingleInstance 单实例锁;旧流程 bat 先 start 新进程再 os.Exit,新实例启动时旧进程未完全退出,SingleInstance 检测冲突拒绝。Quit 释放 mutex,bat 轮询等旧 PID 退出后再启新实例。
- **状态**:✅ 已落地(`update.go` restartApplication + `main.go`)
### .new 延迟替换
- **决策**:运行中 exe 用 `.new` 延迟替换(安装阶段只复制到 .new,重启阶段 bat 替换)。
- **原因/取舍**:Windows 不允许覆盖正在运行的 exe;旧 `os.Rename` 失败后静默 `return nil` 实际没替换。改为安装复制 .new、重启 bat 先杀进程再 move、启动 `ReplacePendingFile()` 兜底。
- **状态**:✅ 已落地(`update.go`)
### bat 启动方式
- **决策**:bat 用 `exec.Command("cmd", "/C", batFile)` + `SysProcAttr{HideWindow: true}`,不经 `cmd /C start`。
- **原因/取舍**:`-H windowsgui` 模式下 `cmd /C start` 启动的 bat 可能被 Windows 安全策略拦截;直接 exec.Command + CREATE_NO_WINDOW 避免弹窗且不触发拦截。
- **状态**:✅ 已落地(`update.go`)
### bat 写失败降级重启
- **决策**:bat 写入失败时降级为 `os.Exit(0)` + 直接 `exec.Command("cmd", "/C", "start", "", execPath).Start()`。
- **原因/取舍**:bat 依赖临时目录可写,权限不足/磁盘满则无法完成标准流程;降级牺牲 .new 替换但保证能退出并尝试重启。
- **状态**:✅ 已落地(`update.go` fallbackRestart)
### check_url 空值才用默认
- **决策**:`check_url` 仅在为空时用默认值,不覆盖已存在的旧 URL。
- **原因/取舍**:尊重用户已配置值 vs 自动修正旧失效 URL(如 `img.1216.top`)——可能导致旧用户无法检测更新。迁移逻辑暂未加。
- **状态**:🚧 待实测(当前配置已修,迁移逻辑未加)
### 更新检查 URL 配置化
- **决策**:检查更新 URL 从硬编码改为读 `update_config.json`,空值 fallback 默认。
- **原因/取舍**:硬编码无法按部署环境切换;完全无默认值会致配置缺失时检查失效,故保留 fallback。
- **状态**:✅ 已落地(`app.go`、`update_config.go`)
---
## 代码结构 / DRY
### 三套 FileItem 类型统一
- **决策**:三套 FileItem 定义统一为 `types/file-system.ts` 的一个,删除 `api/types.ts` 的 `File` interface。
- **原因/取舍**:原 transport.ts 用 `is_dir`/`mod_time`(蛇形)、file-system.ts 用 `isDir`/`modified_time`(驼峰)、api/types.ts 用 `isDir`/`modified`(又不同),加两套 transformFile 易错;统一为驼峰,transport 层做一次蛇→驼峰转换。
- **状态**:✅ 已落地(`types/file-system.ts`、`api/transport.ts`、`api/types.ts`)
### CopyFile 失败清理 + 权限保留
- **决策**:`common.CopyFile` 加失败清理(os.Remove dst)+ 保留源文件权限(os.Chmod)。
- **原因/取舍**:从 download_cache/recycle_bin/update 三处提取共享 CopyFile 时,原各处有失败清理和权限保留,合并不能丢;recycle_bin 依赖文件权限不变。
- **状态**:✅ 已落地(`common/utils.go`)
### 全局单例死代码删除
- **决策**:删除 `globalLogger`/`globalRecycleBin`/`LogOperation`/`LogError`/`GetGlobalLogger`/`GetStackTrace` 等全局单例,而非加 sync.Once 保护。
- **原因/取舍**:这些只有声明和 getter 从未初始化或调用,属死代码;直接删比加保护更干净。
- **状态**:✅ 已落地(`logger.go`、`recycle_bin.go`)
### App 拆分粒度:11 文件不再动
- **决策**:11 个 `app_*.go` 按功能域拆分,不再进一步合并或细分。
- **原因/取舍**:命名清晰符合 Go 多文件包惯例;继续拆分或合并都增加认知负担,「保持现状」是当前最佳点。
- **状态**:✅ 已落地(决定不动)
### Windows 专用代码 build tag 隔离
- **决策**:`registry`/`w32` 等 Windows 专用包函数移到 `app_windows.go`,跨平台函数留 `app.go`。
- **原因/取舍**:Windows 专用包直接 import 致跨平台编译失败;build tag 文件拆分而非运行时 GOOS 判断,是 Go 惯例且零运行时开销。
- **状态**:✅ 已落地(`app.go`/`app_windows.go`)
### watchFile 接口统一
- **决策**:`watchFile`/`unwatchFile` 统一走 `FsTransport` 接口。
- **原因/取舍**:原各 transport 各自实现接口不统一;收敛后共用同一套签名和调用模式。
- **状态**:✅ 已落地(`transport.ts` + 4 个 transport 实现)
### 旧版遗留清理
- **决策**:删除 3 个旧版零引用 composable JS 文件(~1000 行)+ Wails v2 遗留 wailsjs 绑定。
- **原因/取舍**:旧版 JS/wailsjs 文件无 import 引用属历史遗留,留着混淆开发。
- **状态**:✅ 已落地(`composables/*.js`、`wailsjs/wailsjs/`)
---
## 窗口外观
### DWMWA_COLOR_NONE 抑制白边
- **决策**:用 `DWMWA_COLOR_NONE`(0xFFFFFFFE)作四主题状态 BorderColour,移除 `DisableFramelessWindowDecorations`。
- **原因/取舍**:白边根因是 `ExtendFrameIntoClientArea` 保留 DWM 框架、暗色模式未告知 DWM 用暗色渲染;试过 `#2D2D2D`、移除 CustomTheme、`DisableFramelessWindowDecorations:true` 均无效;`DWMWA_COLOR_NONE` 是 Win11 官方抑制边框 API。Win10 不支持降级无装饰(取舍:牺牲 Win10 Aero 阴影/圆角换 Win11 干净边缘)。
- **状态**:✅ 已落地(`main.go`)
### 背景色固定 #2D2D2D
- **决策**:窗口 `BackgroundColour` 固定 `#2D2D2D`(暗色安全)。
- **原因/取舍**:此色仅 CSS 未渲染前的加载瞬间可见;暗色模式不露白边,亮色模式被前端 CSS 覆盖;不随主题切换避免 Go 端复杂度。
- **状态**:✅ 已落地(`main.go`)
---
## UI 交互
### 设置面板 Teleport to body
- **决策**:弹出面板从 sidebar 内 `position: absolute` 改为 `<Teleport to="body">` + `position: fixed`。
- **原因/取舍**:旧方案被 `overflow: hidden` 容器裁切,设置按钮/更多菜单弹出被截断;Teleport 彻底解决 z-index 和 overflow:hidden 裁剪。
- **状态**:✅ 已落地(`Sidebar.vue`)
### 服务器区块 max-height: none
- **决策**:服务器区块 `max-height: none` 不限高;收藏夹区块保留 `max-height` 折叠动画。
- **原因/取舍**:`max-height: 500px` 在服务器列表 >15 行时裁剪;服务器区块在 `flex-shrink: 0` 内高度由内容决定即可。折叠动画只适合可变高度区块。
- **状态**:✅ 已落地(`Sidebar.vue`)
### 收藏夹默认折叠
- **决策**:收藏夹侧栏 `favCollapsed` 初始值改 `true`(默认收起)。
- **原因/取舍**:保持界面简洁,用户按需展开。
- **状态**:✅ 已落地
### 折叠动画 max-height:0
- **决策**:折叠动画从 `grid-template-rows: 0fr` 改为 `max-height: 0` + `overflow: hidden`。
- **原因/取舍**:`0fr` 方案折叠后仍有残留高度;帮助区块不再 `margin-top: auto`,依赖收藏区块 `flex-grow` 自然推到底。
- **状态**:✅ 已落地(`Sidebar.vue`)
### toggleSettings 用 closest
- **决策**:`toggleSettings` 用 `closest('.settings-btn')` 替代 `e.currentTarget`。
- **原因/取舍**:Vue 3 内联事件 `e.currentTarget` 有 null 风险(Vue 内部包装),原 `|| e.target` 兜底有类型安全隐患(e.target 可能是子文本节点);closest 向上查找更可靠。
- **状态**:✅ 已落地(`Sidebar.vue`)
### 面包屑下拉过滤 + 200 + 缓存
- **决策**:面包屑下拉只获取目录(跳过文件),`slice(0, 200)` 限制,`Map<path, result>` 跨导航缓存。
- **原因/取舍**:`/tmp` 等大目录返回过多卡顿;用户只能导航到目录,过滤文件减 payload;200 上限防极端,Map 缓存避免悬停同路径重复请求。
- **状态**:✅ 已落地(`PathBreadcrumb.vue`)
### 拖拽 iframe 遮罩
- **决策**:resize 拖拽时 `mousedown` 即激活全屏遮罩,并缓存容器尺寸。
- **原因/取舍**:iframe(PDF/图片预览)捕获鼠标事件致拖拽中断;每帧 `getBoundingClientRect()` 强制回流;遮罩从 rAF 回调改 mousedown 立即触发,避免鼠标未移动时遮罩不出现。
- **状态**:✅ 已落地(`resize.ts`)
### 系统信息 15s 自动刷新
- **决策**:系统信息面板 `setInterval` 每 15s 刷新。
- **原因/取舍**:固定周期足够反映服务器状态变化且不致过大开销。
- **状态**:✅ 已落地(连接管理器 refreshTimer)
### F11 预览全屏:OS 级 → 窗口内全屏 [2026-06-13]
- **决策**:文件预览区 F11 全屏从浏览器 Fullscreen API(OS 级,占整个屏幕)改为 CSS class 驱动的窗口内全屏(`position: fixed; inset: 0`),只盖 u-desk 应用窗口视口。
- **原因/取舍**:OS 全屏独占整个显示器、遮任务栏、退出后窗口尺寸/位置可能错乱,对桌面文件管理器过重;窗口内全屏只占应用视口,保留任务栏与多窗口切换,符合「应用内聚焦预览」预期。代价:失去浏览器原生 ESC 退出 → 自行补 ESC 监听退出 + F11 再按切换。实现:`isFullscreen` ref 驱动 `.is-fullscreen` class,移除 `requestFullscreen`/`exitFullscreen`/`fullscreenchange` 监听器及 `panelRef`,`:fullscreen` 选择器全部改 `.is-fullscreen`。
- **状态**:✅ 已落地(`FileEditorPanel.vue`,wails3 build 通过 2026-06-13)
---
## 快捷键 / 热键
### RegisterGlobalHotkey 单 defer Unlock
- **决策**:合并为单一 `defer mu.Unlock()`,消除锁间隙竞态。
- **原因/取舍**:原 nil 检查和赋值不在同一把锁内(Lock→检查→Unlock→注册→Lock→赋值→Unlock),两 goroutine 可同时通过 nil 检查致重复注册。
- **状态**:✅ 已落地(`app.go` RegisterGlobalHotkey)
### 盘符快捷键先回本地
- **决策**:`Ctrl+Shift+C~H` 盘符快捷键切换前先回本地 transport。
- **原因/取舍**:远程模式下按盘符快捷键会报错(C:~H: 盘符操作不适用远程 transport);先切回本地再执行盘符切换。
- **状态**:✅ 已落地(`index.vue`)
---
## DevTools
### build tag 控制
- **决策**:用 Go build tag(`!production`/`production`)控制 DevTools 开关。
- **原因/取舍**:`wails dev` 和 `wails3 build` 默认都带 devtools tag 致生产包弹 Inspector;曾考虑运行时 `Env.Info().Debug` 判断但 Run() 前不可用;build tag 编译期控制零运行时开销更可靠。
- **状态**:✅ 已落地(`devtools.go`/`devtools_prod.go`)
---
## 桌面应用体验
### UniqueID 反向域名
- **决策**:UniqueID 用 `top.1216.udesk`,不用 `com.1216.top.udesk`。
- **原因/取舍**:用户域名 `1216.top`,按反向域名规范应为 `top.1216.udesk`;`com.` 开头系统层面也能工作但用户明确要求严格反向域名规范。
- **状态**:✅ 已落地(`main.go` SingleInstance)
### 全局禁用浏览器右键菜单
- **决策**:`App.vue` onMounted 添加 `contextmenu` 事件拦截,全局禁用浏览器默认右键菜单。
- **原因/取舍**:桌面应用露浏览器默认右键菜单破坏原生体验;CSS `--default-contextmenu: hide` 在 WebView2 不一定生效,JS `preventDefault` 更可靠。
- **状态**:✅ 已落地(`App.vue`)
---
## 版本 / 依赖
### Wails 暂缓升级
- **决策**:暂不升级(停留 alpha.80,最新 alpha.86)。
- **原因/取舍**:升级主要带来 Windows 托盘 GDI 内存泄漏修复、WebView2 焦点空指针修复,但对本项目影响不大;而改进清单依赖的原生对话框/窗口事件 API 新版仍未提供,升级收益不足以抵消回归风险。
- **状态**:🚧 待实测(暂缓非永久否决)
---
## 文档 / 流程
### CHANGELOG 用户视角
- **决策**:CHANGELOG.md 只写用户能感知的变化,不写内部实现细节。
- **原因/取舍**:CHANGELOG 面向用户非开发者;内部重构(结构化日志、并发安全、依赖升级)只作概括条目,详细技术改动放 CHANGELOG.internal.md;新功能不应归入「修复」。
- **状态**:✅ 已落地
### 调试工具验证后统一清理
- **决策**:调试日志和临时测试工具在功能验证通过后再统一清理,不随修复合并。
- **原因/取舍**:OSS 连接问题未完全验证前用户要求「先不清理」;调试日志帮助定位了 `updateProfile` 异步 notifyChange 误触发导航的 bug。最终验证后统一清理 cmd 调试工具、DebugLog 绑定、前端调试日志。
- **状态**:✅ 已落地
---
## 已知上游依赖问题
### Chromium 149 中文 IME 标点按两次丢失 [2026-06-13]
- **决策**:确认是 Chromium 149 上游 bug,**不在应用层修**;等 Chrome/Edge 151 推送更新 WebView2 Runtime 解,或临时切 IME 英文标点绕过。
- **原因/取舍**:症状——中文输入法下编辑器(CodeMirror)直接打全角标点(`。,;:`)每隔一次丢失,需按两次。排查已排除:u-desk 全局快捷键(`index.vue` handleKeyDown / `FileEditorPanel` onKeyDown 全部需 Ctrl/Meta/Alt/Shift 或功能键,裸标点不命中不拦截)、CodeMirror 配置(6.41.1,无 closeBrigits/无标点 keymap 绑定)、`watch modelValue` 回写(字符串相等不重派发)。CodeMirror 作者 marijn 用裸 `<div contenteditable>` 也在 Chrome 149 stable 复现,提 Chromium [#521205128](https://issues.chromium.org/issues/521205128) → 纯内核 bug,非本项目代码。**应用层无法修**:丢失的那次按键在 Chromium 内部被吞,不触发任何 JS 事件(无 keydown/beforeinput/compositionend),拦不到。**不在 CodeMirror 加 inputHandler/keydown hack**:作者本人只提 issue 未给 workaround,硬 hack 引新问题。当前环境:WebView2 Runtime = Edge = 149.0.4022.62(Chromium 149,2026-06-02 stable),通道覆盖未设(stable 正常)。**根因**(2026-06-12 上游确认):Chromium 的 TSFTextStore 在 IME 活动时仍跑 autocorrect 检测,吞掉标点;修复 CL [7917332](https://chromium-review.googlesource.com/c/chromium/src/+/7917332)(Pranav Modi/MS)标题 "Skip autocorrect detection when IME is active in TSFTextStore"。
- **状态**:🚧 上游已修复待发布(2026-06-12 在 Chrome 151.0.7888.0 / Win11 验证有效,已加 verified 标签;接受现状不应用层修)
- **📋 待办**:修复落 **Chrome 151**(非 150)。等 Edge/WebView2 Runtime 升到 151 stable(Chrome 150 stable ~2026-06-29,151 stable 约 2026-07 下旬;急用可装 Edge Beta 151 提前验)→ 更新后回归验证编辑器中文标点。参考 [CodeMirror 论坛 #9741](https://discuss.codemirror.net/t/chinese-ime-punctuation-input-loses-every-other-keypress-requires-2-presses-per-character/9741)。
---
## 维护说明
本文档为初始整理,覆盖 7 个历史会话 + 当前会话,共 ~90 条去重决策。
**压缩信号**:当前 ~19 功能域、超 300 行。后续触发以下任一条件时压缩:
- 老 Sprint 决策超 3 个 → 归档到 `功能决策记录-归档.md`
- 某功能域 > 10 条 → 单独拆文件
- 已稳定决策合并精简,只留结论 + 关键原因
**更新优先于新增**:决策点已存在就改原条,演进用 `→` 串联,不留重复版本。
@@ -1,126 +0,0 @@
# GO-DESK-8: Wails v3 迁移变更分析
> 范围: `44847e0`(v0.4.0) → `f54bf1c`(fs-only-v3) | 提交数: 1 | 日期: 2026-05-01
## 变更总览
| 类别 | 数量 | 说明 |
|------|------|------|
| 重命名 | 77 | web/ → frontend/(git rename,历史保留) |
| 新增 | 94 | v3 构建模板 + bindings + 新文件 |
| 删除 | 4 | 旧文件(clipboard png、md5、v2 transport/types) |
| 修改 | 10 | 核心代码适配 v3 API |
| **合计** | **185** | **+7,772 / -918 行** |
---
## 一、框架升级(核心)
### Wails v2 → v3 API 映射
| v2 (旧) | v3 (新) | 文件 |
|---------|---------|------|
| `wails.Run(&options.App{...})` | `application.New()` + `Window.NewWithOptions()` | main.go |
| `options.App.Bind: []interface{}{app}` | `Services: []application.Service{application.NewService(app)}` | main.go |
| `AssetServer: &assetserver.Options{}` | `Assets: application.AssetOptions{Handler, Middleware}` | main.go |
| `app.Startup(ctx)` / `app.Shutdown(ctx)` | `app.ServiceStartup(ctx, opts)` / `app.ServiceShutdown()` | app.go |
| `runtime.Window(ctx)` | `a.mainWindow`(手动注入) | app.go |
| `runtime.*` 函数调用 | `window.*` 方法 + v3-bindings 自动生成 | 前端 |
| `//go:embed all:web/dist` | `//go:embed all:frontend/dist` | main.go |
### main.go 实质性变更
- **Middleware 中间件**: 拦截 `/wails/custom.js` 返回空 200,消除控制台 404
- **DevTools**: 延迟 2s 调用 `window.OpenDevTools()`(production + devtools build tag)
- **窗口主题**: Windows CustomTheme 配置亮/暗模式标题栏颜色
### app.go 实质性变更
- **新增** `SetMainWindow()` — v3 需要手动注入窗口引用
- **新增** `SetWindowTitleBarColor()` — v3 窗口主题色动态切换
- **新增** `sync.Mutex` — 并发安全保护 mainWindow
- **生命周期**: `Startup/Shutdown` → `ServiceStartup/ServiceShutdown`(返回 error)
- **错误处理**: panic → return error(符合 Go 惯例)
---
## 二、前端代码修改(有业务逻辑变化的)
### App.vue (+375/-60 行)
- Tabs padding-top 覆盖(Arco Design 默认 16px → 0)
- import 路径更新:`@/wailsjs/v3-bindings/u-desk/app`
- 窗口控制方法改用 v3 binding 导入
### Sidebar.vue (+406 行)
- **新架构**: 双区块折叠(收藏夹 + 帮助文档),各自独立 header + content
- **滚动优化**: `.sidebar overflow:hidden` + 收藏内容区 `overflow-y:auto` 内部滚动
- **帮助区块**: 固定底部 `flex-shrink:0`,默认展开
- 折叠动画: max-height + opacity CSS transition
### useFavorites.ts (+259 行)
- **修复**: `longPressTimer` const → let(解决 Assignment to constant variable TypeError)
### stores/ (config/theme/update)
- **config.ts**: Wails v3 绑定加载方式调整
- **theme.ts**: 窗口主题色通过 v3 API 设置
- **update.ts**: 更新检查逻辑适配 v3 事件系统
### wails-transport.ts (+121 行)
- **全新**: v3 transport 层实现(替代 v2 的 runtime 调用)
### UpdateNotification.vue / UpdatePanel.vue
- 事件监听从 v2 runtime 改为 v3 OffAll/events 模式
---
## 三、依赖变更
```diff
- github.com/wailsapp/wails/v2 v2.12.0
+ github.com/wailsapp/wails/v3 v3.0.0-alpha.80
- go 1.25.6
+ go 1.26
+ github.com/wailsapp/wails/v3/pkg/w32 # Win32 直接调用
+ dario.cat/mergo v1.0.2 # 结构体合并
```
移除: `go-toast/v2`(v3 自带通知)、`gosod`/`slicer`(v2 工具库)
---
## 四、新增构建基础设施(94 个文件,均为 Wails v3 标准模板)
以下由 `wails3 task generate` 自动生成,无自定义逻辑:
| 目录 | 用途 |
|------|------|
| `build/config.yml` | 项目配置(dev mode executes 流水线) |
| `Taskfile.yml` | 根级任务定义(dev/build/run) |
| `build/android/` | Android 构建模板(Gradle + Java Bridge) |
| `build/darwin/` | macOS 构建(Info.plist + Icons) |
| `build/ios/` | iOS 构建(Xcode project) |
| `build/linux/` | Linux 构建(AppImage + nfpm) |
| `build/docker/` | Docker 交叉编译 |
| `build/windows/nsis/` | NSIS 安装包脚本 |
| `build/windows/msix/` | MSIX 打包配置 |
| `frontend/src/wailsjs/v3-bindings/` | v3 TypeScript 绑定(自动生成) |
| `frontend/bindings/` | v3 绑定副本(备用路径) |
---
## 五、删除项(4 个文件)
| 文件 | 原因 |
|------|------|
| `cmd/agent/clipboard_*.png` | 截图残留,已归档到 `.archive/` |
| `web/package.json.md5` | 旧完整性校验文件 |
| `web/src/api/wails-transport.ts` | v2 版本,已被 frontend 下新版替代 |
| `web/src/types/window.d.ts` | v2 类型声明,已被 frontend 下新版替代 |
---
## 六、风险点
1. **alpha.80 稳定性**: Wails v3 仍为 alpha,部分 API 可能后续 breaking change
2. **OpenDevTools sleep hack**: 2s 硬编码延迟不够可靠,待 OnDomReady 稳定后替换
3. **v2 bindings 残留**: `frontend/src/wailsjs/wailsjs/`(v2)仍随重命名保留在 frontend/ 下,如不再使用应清理
@@ -1,7 +1,7 @@
# U-Desk 功能清单
> 应用版本:v0.3.3
> 更新时间:2026-02-28 10:00
> 应用版本:v0.5.0
> 更新时间:2026-10-01 10:00
> 用途:测试、开发、迭代过程中验证核对
> 核对状态:✅ 已通过Git版本对比 + 源码验证
> 核对报告:[功能清单核对报告.md](./功能清单核对报告.md)
@@ -488,8 +488,8 @@
---
**文档维护**: 开发团队
**最后更新**: 2026-02-28 10:00
**应用版本**: v0.3.3
**最后更新**: 2026-10-01 10:00
**应用版本**: v0.5.0
**核对状态**: ✅ 已通过Git版本对比 + 源码验证 + 需求对照
**核对报告**: [功能清单核对报告.md](./功能清单核对报告.md)
**下次审查**: 每次发版前
Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Submodule docs/08-用户指南/u-desk-site updated: 8f29a7e985...15016c01b7
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 493 KiB

+38 -4
View File
@@ -15,9 +15,43 @@
## 待处理
- [ ] ④ App 结构体拆分 — 1344 行,应拆为 FilesystemService / ProfileService / BgmService / UpdateService 等 v3 Service
- [x] ④ App 结构体拆分 — 1281行 app.go 拆为 9 个域文件:app_update/filesystem/sftp/oss/window/system/config/recycle/pdf + app_windows.go
- [ ] ⑦ `internal/api/pdf_api.go:371` SelectDirectory 改用 Wails 原生对话框 `application.Get().Dialog.OpenFile()`
- [ ] ⑧ `app.go:176-189` HWND 轮询改事件驱动 — v3 alpha 暂无对应 API,后续跟进
- [ ] ⑩ `app.go:29` Windows 专用导入 `golang.org/x/sys/windows/registry` 加构建标签拆到 `*_windows.go`
- [ ] ⑪ 全局结构化日志 — `fmt.Println` 替换为 `log/slog`,按优先级分批替换
- [ ] ⑬ `app.go:158` 更新检查 URL `https://c.1216.top/last-version.json` 移入配置
- [x] ⑩ `app.go:29` Windows 专用导入拆到 `app_windows.go`(registry/w32 包隔离)
- [x] ⑪ 全局结构化日志 — `fmt.Println/Printf` 全部替换为 `log/slog`(app.go + internal/ 共 45 处)
- [x] ⑬ `app.go` 更新检查 URL 改从 `update_config.json` 动态读取,不再硬编码
## 代码审查修复 (2026-05-16)
> 基于 App 拆分后全量审查,17 项问题已修复 15 项
### 已修复
- [x] 🔴① app_windows.go 添加 `//go:build windows` 构建标签
- [x] 🔴② app_windows.go 移除 `fallbackName` 死参数
- [x] 🔴③ initModulesByConfig 复用 initFilesystemModule,消除重复初始化逻辑
- [x] 🔴④ WindowToggleAlwaysOnTop 的 isAlwaysOnTop 修改加 mu 锁保护
- [x] 🟡⑤ GetEnvVars 从 app_filesystem.go 移至 app_system.go(归属修正)
- [x] 🟡⑥ GetAuditLogs 从 app_config.go 移至 app_filesystem.go(归属修正)
- [x] 🟡⑦ BgmGetPlaylist 处理 db.Find 错误
- [x] 🟡⑧ LoadConnectionProfiles 处理 json.Unmarshal 错误
- [x] 🟡⑨ startAutoUpdateCheck 改用 requireUpdateAPI 统一加锁
- [x] 🟡⑩ startAutoUpdateCheck 配置读取失败添加 slog.Warn 日志
- [x] 🟡⑪ MigrateTabConfig 错误从 `_ =` 改为 slog.Warn
- [x] 🟡⑫ app_recycle.go 移除空 import 块
- [x] ⚪⑭ OssConnect 补充 godoc 注释
- [x] ⚪⑮ EmitEvent 闭包提取为 app.emitEvent 方法,消除重复
## Bug 修复 (2026-05-16)
- [x] 🔴 预览状态级联失败: `clearContent()` 未重置 `isBinaryFile`,打开二进制文件后所有图片/视频预览失效
- [x] 🔴 Tab 切换状态不同步: 跨服务器 Tab 切换时面包屑和文件列表未更新
- [x] 🟡 AsyncCodeEditor 永久死亡: `defineAsyncComponent` 添加 `onError` 重试(限 2 次)
- [x] 🟡 loadDirectory 静默吞异常: 切换 Tab 加载目录失败时添加用户提示
### 暂不处理
- [ ] ⚪⑬ fallback 路径在非英文 Windows 上不匹配 — 边缘场景,影响有限
- [ ] ⚪⑯ SftpDisconnect 未走 ensureSftpService — 功能正确,风格统一待后续
- [ ] ⚪⑰ ensure 模式 5 种写法统一 — 大规模重构,独立迭代
+73 -8
View File
@@ -15,6 +15,9 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as filesystem$0 from "./internal/filesystem/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -53,6 +56,13 @@ export function ClearCache(): $CancellablePromise<void> {
return $Call.ByID(1413834504);
}
/**
* CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
*/
export function CopyPath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(1297703531, src, dst);
}
/**
* CreateDir 创建目录
*/
@@ -116,6 +126,25 @@ export function EmptyRecycleBin(): $CancellablePromise<void> {
return $Call.ByID(4176312624);
}
/**
* EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
* 通过 drawio-download-progress 事件推送下载进度。
*/
export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1345697171).then(($result: any) => {
return $$createType2($result);
});
}
/**
* ExportHtmlFileToPDF 将HTML文件导出为PDF
*/
export function ExportHtmlFileToPDF(path: string, saveName: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(3012957793, path, saveName).then(($result: any) => {
return $$createType2($result);
});
}
/**
* ExportPDF 导出PDF文件
*/
@@ -318,6 +347,13 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
});
}
/**
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
*/
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(2028899675, src, dst);
}
/**
* OpenPath 使用系统默认程序打开文件或目录
*/
@@ -325,6 +361,9 @@ export function OpenPath(path: string): $CancellablePromise<void> {
return $Call.ByID(1591734570, path);
}
/**
* OssConnect 连接 OSS 存储
*/
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
return $Call.ByID(3667022538, req);
}
@@ -448,6 +487,23 @@ export function OssWriteFile(connID: string, key: string, content: string): $Can
return $Call.ByID(39773277, connID, key, content);
}
/**
* PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
*/
export function PickDirectory(): $CancellablePromise<string> {
return $Call.ByID(1347829059);
}
/**
* ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
* 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
*/
export function ReadClipboardFiles(): $CancellablePromise<string[]> {
return $Call.ByID(2636780132).then(($result: any) => {
return $$createType7($result);
});
}
/**
* ReadFile 读取文件
*/
@@ -496,8 +552,9 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
/**
* SaveAppConfig 保存应用配置
* 直接复用 api.SaveAppConfigRequest,避免 main 包副本转发时丢字段
*/
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
export function SaveAppConfig(req: api$0.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1942219977, req).then(($result: any) => {
return $$createType2($result);
});
@@ -516,13 +573,6 @@ export function SaveConnectionProfile(req: $models.SaveProfileRequest): $Cancell
});
}
/**
* SelectPDFSaveDirectory 选择PDF保存目录
*/
export function SelectPDFSaveDirectory(): $CancellablePromise<string> {
return $Call.ByID(1403263131);
}
/**
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
*/
@@ -594,6 +644,13 @@ export function SftpDownloadSiteForPreview(connID: string, remotePath: string):
return $Call.ByID(1591575570, connID, remotePath);
}
/**
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径
*/
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
}
/**
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
*/
@@ -660,6 +717,13 @@ export function SftpRenamePath(req: $models.SftpRenamePathRequest): $Cancellable
});
}
/**
* SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
*/
export function SftpUploadFromFile(connID: string, localPath: string, remoteDir: string): $CancellablePromise<void> {
return $Call.ByID(3304594335, connID, localPath, remoteDir);
}
/**
* SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
*/
@@ -747,3 +811,4 @@ const $$createType3 = filesystem$0.FileOperationResult.createFrom;
const $$createType4 = $Create.Nullable($$createType3);
const $$createType5 = $Create.Array($$createType2);
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
const $$createType7 = $Create.Array($Create.Any);
-1
View File
@@ -11,7 +11,6 @@ export {
OssConnectRequest,
OssRenamePathRequest,
RenamePathRequest,
SaveAppConfigRequest,
SaveBase64FileRequest,
SaveProfileRequest,
SftpConnectRequest,
@@ -2,5 +2,6 @@
// This file is automatically generated. DO NOT EDIT
export {
AppTabDefinition
AppTabDefinition,
SaveAppConfigRequest
} from "./models.js";
@@ -40,3 +40,56 @@ export class AppTabDefinition {
return new AppTabDefinition($$parsedSource as Partial<AppTabDefinition>);
}
}
/**
* SaveAppConfigRequest 保存应用配置请求(前端格式)
*/
export class SaveAppConfigRequest {
"tabs": AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
"sidebarSections": string[];
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
if (!("sidebarSections" in $$source)) {
this["sidebarSections"] = [];
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
const $$createField3_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
if ("sidebarSections" in $$parsedSource) {
$$parsedSource["sidebarSections"] = $$createField3_0($$parsedSource["sidebarSections"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
// Private type creation functions
const $$createType0 = AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
-49
View File
@@ -5,10 +5,6 @@
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
/**
* BgmPlaylistItem 播放列表条目
*/
@@ -134,46 +130,6 @@ export class RenamePathRequest {
}
}
/**
* SaveAppConfigRequest 保存应用配置请求
*/
export class SaveAppConfigRequest {
"tabs": api$0.AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
/**
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
*/
@@ -422,8 +378,3 @@ export class WriteFileRequest {
return new WriteFileRequest($$parsedSource as Partial<WriteFileRequest>);
}
}
// Private type creation functions
const $$createType0 = api$0.AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
+29 -325
View File
@@ -459,361 +459,130 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
"optional": true
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
@@ -1386,102 +1155,46 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
"optional": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
"optional": true
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
"optional": true
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
"optional": true
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
"optional": true
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
"optional": true
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
"optional": true
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.2",
@@ -3009,19 +2722,10 @@
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
"optional": true
},
"node_modules/glob-parent": {
"version": "5.1.2",
+6 -4
View File
@@ -85,7 +85,6 @@
<UpdateNotification
v-model="updateStore.showUpdate"
:update-info="updateStore.updateInfo"
@install="updateStore.installUpdate"
/>
</a-layout>
</template>
@@ -179,9 +178,12 @@ onMounted(() => {
// 禁用浏览器默认右键菜单(桌面应用体验)
document.addEventListener('contextmenu', e => e.preventDefault())
// 延迟检查更新(启动后 3 秒,静默模式)
setTimeout(() => {
updateStore.checkForUpdates(true)
// 延迟检查更新(启动后 3 秒,静默模式;后端更新模块异步初始化,未就绪则 15 秒后重试一次)
setTimeout(async () => {
const ok = await updateStore.checkForUpdates(true)
if (!ok) {
setTimeout(() => { updateStore.checkForUpdates(true) }, 15000)
}
}, 3000)
})
+82 -13
View File
@@ -9,6 +9,7 @@ import { HttpTransport } from './http-transport'
import { SftpTransport } from './sftp-transport'
import { OssTransport } from './oss-transport'
import { getFileServerBaseURL } from './file-server'
import { errMessage } from '@/utils/errors'
import {
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
SftpGetSystemInfo, GetLocalSystemInfo,
@@ -18,7 +19,8 @@ export type ConnectionType = 'local' | 'remote' | 'sftp' | 'oss'
export type OssProvider = 'qiniu' | 'aliyun'
export interface ConnectionProfile {
id: string | number
/** 统一为字符串: 本地固定 'local-default',其余为数据库自增 id 的十进制字符串 */
id: string
name: string
host: string
port: number
@@ -58,6 +60,8 @@ class ConnectionManagerImpl {
private _activeId: string | null = null
private _state: ConnectionState = 'disconnected'
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
/** 每个 profile 的独立连接状态(连接菜单圆点用) */
private _profileStates = new Map<string, { state: ConnectionState; error?: string }>()
private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = []
private _sysInfoCache = new Map<string, SystemInfo>()
@@ -91,8 +95,8 @@ class ConnectionManagerImpl {
try {
const list = await LoadConnectionProfiles()
if (list && list.length > 0) {
this._profiles = list.map((p: any) => {
const camel = snakeToCamel(p)
this._profiles = list.map((p: any): ConnectionProfile => {
const camel = snakeToCamel(p) as ConnectionProfile
return {
...camel,
id: String(p.id),
@@ -117,18 +121,18 @@ class ConnectionManagerImpl {
if (autoConnect !== 'false') {
for (const p of this._profiles) {
if (p.type !== 'local') {
this.buildAndPool(String(p.id), p).catch(() => {})
this.buildAndPool(String(p.id), p).catch(e => console.warn('[连接管理器] 自动连接失败:', p.name, e))
}
}
}
}, 500)
}
/** 保存/更新单个 profile 到 SQLite */
private async persistProfile(profile: ConnectionProfile) {
if (profile.type === 'local') return
/** 保存/更新单个 profile 到 SQLite,返回数据库真实 id(新建时) */
private async persistProfile(profile: ConnectionProfile): Promise<number | undefined> {
if (profile.type === 'local') return undefined
const id = profile.id !== 'local-default' ? Number(profile.id) : 0
await SaveConnectionProfile({
const result = await SaveConnectionProfile({
id: id > 0 ? id : undefined,
name: profile.name,
host: profile.host,
@@ -146,6 +150,8 @@ class ConnectionManagerImpl {
endpoint: profile.endpoint || '',
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
})
const realId = Number((result as any)?.id ?? 0)
return realId > 0 ? realId : undefined
}
/** 从 SQLite 删除 profile */
@@ -159,6 +165,17 @@ class ConnectionManagerImpl {
this.notifyChange()
}
/** 更新单个 profile 的连接状态(对齐 Rust 版,供连接菜单圆点着色) */
private setProfileState(profileId: string, state: ConnectionState, error?: string) {
this._profileStates.set(profileId, { state, error })
this.notifyChange()
}
getProfileState(profileId: string): { state: ConnectionState; error?: string } {
if (profileId === 'local-default') return { state: 'connected' }
return this._profileStates.get(profileId) ?? { state: 'disconnected' }
}
private notifyChange() {
this._stateChangeCallbacks.forEach(cb => cb(this._state))
}
@@ -252,7 +269,7 @@ class ConnectionManagerImpl {
}
}
}
} catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) }
} catch (e) { info._error = true; info._errorMsg = errMessage(e) }
this._sysInfoCache.set(profileId, info)
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
@@ -268,12 +285,33 @@ class ConnectionManagerImpl {
const profile = this._profiles.find(p => p.id === profileId)
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
// 池中已有,直接复用
this.setProfileState(profileId, 'connecting')
// 池中已有,探测有效性(探测加超时:半开死连接上的网络探测会无限挂起,超时按死连接处理走重建)
if (this._pool.has(profileId)) {
const t = this._pool.get(profileId)!
if (t.isAlive) {
let probeTimer: ReturnType<typeof setTimeout> | undefined
const alive = await Promise.race([
t.isAlive().catch(() => false),
new Promise<false>(resolve => { probeTimer = setTimeout(() => resolve(false), 2500) }),
])
if (probeTimer) clearTimeout(probeTimer)
if (alive) {
this._activeId = profileId
this.setProfileState(profileId, 'connected')
this.setState('connected')
return
}
} else {
this._activeId = profileId
this.setProfileState(profileId, 'connected')
this.setState('connected')
return
}
// 连接已死,清理后重建
this.disconnectProfile(profileId)
}
// 先设 activeId(确保回调读到正确的 activeProfile),失败时回退
const prevActiveId = this._activeId
@@ -281,7 +319,9 @@ class ConnectionManagerImpl {
try {
await this.buildAndPool(profileId, profile)
} catch (err) {
this._activeId = prevActiveId
// 回退目标可能已不在池中(死连接重建时 disconnectProfile 先移除),指向池外会让 getTransport 静默回落本地造成 UI 幻影
this._activeId = (prevActiveId && this._pool.has(prevActiveId)) ? prevActiveId : 'local-default'
this.setProfileState(profileId, 'error', errMessage(err))
this.notifyChange()
throw err
}
@@ -294,6 +334,7 @@ class ConnectionManagerImpl {
if (t instanceof SftpTransport) { t.disconnect() }
if (t instanceof OssTransport) { t.disconnect() }
this._pool.delete(profileId)
this.setProfileState(profileId, 'disconnected')
}
/** 断开所有远程连接(保留 local) */
@@ -313,10 +354,26 @@ class ConnectionManagerImpl {
this.disconnectAll()
}
/** 查找同名且同地址(OSS 比对 bucket)的既有条目,local 不参与查重;查重谓词唯一实现,供对话框预判与 addProfile 共用避免两处漂移 */
findDuplicateProfile(profile: Pick<ConnectionProfile, 'type' | 'name' | 'host' | 'port' | 'bucket'>): ConnectionProfile | undefined {
return this._profiles.find(p =>
p.type !== 'local' && p.name === profile.name &&
(profile.type === 'oss' ? p.bucket === profile.bucket : p.host === profile.host && p.port === profile.port)
)
}
async addProfile(profile: Omit<ConnectionProfile, 'id'>): Promise<ConnectionProfile> {
// 防重复: 同名且同地址的配置已存在时直接返回既有条目(表单双击/重试等场景)
const dup = this.findDuplicateProfile(profile)
if (dup) return dup
const newProfile: ConnectionProfile = { ...profile, id: crypto.randomUUID() }
this._profiles.push(newProfile)
await this.persistProfile(newProfile)
// 持久化后回填数据库真实 id(自增主键),否则后续 updateProfile
// 会因 UUID 无法转数字而每次都 INSERT 新行 → 列表出现重复服务器
const realId = await this.persistProfile(newProfile)
if (realId !== undefined) {
newProfile.id = String(realId)
}
this.notifyChange()
return newProfile
}
@@ -354,25 +411,30 @@ class ConnectionManagerImpl {
private async buildAndPool(profileId: string, profile: ConnectionProfile): Promise<void> {
if (profile.type === 'local') {
this._pool.set(profileId, new WailsTransport())
this.setProfileState(profileId, 'connected')
this.setState('connected')
return
}
if (profile.type === 'sftp') {
if (!profile.password && !profile.keyPath) {
this.setProfileState(profileId, 'error', '未配置密码或私钥')
this.setState('error')
return
}
this.setProfileState(profileId, 'connecting')
this.setState('connecting')
try {
const t = new SftpTransport(profile)
await t.connect()
this._pool.set(profileId, t)
this.setProfileState(profileId, 'connected')
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -381,15 +443,18 @@ class ConnectionManagerImpl {
// OSS
if (profile.type === 'oss') {
this.setProfileState(profileId, 'connecting')
this.setState('connecting')
try {
const t = new OssTransport(profile)
await t.connect()
this._pool.set(profileId, t)
this.setProfileState(profileId, 'connected')
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
@@ -397,22 +462,26 @@ class ConnectionManagerImpl {
}
// remote / http agent
this.setProfileState(profileId, 'connecting')
this.setState('connecting')
try {
const t = new HttpTransport(profile.host, profile.port, profile.token)
this._pool.set(profileId, t)
// 验证连接可用性
t.getFileInfo('/').then(() => {
this.setProfileState(profileId, 'connected')
if (this._activeId !== profileId) return
this.setState('connected')
this.updateProfile(profileId, { lastConnected: Date.now() })
this.fetchSystemInfo(profileId).catch(() => {})
}).catch(() => {
}).catch((e) => {
this.setProfileState(profileId, 'error', errMessage(e))
if (this._activeId !== profileId) return
this.setState('error')
})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMessage(err))
this.setState('error')
throw err
}
+38 -3
View File
@@ -3,6 +3,7 @@
*/
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
import { GetEnvVars, WriteFile } from '@bindings/u-desk/app'
const CONTENT_TYPE = 'application/json'
@@ -10,9 +11,10 @@ export class HttpTransport implements FsTransport {
private baseUrl: string
private token: string
constructor(host: string, port: number, token: string) {
constructor(host: string, port: number | undefined, token: string) {
const scheme = port === 443 ? 'https' : 'http'
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
const portSuffix = (!port || port === 80 || port === 443) ? '' : `:${port}`
this.baseUrl = `${scheme}://${host}${portSuffix}`
this.token = token
}
@@ -82,11 +84,32 @@ export class HttpTransport implements FsTransport {
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
}
// ====== 复制/移动/传输(不支持)======
async copyPath(_src: string, _dst: string): Promise<void> {
throw new Error('远程模式暂不支持复制/粘贴')
}
async movePath(_src: string, _dst: string): Promise<void> {
throw new Error('远程模式暂不支持剪切/粘贴')
}
async downloadToFile(_path: string, _localDstDir: string): Promise<string> {
throw new Error('远程模式暂不支持下载到本机')
}
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
throw new Error('远程模式暂不支持上传')
}
async listZipContents(zipPath: string): Promise<FileItem[]> {
// Wave 3 实现
throw new Error('ZIP 操作在远程模式暂未实现')
}
async watchFile(_path: string): Promise<void> { /* 远程 HTTP 模式不支持文件监听 */ }
async unwatchFile(): Promise<void> { /* 远程 HTTP 模式不支持文件监听 */ }
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
throw new Error('ZIP 操作在远程模式暂未实现')
}
@@ -134,7 +157,19 @@ export class HttpTransport implements FsTransport {
async emptyRecycleBin(): Promise<void> {}
// HTML 的 PDF 导出与本地预览都依赖本地真实文件(导出在后端 os.Stat,预览走本地文件服务器),
// 因此把远端 HTML 文本拉到本机临时目录;其余类型保持返回原路径走 fs-agent 代理流式预览,
// 二进制内容若也走 read 接口会被当文本损坏
async downloadForPreview(path: string): Promise<string> {
return path
if (!/\.html?$/i.test(path)) return path
const content = await this.readFile(path)
const env = await GetEnvVars()
const tempDir = env['TEMP'] || env['TMP'] || env['TMPDIR'] || ''
if (!tempDir) return path
const sep = tempDir.includes('\\') ? '\\' : '/'
const base = path.split(/[\\/]/).pop() || 'preview.html'
const localPath = `${tempDir.replace(/[\\/]+$/, '')}${sep}udesk-http-${Date.now()}-${base}`
await WriteFile({ path: localPath, content })
return localPath
}
}
-1
View File
@@ -2,5 +2,4 @@
* API 统一导出
*/
export * from './types'
export * from './system'
+28 -12
View File
@@ -5,6 +5,7 @@
import type {
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
} from './transport'
import { transformFileList, joinPath, probeAlive } from './transport'
import type { ConnectionProfile } from './connection-manager'
import {
OssConnect, OssDisconnect, OssListDir, OssReadFile,
@@ -13,14 +14,6 @@ import {
OssGetCommonPaths, OssWriteBase64File, OssGetSignedURL,
} from '@bindings/u-desk/app'
function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
export class OssTransport implements FsTransport {
private profile: ConnectionProfile
private connID: string | null = null
@@ -40,6 +33,10 @@ export class OssTransport implements FsTransport {
return result
}
async isAlive(): Promise<boolean> {
return probeAlive(this.connID, id => OssListDir(id, ''))
}
get sessionId(): string | null { return this.connID }
async disconnect(): Promise<void> {
@@ -84,13 +81,11 @@ export class OssTransport implements FsTransport {
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return OssCreateFile(this.requireConn(), fullPath)
return OssCreateFile(this.requireConn(), joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return OssCreateDir(this.requireConn(), fullPath)
return OssCreateDir(this.requireConn(), joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
@@ -105,6 +100,24 @@ export class OssTransport implements FsTransport {
})
}
// ====== 复制/移动/传输(不支持)======
async copyPath(_src: string, _dst: string): Promise<void> {
throw new Error('OSS 面板暂不支持复制/粘贴')
}
async movePath(_src: string, _dst: string): Promise<void> {
throw new Error('OSS 面板暂不支持剪切/粘贴')
}
async downloadToFile(_path: string, _localDstDir: string): Promise<string> {
throw new Error('OSS 下载到本机暂未实现')
}
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
throw new Error('OSS 上传暂未实现')
}
// ====== ZIP 操作(不支持)======
async listZipContents(_zipPath: string): Promise<FileItem[]> {
@@ -177,4 +190,7 @@ export class OssTransport implements FsTransport {
async getSignedUrl(key: string): Promise<string> {
return OssGetSignedURL(this.requireConn(), key)
}
async watchFile(_path: string): Promise<void> { /* OSS 模式不支持文件监听 */ }
async unwatchFile(): Promise<void> { /* OSS 模式不支持文件监听 */ }
}
+33 -12
View File
@@ -5,22 +5,16 @@
import type {
FsTransport, FileItem, FileOperationResult, DetectTypeResult,
} from './transport'
import { transformFileList, joinPath, probeAlive } from './transport'
import type { ConnectionProfile } from './connection-manager'
import {
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpDownloadSiteForPreview,
SftpDownloadToFile, SftpUploadFromFile,
SftpGetCommonPaths, SftpWriteBase64File,
} from '@bindings/u-desk/app'
function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
export class SftpTransport implements FsTransport {
private profile: ConnectionProfile
private connID: string | null = null
@@ -42,6 +36,10 @@ export class SftpTransport implements FsTransport {
return result
}
async isAlive(): Promise<boolean> {
return probeAlive(this.connID, id => SftpGetFileInfo(id, '/'))
}
get sessionId(): string | null { return this.connID }
async disconnect(): Promise<void> {
@@ -90,13 +88,11 @@ export class SftpTransport implements FsTransport {
// ====== 文件操作 ======
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return SftpCreateFile(this.requireConn(), fullPath)
return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return SftpCreateDir(this.requireConn(), fullPath)
return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
@@ -111,6 +107,28 @@ export class SftpTransport implements FsTransport {
})
}
// ====== 复制/移动(粘贴仅本地面板支持)======
async copyPath(_src: string, _dst: string): Promise<void> {
throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传')
}
async movePath(_src: string, _dst: string): Promise<void> {
throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传')
}
// ====== 传输 ======
/** 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径 */
async downloadToFile(remotePath: string, localDstDir: string): Promise<string> {
return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir)
}
/** 上传本地文件/目录到远程目录(流式,目录递归) */
async uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
await SftpUploadFromFile(this.requireConn(), localPath, remoteDir)
}
// ====== ZIP 操作(不支持)======
async listZipContents(_zipPath: string): Promise<FileItem[]> {
@@ -179,4 +197,7 @@ export class SftpTransport implements FsTransport {
async downloadSiteForPreview(remotePath: string): Promise<string> {
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
}
async watchFile(_path: string): Promise<void> { /* SFTP 模式不支持文件监听 */ }
async unwatchFile(): Promise<void> { /* SFTP 模式不支持文件监听 */ }
}
+44 -29
View File
@@ -3,18 +3,17 @@
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
*/
import type { File } from './types'
import type { FileItem } from '@/types/file-system'
import { connectionManager } from './connection-manager'
import { PickDirectory } from '../wailsjs/v3-bindings/u-desk/app'
/**
* 转换后端文件数据格式(蛇形 → 驼峰)
*/
function transformFile(file: any): File {
/** 转换后端蛇形字段为前端驼峰格式 */
function toFileItem(file: any): FileItem {
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
}
function transformFileList(files: any[]): File[] {
return files.map(transformFile)
function toFileItemList(files: any[]): FileItem[] {
return files.map(toFileItem)
}
const t = () => connectionManager.getTransport()
@@ -23,21 +22,21 @@ export async function getSystemInfo() { return t().getFileInfo('/') }
export async function getCPUInfo() {
if (connectionManager.isRemote()) return {}
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
try { return (await window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
}
export async function getMemoryInfo() {
if (connectionManager.isRemote()) return {}
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
try { return (await window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
}
export async function getDiskInfo() {
if (connectionManager.isRemote()) return {}
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
try { return (await window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
}
export async function listDir(path: string): Promise<File[]> {
return transformFileList(await t().listDir(path))
export async function listDir(path: string): Promise<FileItem[]> {
return toFileItemList(await t().listDir(path))
}
export async function readFile(path: string): Promise<string> {
@@ -69,12 +68,31 @@ export async function renamePath(oldPath: string, newPath: string): Promise<any>
return t().renamePath(oldPath, String(newPath))
}
export async function getEnvVars(): Promise<Record<string, string>> {
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
// 复制/移动(本机面板粘贴;进度经 transfer-progress 事件推送)
export async function copyPath(src: string, dst: string): Promise<void> {
await t().copyPath(src, dst)
}
export async function listZipContents(zipPath: string): Promise<File[]> {
return transformFileList(await t().listZipContents(zipPath))
export async function movePath(src: string, dst: string): Promise<void> {
await t().movePath(src, dst)
}
// 下载远程文件/目录到本机指定目录(SFTP),返回本地落地路径
export async function downloadToFile(path: string, localDstDir: string): Promise<string> {
return t().downloadToFile(path, localDstDir)
}
// 上传本地文件/目录到远程目录(SFTP)
export async function uploadFromFile(localPath: string, remoteDir: string): Promise<void> {
await t().uploadFromFile(localPath, remoteDir)
}
export async function getEnvVars(): Promise<Record<string, string>> {
try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
}
export async function listZipContents(zipPath: string): Promise<FileItem[]> {
return toFileItemList(await t().listZipContents(zipPath))
}
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
@@ -85,8 +103,8 @@ export async function extractFileFromZipToTemp(zipPath: string, filePath: string
return t().extractFileFromZipToTemp(zipPath, filePath)
}
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
return transformFile(await t().getZipFileInfo(zipPath, filePath))
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
return toFileItem(await t().getZipFileInfo(zipPath, filePath))
}
export async function openPath(path: string): Promise<void> {
@@ -109,19 +127,16 @@ export async function getCommonPaths() {
return t().getCommonPaths()
}
// 文件监听(仅本地模式,直接调用 Wails 绑定)
// 打开系统目录选择器(取消返回空串)
export async function pickDirectory(): Promise<string> {
return PickDirectory()
}
// 文件监听(通过 Transport 层,本地模式走 Wails IPC,远程模式空操作)
export async function watchFile(path: string): Promise<void> {
if (connectionManager.isRemote()) return
try {
const { WatchFile } = await import('../wailsjs/v3-bindings/u-desk/app')
await WatchFile(path)
} catch { /* 忽略绑定未生成的场景 */ }
await t().watchFile(path)
}
export async function unwatchFile(): Promise<void> {
if (connectionManager.isRemote()) return
try {
const { UnwatchFile } = await import('../wailsjs/v3-bindings/u-desk/app')
await UnwatchFile()
} catch { /* 忽略 */ }
await t().unwatchFile()
}
+50 -1
View File
@@ -34,6 +34,9 @@ export type DetectTypeResult = {
}
export interface FsTransport {
/** 检测连接是否仍然存活 */
isAlive?(): Promise<boolean>
// 文件列表与信息
listDir(path: string): Promise<FileItem[]>
getFileInfo(path: string): Promise<Record<string, any>>
@@ -49,6 +52,14 @@ export interface FsTransport {
deletePath(path: string): Promise<FileOperationResult>
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
// 复制/移动(本机面板粘贴;SFTP/OSS/HTTP 暂不支持)
copyPath(src: string, dst: string): Promise<void>
movePath(src: string, dst: string): Promise<void>
// 传输(SFTP 下载到本机 / 本地文件上传到 SFTP;进度经 transfer-progress 事件推送)
downloadToFile(path: string, localDstDir: string): Promise<string>
uploadFromFile(localPath: string, remoteDir: string): Promise<void>
// ZIP 操作(Wave 3)
listZipContents(zipPath: string): Promise<FileItem[]>
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
@@ -70,10 +81,48 @@ export interface FsTransport {
emptyRecycleBin(): Promise<void>
// 预览辅助
/** 下载远程文件到本地临时目录,本地/HTTP 直接返回原路径 */
/** 下载远程文件到本地临时目录,本地直接返回原路径;HTTP 仅对 HTML 落本地副本,其余类型返回原路径 */
downloadForPreview(path: string): Promise<string>
/** 下载 HTML 及其引用的资源到临时目录(OSS 实现) */
downloadSiteForPreview?(path: string): Promise<string>
/** 获取预签名 URL(仅 OSS 实现,其他返回空串) */
getSignedUrl?(path: string): Promise<string>
// 文件监听(仅本地 Wails 模式有效)
watchFile(path: string): Promise<void>
unwatchFile(): Promise<void>
}
// ========== 共享工具函数 ==========
// 说明:后端蛇形 → 前端驼峰(isDir/modified_time)的统一转换在 system.ts 的
// toFileItem/toFileItemList 完成;下面两个函数仅做【类型适配】(运行时恒等),
// 把绑定层返回的松散类型断言为 FileItem,供各 transport 复用。
/** 类型适配:断言后端文件数据为 FileItem(运行时恒等) */
export function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
export function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
/** 路径拼接:确保父路径以 / 结尾后拼接文件名 */
export function joinPath(parent: string, name: string): string {
return parent.replace(/\/$/, '') + '/' + name
}
/** 连接存活探测:无会话 id 或探测调用失败均视为死连接(SFTP/OSS 共用) */
export async function probeAlive(
connId: string | null,
probe: (id: string) => Promise<unknown>,
): Promise<boolean> {
if (!connId) return false
try {
await probe(connId)
return true
} catch {
return false
}
}
-40
View File
@@ -1,40 +0,0 @@
/**
* API 类型定义
*/
// 系统信息
export interface SystemInfo {
os: string
arch: string
version: string
}
export interface CPU {
model: string
cores: number
usage: number
}
export interface Memory {
total: number
used: number
free: number
usage: number
}
export interface Disk {
path: string
total: number
used: number
free: number
usage: number
}
export interface File {
name: string
path: string
size: number
isDir: boolean
modified?: string
modified_time?: string
}
+32 -15
View File
@@ -3,25 +3,20 @@
*/
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
import { transformFile, transformFileList, joinPath } from './transport'
import {
ListDir, GetFileInfo, ReadFile, WriteFile, SaveBase64File,
CreateFile, CreateDir, DeletePath, RenamePath,
CreateFile, CreateDir, DeletePath, RenamePath, CopyPath, MovePath,
ListZipContents, ExtractFileFromZip, ExtractFileFromZipToTemp, GetZipFileInfo,
OpenPath, GetFileServerURL, ResolveShortcut, DetectFileTypeByContent,
GetCommonPaths, GetRecycleBinEntries, RestoreFromRecycleBin,
DeletePermanently, EmptyRecycleBin
DeletePermanently, EmptyRecycleBin,
WatchFile, UnwatchFile
} from '../wailsjs/v3-bindings/u-desk/app'
function transformFile(file: any): FileItem {
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
}
function transformFileList(files: any[]): FileItem[] {
return files.map(transformFile)
}
export class WailsTransport implements FsTransport {
async listDir(path: string): Promise<FileItem[]> {
// 蛇形→驼峰转换统一由 system.ts 的 toFileItemList 完成,此处仅类型适配
return transformFileList(await ListDir(path))
}
@@ -43,13 +38,11 @@ export class WailsTransport implements FsTransport {
}
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
return CreateFile(fullPath)
return CreateFile(joinPath(dirPath, filename))
}
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
return CreateDir(fullPath)
return CreateDir(joinPath(parentPath, dirname))
}
async deletePath(path: string): Promise<FileOperationResult> {
@@ -60,6 +53,22 @@ export class WailsTransport implements FsTransport {
return RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
}
async copyPath(src: string, dst: string): Promise<void> {
await CopyPath(String(src), String(dst))
}
async movePath(src: string, dst: string): Promise<void> {
await MovePath(String(src), String(dst))
}
async downloadToFile(path: string, _localDstDir: string): Promise<string> {
return path // 本地文件无需下载
}
async uploadFromFile(_localPath: string, _remoteDir: string): Promise<void> {
// 本地面板无上传语义(外部文件拖入走 copyPath)
}
async listZipContents(zipPath: string): Promise<FileItem[]> {
return transformFileList(await ListZipContents(zipPath))
}
@@ -73,7 +82,7 @@ export class WailsTransport implements FsTransport {
}
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
return transform(await GetZipFileInfo(zipPath, filePath))
return transformFile(await GetZipFileInfo(zipPath, filePath))
}
async openPath(path: string): Promise<void> {
@@ -120,4 +129,12 @@ export class WailsTransport implements FsTransport {
async downloadForPreview(path: string): Promise<string> {
return path
}
async watchFile(path: string): Promise<void> {
await WatchFile(path)
}
async unwatchFile(): Promise<void> {
await UnwatchFile()
}
}
@@ -145,9 +145,12 @@ watch(() => props.visible, (val) => {
})
watch(() => form.type, (t) => {
if (t === 'sftp') form.port = 22
else if (t === 'remote') form.port = 9876
else form.port = 0
// 仅当前端口是其它类型的默认值(而非用户自定义值)时才重置,避免切类型丢失自定义端口
const DEFAULT_PORTS: Record<string, number> = { sftp: 22, remote: 9876, oss: 0 }
const isOtherDefault = Object.entries(DEFAULT_PORTS).some(([, v]) => form.port === v)
if (isOtherDefault) {
form.port = DEFAULT_PORTS[t] ?? 0
}
})
async function handleOk(): Promise<boolean> {
@@ -166,8 +169,10 @@ async function handleOk(): Promise<boolean> {
await connectionManager.updateProfile(editingId.value, { ...form })
Message.success('已更新')
} else {
// addProfile 遇同名同地址的已有配置会静默复用,提前查重以区分提示
const existed = !!connectionManager.findDuplicateProfile(form)
await connectionManager.addProfile({ ...form, type: form.type })
Message.success('已添加')
Message.success(existed ? '已存在同名配置,已复用' : '已添加')
}
emit('update:visible', false)
return true
@@ -77,7 +77,6 @@
<script setup lang="ts">
import { ref, computed, shallowRef, onMounted, onUnmounted, provide, inject, watch, type Ref } from 'vue'
import { IconCloud, IconFolder, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
import { Message } from '@arco-design/web-vue'
import { connectionManager } from '@/api/connection-manager'
import { listDir } from '@/api/system'
import { sortFileList } from '@/utils/fileUtils'
@@ -256,17 +255,16 @@ function handleClickOutside(e: MouseEvent) {
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
// 清理目录悬停延迟 timer,避免卸载后回调仍执行
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
if (dirCloseTimer.value) clearDelay(dirCloseTimer.value)
})
// === 原有方法 ===
async function handleSelect(p: { id: string }) {
// 连接动作由父级统一入口处理,这里只上抛所选服务器
function handleSelect(p: { id: string }) {
showMenu.value = false
try {
await connectionManager.connect(p.id)
emit('select', p.id)
} catch (err) {
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
}
}
function toggleMore(p: { id: string }) {
@@ -281,14 +279,22 @@ function handleEdit(p: { id: string }) {
function handleDelete(p: { id: string; name: string }) {
if (!window.confirm(`确定删除「${p.name}」?`)) return
const wasActive = p.id === activeId.value
connectionManager.removeProfile(p.id)
moreOpenId.value = null
// 删除的是活跃服务器时连接已回落本地,上抛切换让视图加载本地根目录
if (wasActive) emit('select', 'local-default')
}
function dotClass(p: { type: string }): string {
if (p.type === 'sftp') return 'sftp'
if (p.type === 'remote') return 'remote'
if (p.type === 'oss') return 'oss'
function dotClass(p: { id: string; type: string }): string {
// 优先反映实际连接状态;未连接/出错才回落到类型色(disconnected 用灰,不与 error 红混淆)
if (p.type !== 'local') {
const st = connectionManager.getProfileState(p.id).state
if (st === 'connected') return 'connected'
if (st === 'connecting') return 'connecting'
if (st === 'error') return 'error'
return 'disconnected-idle'
}
return 'local'
}
@@ -347,7 +353,7 @@ watch(() => props.filePath, () => {
.dot.connected { background: var(--color-success-6); }
.dot.connecting { background: var(--color-warning-6); animation: pulse 1.5s infinite; }
.dot.disconnected { background: var(--color-danger-6); }
.dot.disconnected-idle { background: var(--color-text-4); }
.dot.error { background: var(--color-danger-6); }
.dot.local { background: var(--color-text-3); }
.dot.remote { background: var(--color-primary-6); }
@@ -18,6 +18,16 @@
<span>新建文件夹</span>
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
</div>
<div
class="context-menu-item"
:class="{ disabled: config.clipboardEmpty }"
:title="config.clipboardEmpty ? '剪贴板为空' : undefined"
@click="handlePaste"
>
<span class="context-menu-icon">📋</span>
<span>粘贴</span>
<span class="context-menu-shortcut">Ctrl+V</span>
</div>
</template>
<!-- 文件菜单 -->
@@ -34,13 +44,35 @@
</div>
<div class="context-menu-divider"></div>
<div
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
v-if="!config.selectedFile.isDir && isOfficeFile(config.selectedFile.name)"
class="context-menu-item"
@click="handleOpenWithSystem"
>
<span class="context-menu-icon">🚀</span>
<span>系统默认程序打开</span>
</div>
<template v-if="config.isSftp">
<!-- SFTP 面板:下载到本机 -->
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleDownload">
<span class="context-menu-icon">⬇️</span>
<span>下载到本机...</span>
</div>
</template>
<template v-else>
<!-- 本机面板:复制/剪切 -->
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleCopy">
<span class="context-menu-icon">📋</span>
<span>复制</span>
<span class="context-menu-shortcut">Ctrl+C</span>
</div>
<div class="context-menu-item" @click="handleCut">
<span class="context-menu-icon">✂️</span>
<span>剪切</span>
<span class="context-menu-shortcut">Ctrl+X</span>
</div>
</template>
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click="handleRename">
<span class="context-menu-icon">✏️</span>
@@ -111,26 +143,31 @@ const handleCreateDir = () => {
emit('close')
}
const handleOpenWithSystem = () => {
/** 文件类菜单项:带选中文件发出动作并关闭(未选中仅关闭) */
const emitFileAction = (action: string) => {
if (props.config.selectedFile) {
emit('action', 'openWithSystem', props.config.selectedFile)
emit('action', action, props.config.selectedFile)
}
emit('close')
}
const handleRename = () => {
if (props.config.selectedFile) {
emit('action', 'rename', props.config.selectedFile)
}
const handleOpenWithSystem = () => emitFileAction('openWithSystem')
const handleCopy = () => emitFileAction('copy')
const handleCut = () => emitFileAction('cut')
const handleDownload = () => emitFileAction('download')
const handlePaste = () => {
if (props.config.clipboardEmpty) return
emit('action', 'paste')
emit('close')
}
const handleDelete = () => {
if (props.config.selectedFile) {
emit('action', 'delete', props.config.selectedFile)
}
emit('close')
}
const handleRename = () => emitFileAction('rename')
const handleDelete = () => emitFileAction('delete')
</script>
<style scoped>
@@ -169,6 +206,15 @@ const handleDelete = () => {
background: rgb(var(--danger-1));
}
.context-menu-item.disabled {
color: var(--color-text-4);
cursor: not-allowed;
}
.context-menu-item.disabled:hover {
background: transparent;
}
.context-menu-icon {
font-size: 16px;
width: 20px;
@@ -0,0 +1,145 @@
<template>
<div class="drawio-preview">
<!-- 下载中(首次使用时下载 viewer) -->
<div v-if="downloading" class="drawio-loading">
<a-spin :loading="true" />
<p class="drawio-loading-text">
{{ hasProgress ? `正在下载 Draw.io 查看器 ${progress}%` : '正在准备 Draw.io 查看器...' }}
</p>
<a-progress v-if="hasProgress" :percent="progress" style="max-width: 360px; width: 80%" />
</div>
<!-- 错误 -->
<div v-else-if="errorMsg" class="drawio-error">
<a-alert type="error" message="Draw.io 预览失败" :description="errorMsg" />
<a-button size="small" @click="load">重试</a-button>
</div>
<!-- drawio viewer iframe(viewer 用 ?url= 自取 .drawio 原文) -->
<iframe v-else-if="viewerSrc" :src="viewerSrc" class="drawio-iframe" frameborder="0" />
</div>
</template>
<script setup lang="ts">
// DrawioPreview:drawio 文件预览插件组件。
// 流程:① 调 EnsureDrawioViewer 确保本地已缓存 viewer(首次触发下载,监听进度);
// ② 用 resolveFileUrl 拿 .drawio 原文 URL,拼 viewer iframe src(viewer 用 ?url= 自取原文)。
// 关键:viewer base 必须用本地文件服务器(getFileServerBaseURL),与 /drawio-viewer/ 路由及 .drawio 原文同源。
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { On, Off } from '@wailsio/events'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { resolveFileUrl } from '../composables/useFilePreview'
import { getFileServerBaseURL } from '@/api/file-server'
import { EnsureDrawioViewer } from '@bindings/u-desk/app'
import type { FileEditorPanelConfig } from '@/types/file-system'
const props = defineProps<{ config: FileEditorPanelConfig }>()
const downloading = ref(false)
const hasProgress = ref(false)
const progress = ref(0)
const errorMsg = ref('')
const viewerSrc = ref('')
const EVENT_NAME = 'drawio-download-progress'
const onProgress = (event: unknown) => {
const d = parseWailsEventData(event)
const p = Number(d.progress)
if (!isNaN(p) && p >= 0) {
hasProgress.value = true
progress.value = Math.round(p)
}
}
// 构造 viewer iframe src
const buildViewerSrc = async (filePath: string): Promise<string> => {
// 1. .drawio 原文 URL(本地/远程自适应;远程先下载到 temp 再走 localfs)
const xmlUrl = await resolveFileUrl(filePath)
if (!xmlUrl) throw new Error('无法解析文件 URL(远程下载可能失败)')
// 2. viewer base = 本地文件服务器(与 /drawio-viewer/ 路由及原文同源)
const base = getFileServerBaseURL().replace(/\/$/, '')
// 3. chrome=0 隐藏工具栏,lightbox=1 只读灯箱,layers=1 显示图层
const params = new URLSearchParams({
url: xmlUrl,
chrome: '0',
lightbox: '1',
layers: '1',
})
return `${base}/drawio-viewer/index.html?${params.toString()}`
}
const load = async () => {
errorMsg.value = ''
viewerSrc.value = ''
const filePath = props.config.currentFileFullPath
if (!filePath) return
try {
// 1. 确保 viewer 缓存就绪(首次触发下载)
downloading.value = true
hasProgress.value = false
progress.value = 0
await EnsureDrawioViewer()
downloading.value = false
// 2. 构造 viewer URL 并渲染
viewerSrc.value = await buildViewerSrc(filePath)
} catch (e: any) {
downloading.value = false
console.error('[DrawioPreview] 加载失败:', e)
errorMsg.value = e?.message || String(e)
}
}
watch(() => props.config.currentFileFullPath, () => load(), { immediate: true })
// 进度监听只注册一次;Wails On 每次调用都会追加监听器,放在 load() 里会随文件切换累积
onMounted(() => {
On(EVENT_NAME, onProgress)
})
onUnmounted(() => {
Off(EVENT_NAME)
})
</script>
<style scoped>
.drawio-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-bg-1);
position: relative;
}
.drawio-iframe {
width: 100%;
height: 100%;
border: none;
flex: 1;
min-height: 0;
}
.drawio-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
padding: 24px;
}
.drawio-loading-text {
color: var(--color-text-2);
margin: 0;
}
.drawio-error {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
</style>
@@ -72,6 +72,7 @@ export interface BgmTrack { name: string; path: string; url: string; profileId?:
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { LrcLine } from '@/utils/lrcParser'
import { BgmGetPlaylist, BgmSavePlaylist } from '@bindings/u-desk/app'
import { Message } from '@arco-design/web-vue'
import { connectionManager } from '@/api/connection-manager'
const props = defineProps<{
@@ -143,7 +144,10 @@ const restorePlaylist = async (resolver?: (path: string) => Promise<string | und
} else {
playlist.value = entries.map(e => ({ name: e.name, path: e.path, url: '', profileId: (e as any).profile_id || '' }))
}
} catch (e) { console.debug('[BgmBar] 恢复播放列表失败:', e) }
} catch (e) {
console.debug('[BgmBar] 恢复播放列表失败:', e)
Message.warning('背景音乐播放列表恢复失败')
}
}
// 启动时自动恢复(url 延迟解析),并选中上次播放的曲目
@@ -1,10 +1,11 @@
<template>
<div ref="panelRef" class="file-editor-panel" :style="{ width: width + '%' }">
<div class="file-editor-panel" :class="{ 'is-fullscreen': isFullscreen }" :style="{ width: width + '%' }">
<!-- 有选中文件时显示表头和内容 -->
<template v-if="config.currentFileName">
<div class="panel-header">
<span class="panel-title">
<template v-if="config.isImageView">🖼️ 图片预览</template>
<template v-if="config.activePlugin">{{ config.activePlugin.icon }} {{ config.activePlugin.name }}</template>
<template v-else-if="config.isImageView">🖼️ 图片预览</template>
<template v-else-if="config.isVideoView">🎬 视频预览</template>
<template v-else-if="config.isAudioView">🎵 音频预览</template>
<template v-else-if="config.isPdfFile">📕 PDF 预览</template>
@@ -47,8 +48,10 @@
</div>
<div class="editor-content thin-dark-scrollbar">
<!-- 插件预览(drawio 等,优先于内置 v-if 链) -->
<component v-if="config.activePluginComponent" :is="config.activePluginComponent" :config="config" />
<!-- 二进制文件提示 -->
<div v-if="config.isBinaryFile" class="binary-file-message">
<div v-else-if="config.isBinaryFile" class="binary-file-message">
<pre>{{ config.fileContent }}</pre>
</div>
@@ -207,6 +210,17 @@
<template #icon><icon-save /></template>
</a-button>
</a-tooltip>
<!-- PDF 导出按钮(仅预览模式显示,后端 Chrome 渲染输出到桌面) -->
<a-tooltip v-if="!config.isEditMode" position="left" content="导出 PDF">
<a-button
type="outline"
size="small"
:loading="htmlExporting"
@click="handleExportHtmlPDF"
>
<template #icon><icon-file-pdf /></template>
</a-button>
</a-tooltip>
<!-- 预览/编辑切换按钮 -->
<a-tooltip
position="left"
@@ -437,6 +451,7 @@ import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandle
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
import { isDirty } from '../composables/useMultiPreview'
import { On, Off } from '@wailsio/events'
import { ExportHtmlFileToPDF } from '@bindings/u-desk/app'
import { connectionManager } from '@/api/connection-manager'
import { listDir, readFile } from '@/api/system'
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
@@ -445,10 +460,15 @@ import { parseLrc, type LrcLine } from '@/utils/lrcParser'
import BgmBar, { type BgmTrack } from './FileEditor/BgmBar.vue'
// 异步加载 CodeEditor 组件,减少初始包大小
let editorRetryCount = 0
const AsyncCodeEditor = defineAsyncComponent({
loader: () => import('@/components/CodeEditor.vue'),
delay: 200,
timeout: 10000
timeout: 10000,
onError(error, retry) {
console.error('[AsyncCodeEditor] 加载失败:', error)
if (++editorRetryCount <= 2) retry()
}
})
// Office 预览容器引用
@@ -459,21 +479,11 @@ const csvPreviewRef = ref<HTMLElement | null>(null)
// Markdown 预览容器引用
const markdownPreviewRef = ref<HTMLElement | null>(null)
// 全屏
const panelRef = ref<HTMLElement | null>(null)
// 全屏(仅应用窗口内全屏,非 OS 级 fullscreen)
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
isFullscreen.value = !isFullscreen.value
}
function onKeyDown(e: KeyboardEvent) {
@@ -481,6 +491,10 @@ function onKeyDown(e: KeyboardEvent) {
e.preventDefault()
toggleFullscreen()
}
// ESC 退出窗口内全屏
if (e.key === 'Escape' && isFullscreen.value) {
isFullscreen.value = false
}
}
// Props
@@ -526,32 +540,39 @@ const getFileTypeIcon = (filename: string): string => {
// HTML 预览 URL(OSS 需要先下载到本地再预览)
const htmlPreviewUrl = ref('')
// 导出 PDF 需要本地可访问的源文件路径:远程文件取缓存副本,本地文件即原路径
const htmlPreviewLocalPath = ref('')
watch(
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
async ([path, isHtml]) => {
htmlPreviewUrl.value = ''
htmlPreviewLocalPath.value = ''
if (!path || !isHtml) return
// 下载到临时目录后是本地文件,统一用本地文件服务器
const base = getFileServerBaseURL()
const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}`
const applyLocalPath = (localPath: string) => {
htmlPreviewLocalPath.value = localPath
htmlPreviewUrl.value = makeUrl(localPath)
}
if (connectionManager.isRemote()) {
try {
const transport = connectionManager.getTransport()
if (transport?.downloadSiteForPreview) {
const tempPath = await transport.downloadSiteForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath)
applyLocalPath(tempPath)
} else if (transport?.downloadForPreview) {
const tempPath = await transport.downloadForPreview(path)
htmlPreviewUrl.value = makeUrl(tempPath)
applyLocalPath(tempPath)
}
} catch (e) {
console.warn('[HTML预览] 下载失败:', path, e)
}
} else {
htmlPreviewUrl.value = makeUrl(path)
applyLocalPath(path)
}
},
{ immediate: true }
@@ -793,12 +814,12 @@ const handlePdfLoad = (event: Event) => {
}
}
// 带认证的 fetch(远程模式自动附加 Bearer token)
const authFetch = async (url: string): Promise<Response> => {
// 带认证的 fetch(远程模式自动附加 Bearer token),支持取消信号
const authFetch = async (url: string, signal?: AbortSignal): Promise<Response> => {
const token = connectionManager.activeProfile?.token
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
return fetch(url, { headers })
return fetch(url, { headers, signal })
}
// 打印窗口导出 PDF 公共函数
@@ -895,6 +916,32 @@ const handleExportCsvPDF = async () => {
)
}
// HTML 文件导出 PDF:交给后端 headless Chrome 渲染,绕开浏览器打印对话框
const htmlExporting = ref(false)
const handleExportHtmlPDF = async () => {
const srcPath = htmlPreviewLocalPath.value
if (!srcPath) {
Message.error('HTML 预览尚未就绪,请稍候重试')
return
}
const baseName = (props.config.currentFileName || 'export').replace(/\.[^.]+$/, '')
htmlExporting.value = true
try {
const res = await ExportHtmlFileToPDF(srcPath, baseName)
if (res?.success) {
// 后端导出成功已自动打开资源管理器选中文件,弹窗只做明确指向,不依赖用户记住路径
Message.success('PDF 已导出,已在桌面打开所在位置')
} else {
Message.error(res?.message || 'PDF 导出失败')
}
} catch (e: any) {
// 后端约定的错误文案随异常抛出,无 Chrome 时向用户展示安装提示
Message.error(e?.message || 'PDF 导出失败')
} finally {
htmlExporting.value = false
}
}
// 监听模式切换,切换到预览模式时渲染 Mermaid 图表
watch(() => props.config.isEditMode, async (newVal, oldVal) => {
// 从编辑模式切换到预览模式
@@ -945,23 +992,34 @@ watch(() => [props.config.isCsvFile, props.config.currentFileFullPath, props.con
}, { immediate: true })
// Excel 预览加载(直接使用本地文件服务器,秒开)
// 取消上一个进行中的预览:快速切文件时 abort 旧下载 + worker 侧 wid 自动推进丢弃旧解析
let excelAbortController: AbortController | null = null
const loadExcelPreview = async (filePath: string) => {
if (!excelPreviewRef.value) return
// 取消上一次尚未完成的下载
if (excelAbortController) excelAbortController.abort()
const controller = new AbortController()
excelAbortController = controller
try {
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
// 直接从本地文件服务器获取(不走 base64)
const fileUrl = props.config.previewUrl
const response = await authFetch(fileUrl)
const response = await authFetch(fileUrl, controller.signal)
const blob = await response.blob()
if (controller.signal.aborted) return
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
const result = await previewExcel(file, excelPreviewRef.value)
if (controller.signal.aborted) return
if (!result.success) {
throw new Error(result.error || '预览失败')
}
} catch (error) {
} catch (error: any) {
// 被后续切换主动取消 —— 静默
if (controller.signal.aborted || error?.name === 'AbortError') return
console.error('[loadExcelPreview] 错误:', error)
if (excelPreviewRef.value) {
excelPreviewRef.value.innerHTML = `
@@ -1083,6 +1141,20 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
}
}, { immediate: true })
// 切换文件时重置 Markdown 预览的滚动位置
// v-html 只替换内容,外层滚动容器的 scrollTop 不会自动归零,
// 否则从 A 文档跳进 B 文档时会停留在 A 的滚动位置
watch(
() => props.config.currentFileFullPath,
() => {
nextTick(() => {
if (markdownPreviewRef.value) {
markdownPreviewRef.value.scrollTop = 0
}
})
}
)
// 处理 HTML iframe 发送的消息(链接点击)
const handleHtmlIframeMessage = (event: MessageEvent) => {
// 安全检查:接受来自本地文件服务器或同源的消息
@@ -1105,7 +1177,6 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
// 监听 iframe 的 postMessage + 全屏事件
onMounted(() => {
window.addEventListener('message', handleHtmlIframeMessage)
document.addEventListener('fullscreenchange', onFullscreenChange)
document.addEventListener('keydown', onKeyDown)
On('toggle-bgm-bar', () => {
bgmHidden.value = !bgmHidden.value
@@ -1117,7 +1188,6 @@ onUnmounted(() => {
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
}
window.removeEventListener('message', handleHtmlIframeMessage)
document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('keydown', onKeyDown)
Off('toggle-bgm-bar')
copyCleanup()
@@ -1135,9 +1205,11 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
position: relative;
}
.file-editor-panel:fullscreen {
width: 100vw !important;
height: 100vh;
.file-editor-panel.is-fullscreen {
position: fixed;
inset: 0;
width: 100% !important;
height: 100%;
z-index: 1000;
}
@@ -1157,7 +1229,7 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
}
/* 仅全屏模式下 header 可拖动窗口 */
.file-editor-panel:fullscreen .panel-header {
.file-editor-panel.is-fullscreen .panel-header {
--wails-draggable: drag;
}
@@ -1607,54 +1679,7 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
fill: var(--color-text-1);
}
/* ========== 代码高亮主题色(不依赖 hljs 主题 CSS) ========== */
/* 亮色模式 - GitHub 配色 */
.markdown-preview-content :deep(.hljs) {
color: #24292e;
background: #f6f8fa;
}
.markdown-preview-content :deep(.hljs-comment),
.markdown-preview-content :deep(.hljs-quote) { color: #6a737d; font-style: italic; }
.markdown-preview-content :deep(.hljs-keyword),
.markdown-preview-content :deep(.hljs-selector-tag),
.markdown-preview-content :deep(.hljs-subst) { color: #d73a49; }
.markdown-preview-content :deep(.hljs-string),
.markdown-preview-content :deep(.hljs-doctag) { color: #032f62; }
.markdown-preview-content :deep(.hljs-number),
.markdown-preview-content :deep(.hljs-literal),
.markdown-preview-content :deep(.hljs-variable),
.markdown-preview-content :deep(.hljs-template-variable),
.markdown-preview-content :deep(.hljs-tag .hljs-attr) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-title),
.markdown-preview-content :deep(.hljs-section),
.markdown-preview-content :deep(.hljs-selector-id) { color: #6f42c1; font-weight: bold; }
.markdown-preview-content :deep(.hljs-type),
.markdown-preview-content :deep(.hljs-class .hljs-title) { color: #6f42c1; }
.markdown-preview-content :deep(.hljs-tag .hljs-keyword),
.markdown-preview-content :deep(.hljs-tag .hljs-title) { color: #22863a; }
.markdown-preview-content :deep(.hljs-bullet) { color: #e36209; }
.markdown-preview-content :deep(.hljs-symbol) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-built_in),
.markdown-preview-content :deep(.hljs-type) { color: #005cc5; }
.markdown-preview-content :deep(.hljs-attr) { color: #e36209; }
.markdown-preview-content :deep(.hljs-meta) { color: #735c0f; }
.markdown-preview-content :deep(.hljs-addition) { color: #22863a; background-color: #f0fff4; }
.markdown-preview-content :deep(.hljs-deletion) { color: #b31d28; background-color: #ffeef0; }
/* 亮色 hljs 样式由全局 style.css 提供 */
/* ========== 深色模式适配 ========== */
@@ -1725,12 +1750,16 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
border: 1px solid color-mix(in srgb, var(--color-border-2) 40%, transparent);
border-right: none;
cursor: pointer;
transition: all 0.3s ease;
transition: opacity 0.15s ease, background-color 0.15s ease;
position: relative;
opacity: 0.68;
/* 未悬停时高透明避免遮挡内容,容器 hover / tab hover 恢复;
active 态与未保存 dirty 标记保持较高可见度(状态不能被压暗) */
opacity: 0.25;
}
.preview-tabs:hover .preview-tab {
.preview-tabs:hover .preview-tab,
.preview-tab:hover,
.preview-tab.active {
opacity: 1;
}
@@ -1741,6 +1770,7 @@ body[arco-theme*='dark'] .markdown-preview-content :deep(.hljs-link) {
.preview-tab.active {
background: var(--color-fill-2);
border-left-color: transparent;
opacity: 0.85;
}
.preview-tab.active::before {
@@ -50,6 +50,7 @@
<div
class="file-list-wrapper thin-dark-scrollbar"
data-file-drop-target
@contextmenu.prevent="handleWrapperContextMenu"
>
<!-- 文件列表(滚动区域) -->
@@ -378,7 +379,8 @@ const handleRowContextMenu = (record: FileItem, ev: Event) => {
const getRowClassName = (record: FileItem): string => [
props.config.selectedFileItem?.path === record.path && 'row-selected',
props.config.editingFilePath === record.path && 'row-editing'
props.config.editingFilePath === record.path && 'row-editing',
props.config.cutPaths?.includes(record.path) && 'row-cut'
].filter(Boolean).join(' ')
const handleWrapperContextMenu = (event: MouseEvent) => {
@@ -537,6 +539,17 @@ defineExpose({ focusEditingItem })
background: var(--color-fill-1);
}
/* 被剪切的文件行 */
.file-table :deep(.arco-table-tr.row-cut) {
opacity: 0.5;
}
/* 外部文件拖入悬停反馈(Wails 运行时自动添加的类) */
.file-list-wrapper.file-drop-target-active {
outline: 1px dashed rgb(var(--primary-6));
outline-offset: -2px;
}
/* ====== 列内容 ====== */
.col-icon { text-align: center; vertical-align: middle !important; }
.file-item-icon {
@@ -1,9 +1,9 @@
<template>
<transition name="slide">
<div v-show="config.visible" class="sidebar">
<div v-show="config.visible" class="sidebar" :style="{ width: width + 'px' }">
<template v-for="section in sectionOrder" :key="section">
<!-- 服务器区块 -->
<div v-if="section === 'server' && showServer" class="sidebar-section">
<div v-if="section === 'server' && showServer" class="sidebar-section server-section">
<div class="section-header" @click="serverCollapsed = !serverCollapsed">
<span class="section-title">🖥️ 服务器</span>
<icon-down v-if="!serverCollapsed" class="section-toggle" />
@@ -19,10 +19,16 @@
<span class="col-metric">内存</span>
<span class="col-action settings-btn" @click="toggleSettings($event)" title="管理">···</span>
</div>
<!-- 表格行 -->
<div
v-for="p in visibleProfiles"
<!-- 表格行(右键菜单与行内「...」菜单共用同一组操作,互斥展开) -->
<a-dropdown
v-for="(p, idx) in visibleProfiles"
:key="p.id"
trigger="contextMenu"
align-point
:popup-visible="srvCtxMenuIndex === idx"
@popup-visible-change="(v: boolean) => onSrvCtxVisible(idx, v)"
>
<div
class="server-table-row"
:class="{ active: p.id === activeId }"
@click="handleSelect(p)"
@@ -39,6 +45,23 @@
>···</span>
<span v-else class="col-action"></span>
</div>
<template #content>
<a-doption @click="handleSelect(p)">
<template #icon><icon-computer /></template>
连接
</a-doption>
<template v-if="p.type !== 'local'">
<a-doption @click="handleEdit(p)">
<template #icon><icon-edit /></template>
编辑
</a-doption>
<a-doption class="danger" @click="handleDelete(p)">
<template #icon><icon-close /></template>
删除
</a-doption>
</template>
</template>
</a-dropdown>
</div>
</div>
@@ -54,9 +77,15 @@
</div>
<div class="section-content-wrap" :class="{ collapsed: favCollapsed }">
<div class="section-content fav-content">
<div
<a-dropdown
v-for="(fav, index) in config.favoriteFiles"
:key="fav.path"
trigger="contextMenu"
align-point
:popup-visible="favCtxMenuIndex === index"
@popup-visible-change="(v: boolean) => onFavCtxVisible(index, v)"
>
<div
class="sidebar-item"
:class="{
'sidebar-item-pinned': fav.pinnedAt,
@@ -78,30 +107,49 @@
@drop="handleDrop($event, index)"
@dragend="handleDragEnd"
>
<span class="sidebar-item-icon">{{ getFileIcon(fav) }}</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 }"
<span class="sidebar-item-icon">{{ getFileIcon({ is_dir: fav.isDir, name: fav.name }) }}</span>
<span class="sidebar-item-name" :title="favoriteTooltip(fav)">{{ favoriteName(fav) }}</span>
<a-dropdown
trigger="click"
:popup-visible="favMenuIndex === index"
@popup-visible-change="(v: boolean) => onFavMenuVisible(index, v)"
>
<a-button type="text" size="mini" title="更多操作" class="sidebar-item-more" @click.stop>
<template #icon>
<icon-pushpin :style="{ opacity: fav.pinnedAt ? 1 : 0.4 }" />
<icon-more />
</template>
</a-button>
<a-button
type="text"
size="mini"
@click.stop="handleRemoveFavorite(fav)"
class="sidebar-item-remove"
>
<template #icon>
<icon-close />
<template #content>
<a-doption @click="openRename(fav)">
<template #icon><icon-edit /></template>
重命名
</a-doption>
<a-doption @click="handleTogglePin(fav)">
<template #icon><icon-pushpin /></template>
{{ fav.pinnedAt ? '取消置顶' : '置顶' }}
</a-doption>
<a-doption class="danger" @click="handleRemoveFavorite(fav)">
<template #icon><icon-close /></template>
删除
</a-doption>
</template>
</a-button>
</a-dropdown>
</div>
<template #content>
<a-doption @click="openRename(fav)">
<template #icon><icon-edit /></template>
重命名
</a-doption>
<a-doption @click="handleTogglePin(fav)">
<template #icon><icon-pushpin /></template>
{{ fav.pinnedAt ? '取消置顶' : '置顶' }}
</a-doption>
<a-doption class="danger" @click="handleRemoveFavorite(fav)">
<template #icon><icon-close /></template>
删除
</a-doption>
</template>
</a-dropdown>
<div v-if="config.favoriteFiles.length === 0" class="sidebar-empty">
<icon-star />
<span>暂无收藏</span>
@@ -112,7 +160,7 @@
</div>
<!-- 帮助文档区块 -->
<div v-if="section === 'help' && showHelp" class="sidebar-section">
<div v-if="section === 'help' && showHelp" class="sidebar-section help-section">
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
<span class="section-title">📖 帮助</span>
<icon-down v-if="!helpCollapsed" class="section-toggle" />
@@ -131,6 +179,26 @@
</div>
</transition>
<!-- 重命名收藏弹窗 -->
<a-modal
:visible="renameVisible"
title="重命名收藏"
:width="420"
:mask-closable="false"
unmount-on-close
@cancel="renameVisible = false"
@before-ok="handleRenameOk"
>
<a-input
ref="renameInputRef"
v-model="renameValue"
:placeholder="renameOriginal"
:max-length="255"
allow-clear
@keydown.enter.prevent="confirmRename"
/>
</a-modal>
<!-- 更多操作子菜单 — Teleport 到 body -->
<Teleport to="body">
<div v-if="moreOpenId" class="sidebar-popup more-menu" :style="moreMenuStyle" @click.stop>
@@ -141,6 +209,9 @@
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
<icon-plus /> 添加服务器
</div>
<div class="settings-item" @click="connectAllServers">
<icon-link /> 一键全连
</div>
<div class="settings-item" @click="toggleAutoConnect">
<icon-check-circle :style="{ opacity: autoConnect ? 1 : 0.3 }" />
启动时自动连接所有服务器
@@ -165,27 +236,27 @@
</template>
<script setup lang="ts">
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
import { ref, computed, shallowRef, watch, onMounted, onUnmounted } from 'vue'
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
import type { SystemInfo } from '@/api/connection-manager'
import { connectionManager } from '@/api/connection-manager'
import { useConfigStore } from '@/stores/config'
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
import { IconStar, IconClose, IconPushpin, IconEdit, IconMore, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle, IconLink } from '@arco-design/web-vue/es/icon'
import { getFileIcon } from '@/utils/fileUtils'
import { Message } from '@arco-design/web-vue'
const configStore = useConfigStore()
// Props
interface Props {
config: SidebarConfig
width: number
}
const props = defineProps<Props>()
// 折叠状态(组件内部,不污染父组件)
// 折叠状态(组件内部,不污染父组件);收藏夹默认展开,用户手动折叠后会话内保持
const serverCollapsed = ref(false)
const favCollapsed = ref(true)
const favCollapsed = ref(false)
const helpCollapsed = ref(true)
// 侧边栏区块可见性(从配置读取)
@@ -223,6 +294,7 @@ function toggleSettings(e: MouseEvent) {
return
}
moreOpenId.value = null
srvCtxMenuIndex.value = -1
const btn = (e.target as HTMLElement).closest<HTMLElement>('.settings-btn')
if (!btn) return
settingsPanelStyle.value = calcPopupStyle(btn)
@@ -236,6 +308,7 @@ function toggleMore(p: { id: string; name: string }, e: MouseEvent) {
return
}
settingsOpen.value = false
srvCtxMenuIndex.value = -1
moreOpenId.value = p.id
moreProfile.value = p
const target = (e.target as HTMLElement).closest('.more-btn') as HTMLElement
@@ -309,6 +382,25 @@ function toggleServerVisibility(id: string) {
localStorage.setItem('desk:hiddenServers', JSON.stringify(hiddenServerIds.value))
}
// 一键全连:并发连接所有可见的非本地服务器(连接池模式,已连的探测复用),
// 全部落地后恢复原焦点,不打扰当前视图
const connectingAll = ref(false)
async function connectAllServers() {
if (connectingAll.value) return
settingsOpen.value = false
connectingAll.value = true
const prevActive = activeId.value
try {
const targets = visibleProfiles.value.filter(p => p.type !== 'local')
await Promise.allSettled(targets.map(p => connectionManager.connect(p.id)))
if (prevActive && profiles.value.some(p => p.id === prevActive)) {
await connectionManager.connect(prevActive).catch(() => {})
}
} finally {
connectingAll.value = false
}
}
// 监听连接变化 + 系统信息变化
connectionManager.onStateChange(() => {
profiles.value = connectionManager.profiles
@@ -344,6 +436,7 @@ interface Emits {
(e: 'openFavorite', file: FavoriteFile): void
(e: 'removeFavorite', path: string): void
(e: 'togglePin', path: string): void
(e: 'renameFavorite', path: string, displayName: string): void
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
(e: 'longPressCancel'): void
(e: 'dragStart', event: DragEvent, index: number): void
@@ -351,6 +444,7 @@ interface Emits {
(e: 'drop', event: DragEvent, targetIndex: number): void
(e: 'dragEnd'): void
(e: 'openConnectionDialog', editId?: string): void
(e: 'serverSelect', id: string): void
}
const emit = defineEmits<Emits>()
@@ -368,6 +462,76 @@ const handleTogglePin = (file: FavoriteFile) => {
emit('togglePin', file.path)
}
// 收藏项双菜单受控互斥:右键菜单与「...」点击菜单,一方展开强制收起另一方
const favMenuIndex = ref(-1)
const favCtxMenuIndex = ref(-1)
function onFavCtxVisible(index: number, visible: boolean) {
favCtxMenuIndex.value = visible ? index : -1
if (visible) favMenuIndex.value = -1
}
function onFavMenuVisible(index: number, visible: boolean) {
favMenuIndex.value = visible ? index : -1
if (visible) favCtxMenuIndex.value = -1
}
// 重命名收藏弹窗(预填展示名,placeholder 显示原名)
const renameVisible = ref(false)
const renameValue = ref('')
const renameTarget = ref<FavoriteFile | null>(null)
const renameInputRef = ref()
const renameOriginal = computed(() => renameTarget.value?.name ?? '')
const openRename = (fav: FavoriteFile) => {
renameTarget.value = fav
renameValue.value = fav.displayName || ''
renameVisible.value = true
}
watch(renameVisible, (val) => {
if (!val) return
setTimeout(() => {
renameInputRef.value?.focus()
}, 100)
})
const handleRenameOk = (done: (closed?: boolean) => void) => {
if (!renameTarget.value) {
done(false)
return
}
// 空值表示清除展示名,回退显示原名
emit('renameFavorite', renameTarget.value.path, renameValue.value.trim())
done()
}
// 输入框回车与点击确定走同一提交逻辑
const confirmRename = () => {
handleRenameOk(() => { renameVisible.value = false })
}
// 展示名优先,未设置回退原名
const favoriteName = (fav: FavoriteFile): string => fav.displayName || fav.name
// 悬停提示两行:完整路径 + 来源连接名
const favoriteTooltip = (fav: FavoriteFile): string => {
const source = favoriteSourceName(fav)
return source ? `${fav.path}\n来源:${source}` : fav.path
}
function favoriteSourceName(fav: FavoriteFile): string {
if (fav.profileId) {
return connectionManager.profiles.find(p => p.id === fav.profileId)?.name ?? ''
}
// 旧数据无 profileId,按路径格式推断连接(与加载迁移逻辑一致)
const isLocalPath = /^[A-Za-z]:/.test(fav.path) || fav.path.startsWith('\\\\')
if (isLocalPath) {
return connectionManager.profiles.find(p => p.type === 'local')?.name ?? '本地'
}
return connectionManager.profiles.find(p => p.type !== 'local')?.name ?? ''
}
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
emit('longPressStart', event, index)
}
@@ -399,7 +563,9 @@ function sysInfo(profileId: string): SystemInfo | undefined {
function stateDotClass(profileId: string): string {
if (profileId === activeId.value) {
const state = connectionManager.state
// 用 per-profile 状态而非全局 state:全局 state 只描述最近一次连接尝试,
// 连 B 失败后全局停在 error,焦点回落 A 会把 A 误染红
const state = connectionManager.getProfileState(profileId).state
if (state === 'connected') return 'connected'
if (state === 'connecting') return 'connecting'
if (state === 'error') return 'error'
@@ -460,7 +626,8 @@ function formatBytes(n: number): string {
function stateText(profileId: string): string {
if (profileId === activeId.value) {
const s = connectionManager.state
// 同 stateDotClass:per-profile 状态不随连接尝试漂移
const s = connectionManager.getProfileState(profileId).state
if (s === 'connected') return '已连接'
if (s === 'connecting') return '连接中...'
if (s === 'error') return '连接失败'
@@ -472,15 +639,11 @@ function stateText(profileId: string): string {
return '未连接'
}
async function handleSelect(p: { id: string; type: string }) {
// 同一服务器重复点击也上抛切换事件,由父级强制重载列表
function handleSelect(p: { id: string; type: string }) {
moreOpenId.value = null
moreProfile.value = null
if (p.id === activeId.value) return
try {
await connectionManager.connect(p.id)
} catch (err) {
Message.error(`连接失败: ${err instanceof Error ? err.message : String(err)}`)
}
emit('serverSelect', p.id)
}
function handleEdit(p: { id: string }) {
@@ -491,9 +654,24 @@ function handleEdit(p: { id: string }) {
function handleDelete(p: { id: string; name: string }) {
if (!window.confirm(`确定删除「${p.name}」?`)) return
const wasActive = p.id === activeId.value
connectionManager.removeProfile(p.id)
moreOpenId.value = null
moreProfile.value = null
// 删除的是活跃服务器时连接已回落本地,补发切换让视图加载本地根目录
if (wasActive) emit('serverSelect', 'local-default')
}
// 服务器项右键菜单(与行内「...」菜单互斥:一方展开强制收起另一方)
const srvCtxMenuIndex = ref(-1)
function onSrvCtxVisible(index: number, visible: boolean) {
srvCtxMenuIndex.value = visible ? index : -1
if (visible) {
moreOpenId.value = null
moreProfile.value = null
settingsOpen.value = false
}
}
</script>
@@ -516,13 +694,13 @@ function handleDelete(p: { id: string; name: string }) {
position: relative;
}
/* 服务器区块不收缩 */
.sidebar-section:first-child {
/* 服务器区块不收缩(按显式类名定位,不依赖排序位置) */
.server-section {
flex-shrink: 0;
}
/* 服务器区块不需要 max-height 限制 */
.sidebar-section:first-child > .section-content-wrap {
.server-section > .section-content-wrap {
max-height: none;
}
@@ -541,6 +719,9 @@ function handleDelete(p: { id: string; name: string }) {
.fav-section > .section-content-wrap {
flex: 1;
min-height: 0;
max-height: none;
display: flex;
flex-direction: column;
}
.fav-section > .section-content-wrap.collapsed {
@@ -555,8 +736,8 @@ function handleDelete(p: { id: string; name: string }) {
flex-direction: column;
}
/* 帮助区块固定在底部 */
.sidebar-section:last-child {
/* 帮助区块不收缩(按显式类名定位,不依赖排序位置) */
.help-section {
flex-shrink: 0;
}
@@ -807,17 +988,8 @@ function handleDelete(p: { id: string; name: string }) {
text-overflow: ellipsis;
}
.sidebar-item-remove {
opacity: 0;
transition: opacity 0.2s;
}
.sidebar-item:hover .sidebar-item-remove {
opacity: 1;
}
.sidebar-item-pinned {
background: var(--color-fill-1);
background: var(--color-primary-light-1);
border-radius: 0;
}
@@ -833,12 +1005,13 @@ function handleDelete(p: { id: string; name: string }) {
border-radius: 6px;
}
.sidebar-item-pin {
/* 「...」操作菜单按钮,悬停收藏项时显示 */
.sidebar-item-more {
opacity: 0;
transition: opacity 0.2s;
}
.sidebar-item:hover .sidebar-item-pin {
.sidebar-item:hover .sidebar-item-more {
opacity: 1;
}
@@ -886,6 +1059,17 @@ function handleDelete(p: { id: string; name: string }) {
}
</style>
<!-- 非 scoped:Arco 下拉菜单挂载在 body 下,删除项标红需全局样式 -->
<style>
.arco-dropdown-option.danger {
color: rgb(var(--danger-6));
}
.arco-dropdown-option.danger:hover {
background: rgb(var(--danger-1));
}
</style>
<!-- 非 scoped:Teleport 到 body 的面板样式 -->
<style>
/* 弹出面板公共 */
@@ -168,7 +168,7 @@ interface Emits {
(e: 'goToPath', path: string): void
(e: 'openFile', path: string): void
(e: 'navigateToZipDirectory', path: string): void
(e: 'connectionChanged'): void
(e: 'connectionChanged', id: string): void
}
const emit = defineEmits<Emits>()
@@ -186,8 +186,8 @@ watch(() => props.openConnectionDialog, (v) => {
}
})
const onConnectionChanged = async (_id: string) => {
emit('connectionChanged')
const onConnectionChanged = (id: string) => {
emit('connectionChanged', id)
}
const onEditProfile = (id: string) => {
@@ -173,6 +173,17 @@ export function useFavorites() {
return !!fav?.pinnedAt
}
/**
* 设置收藏项展示名(空串表示清除,显示时回退原名)
*/
const renameFavorite = (path: string, displayName: string) => {
const normalizedPath = normalizePath(path)
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
if (!fav) return
fav.displayName = displayName
saveFavorites()
}
/**
* 更新收藏项路径(重命名时使用,保留置顶状态和添加时间)
*/
@@ -268,6 +279,7 @@ export function useFavorites() {
isFavorite,
togglePin,
isPinned,
renameFavorite,
updateFavoritePath,
onLongPressStart,
@@ -0,0 +1,41 @@
/**
* 文件剪贴板 Composable(应用内内存态单例)
* 复制/剪切 → 粘贴到其他目录;粘贴动作由 index.vue 执行
*/
import { ref, computed } from 'vue'
import type { FileItem } from '@/types/file-system'
export type ClipboardMode = 'copy' | 'cut'
interface FileClipboardState {
mode: ClipboardMode
items: FileItem[]
fromDir: string
}
// 模块级单例状态(跨组件共享)
const state = ref<FileClipboardState | null>(null)
export function useFileClipboard() {
const clipboard = computed(() => state.value)
const clipboardEmpty = computed(() => !state.value || state.value.items.length === 0)
/** 设置复制项 */
const setCopy = (items: FileItem[], fromDir: string) => {
state.value = { mode: 'copy', items: [...items], fromDir }
}
/** 设置剪切项 */
const setCut = (items: FileItem[], fromDir: string) => {
state.value = { mode: 'cut', items: [...items], fromDir }
}
/** 清空剪贴板 */
const clear = () => {
state.value = null
}
return { clipboard, clipboardEmpty, setCopy, setCut, clear }
}
@@ -10,9 +10,9 @@ import { getExt } from '@/utils/fileUtils'
import {
isImageFile, isVideoFile, isAudioFile, isPdfFile,
isExcelFile, isWordFile, isCsvFile,
isTextEditable, isConfigFile
isTextEditable, isConfigFile, isKnownBinary
} from '@/utils/fileTypeHelpers'
import { useFileOperations } from './useFileOperations'
import { readFile as readFileApi, writeFile as writeFileApi } from '@/api'
import type { FileItem } from '@/types/file-system'
export interface UseFileEditOptions {
@@ -47,15 +47,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 文件版本跟踪(用于防止切换文件后的过期更新)
const fileVersion = ref(0)
// 使用文件操作 composable
const { readFile, writeFile } = useFileOperations({
onSuccess: (operation, data) => {
// 可以在这里添加成功处理逻辑
},
onError: (operation, error) => {
Message.error(`${operation} 失败: ${error.message}`)
}
})
// 直接调用底层 API(错误由本文件 catch 统一处理)
/**
* 获取文件路径(从 FileItem 对象或字符串中提取)
@@ -67,13 +59,8 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
return ''
}
// 已知二进制扩展名(无需读取内容即可判定)
const KNOWN_BINARY_EXTS = new Set([
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite', 'pdb', 'idb',
'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
'msi', 'jar', 'war', 'ear', 'apk'
])
// 已知二进制扩展名判定委托给 fileTypeHelpers.isKnownBinary
// 不再本地维护列表,统一入口
/**
* 判断是否为二进制文件(基于扩展名)
@@ -85,7 +72,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
// 已知二进制扩展名 → 直接判定
if (KNOWN_BINARY_EXTS.has(ext)) return true
if (isKnownBinary(path)) return true
// 媒体文件(可预览,不算二进制)
const isMediaFile = isImageFile(path) ||
@@ -191,7 +178,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// Office 文件直接读取内容进行预览,跳过二进制检测
if (isExcelFile(filename) || isWordFile(filename)) {
const content = await readFile(path)
const content = await readFileApi(path)
fileContent.value = content
originalContent.value = content
isEditMode.value = false
@@ -253,7 +240,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
}
// 对于无扩展名或未知类型文件,先尝试读取
const content = await readFile(path)
const content = await readFileApi(path)
// 检查文件大小
const fileSize = content.length // UTF-16 字符数
@@ -352,7 +339,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
isSaving.value = true
try {
await writeFile(targetPath, fileContent.value)
await writeFileApi(targetPath, fileContent.value)
originalContent.value = fileContent.value
// 清除草稿
@@ -499,6 +486,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
const clearContent = () => {
fileContent.value = ''
originalContent.value = ''
isBinaryFile.value = false
}
/**
@@ -1,9 +1,8 @@
/**
* 文件操作 Composable
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
* 文件操作 Composable(纯代理层)
* 直接委托底层 API,不做 try-catch,错误由调用方处理
*/
import { Message } from '@arco-design/web-vue'
import { getPathSeparator } from '@/utils/fileUtils'
import {
listDir,
@@ -13,255 +12,123 @@ import {
createFile,
createDir,
renamePath as renamePathApi,
copyPath as copyPathApi,
movePath as movePathApi,
listZipContents as listZipContentsApi,
extractFileFromZip,
extractFileFromZipToTemp as extractZipToTempApi,
getFileServerURL as getFileServerUrlApi
} from '@/api'
import type { FileItem, FileOperationResult } from '@/types/file-system'
import type { FileItem } from '@/types/file-system'
export interface UseFileOperationsOptions {
onSuccess?: (operation: string, data: any) => void
onError?: (operation: string, error: Error) => void
}
/**
* 文件操作结果
*/
export function useFileOperations(options: UseFileOperationsOptions = {}) {
const { onSuccess, onError } = options
const { onSuccess } = options
/**
* 列出目录内容
*/
/** 列出目录内容 */
const listDirectory = async (path: string): Promise<FileItem[]> => {
try {
const result = await listDir(path)
onSuccess?.('listDirectory', result)
return result
} catch (error) {
const err = error as Error
onError?.('listDirectory', err)
throw err
}
}
/**
* 读取文件内容
*/
/** 读取文件内容 */
const readFile = async (path: string): Promise<string> => {
try {
const content = await readFileApi(path)
onSuccess?.('readFile', { path, size: content.length })
return content
} catch (error) {
const err = error as Error
onError?.('readFile', err)
throw err
}
}
/**
* 写入文件内容
*/
const writeFile = async (
path: string,
content: string,
createBackup: boolean = false
): Promise<void> => {
try {
/** 写入文件内容 */
const writeFile = async (path: string, content: string): Promise<void> => {
await writeFileApi(path, content)
onSuccess?.('writeFile', { path, size: content.length })
} catch (error) {
const err = error as Error
onError?.('writeFile', err)
throw err
}
}
/**
* 删除路径(文件或目录),返回被删除的文件信息
*/
/** 删除路径 */
const deletePath = async (path: string): Promise<FileItem> => {
try {
const result = await deletePathApi(path)
onSuccess?.('deletePath', { path, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('deletePath', err)
throw err
}
}
/**
* 创建新文件,返回创建的文件信息
*/
const createNewFile = async (
dirPath: string,
filename: string
): Promise<FileItem> => {
try {
/** 创建新文件 */
const createNewFile = async (dirPath: string, filename: string): Promise<FileItem> => {
const result = await createFile(dirPath, filename)
onSuccess?.('createFile', { dirPath, filename, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('createFile', err)
throw err
}
}
/**
* 创建新目录,返回创建的目录信息
*/
/** 创建新目录 */
const createNewDir = async (parentPath: string, dirname: string): Promise<FileItem> => {
try {
const result = await createDir(parentPath, dirname)
onSuccess?.('createDir', { parentPath, dirname, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('createDir', err)
throw err
}
}
/**
* 重命名文件或目录,返回新文件信息
*/
/** 重命名 */
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
// 构造新路径
const separator = getPathSeparator(oldPath)
const parentPath = oldPath.substring(
0,
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
)
const newPath = parentPath + separator + newName
try {
const result = await renamePathApi(oldPath, newPath)
onSuccess?.('rename', { oldPath, newPath, result })
return result as FileItem
} catch (error) {
const err = error as Error
onError?.('rename', err)
throw err
}
}
/**
* 复制文件或目录
*/
/** 复制(递归,进度经 transfer-progress 事件) */
const copy = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现复制逻辑
Message.warning('复制功能暂未实现')
await copyPathApi(fromPath, toPath)
onSuccess?.('copy', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('copy', err)
throw err
}
}
/**
* 移动文件或目录
*/
/** 移动(跨盘自动回退复制+删除) */
const move = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现移动逻辑
Message.warning('移动功能暂未实现')
await movePathApi(fromPath, toPath)
onSuccess?.('move', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('move', err)
throw err
}
}
/**
* 列出 ZIP 文件内容
*/
/** 列出 ZIP 文件内容 */
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
try {
const result = await listZipContentsApi(zipPath)
onSuccess?.('listZipContents', { zipPath, count: result.length })
return result
} catch (error) {
const err = error as Error
onError?.('listZipContents', err)
throw err
}
}
/**
* 从 ZIP 中提取文件内容(文本)
*/
/** 从 ZIP 中提取文件内容 */
const extractZipFile = async (zipPath: string, filePath: string): Promise<string> => {
try {
const content = await extractFileFromZip(zipPath, filePath)
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
return content
} catch (error) {
const err = error as Error
onError?.('extractZipFile', err)
throw err
}
}
/**
* 从 ZIP 中提取文件到临时目录(二进制文件,如图片)
*/
/** 从 ZIP 中提取文件到临时目录 */
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
try {
const tempPath = await extractZipToTempApi(zipPath, filePath)
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
return tempPath
} catch (error) {
const err = error as Error
onError?.('extractZipFileToTemp', err)
throw err
}
}
/**
* 获取文件服务器 URL
*/
/** 获取文件服务器 URL */
const getFileServerURL = async (): Promise<string> => {
try {
const url = await getFileServerUrlApi()
onSuccess?.('getFileServerURL', { url })
return url
} catch (error) {
const err = error as Error
onError?.('getFileServerURL', err)
throw err
}
}
return {
// 基础操作
listDirectory,
readFile,
writeFile,
deletePath,
// 创建操作
createNewFile,
createNewDir,
// 高级操作
rename,
copy,
move,
// ZIP 操作
listZipContents,
extractZipFile,
extractZipFileToTemp,
getFileServerURL
listDirectory, readFile, writeFile, deletePath,
createNewFile, createNewDir,
rename, copy, move,
listZipContents, extractZipFile, extractZipFileToTemp, getFileServerURL
}
}
// 注意:FileItem 类型已统一定义在 @/types/file-system.ts
@@ -22,6 +22,7 @@ const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
// 缓存检测结果
const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
const CACHE_TTL = 60000 // 1分钟缓存
const CACHE_MAX_SIZE = 200
// ====== URL 工具函数(导出供外部使用) ======
@@ -122,6 +123,7 @@ export function useFilePreview() {
try {
const result = await detectFileTypeByContent(path)
const data = { category: result.category, ext: result.extension }
if (contentDetectCache.size >= CACHE_MAX_SIZE) contentDetectCache.clear()
contentDetectCache.set(path, { timestamp: Date.now(), result: data })
return data
} catch {
@@ -59,7 +59,9 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
* 导航到指定路径
*/
const navigate = async (path: string) => {
if (!path || path === filePath.value) return
if (!path || path === filePath.value) {
return
}
try {
const normalizedPath = normalizePathSeparators(path)
+410 -67
View File
@@ -1,14 +1,16 @@
<template>
<div class="file-system-container">
<!-- 主内容区:左侧边栏 + 右侧工作区 -->
<div class="main-content">
<div ref="mainContentRef" class="main-content">
<!-- 左侧收藏夹侧边栏(全高) -->
<Sidebar
v-if="showSidebar"
:config="sidebarConfig"
:width="sidebarWidth"
@open-favorite="handleOpenFavorite"
@remove-favorite="handleRemoveFavorite"
@toggle-pin="handleToggleFavPin"
@rename-favorite="handleRenameFavorite"
@long-press-start="handleLongPressStart"
@long-press-cancel="handleLongPressCancel"
@drag-start="handleDragStart"
@@ -16,8 +18,12 @@
@drop="handleDrop"
@drag-end="handleDragEnd"
@open-connection-dialog="handleOpenConnectionDialog"
@server-select="handleConnectionChanged"
/>
<!-- 侧边栏拖宽把手 -->
<div v-if="showSidebar" class="sidebar-resizer" @mousedown="handleSidebarResizeWithOverlay"></div>
<!-- 右侧工作区:面包屑工具栏 + 文件列表/编辑器 -->
<div class="workspace-area">
<!-- 面包屑导航工具栏 -->
@@ -93,6 +99,9 @@
@close="hideContextMenu"
/>
<!-- 传输进度面板(复制/移动/下载/上传共用) -->
<TransferPanel />
<!-- 输入对话框 -->
<a-modal
v-model:visible="inputDialogVisible"
@@ -120,8 +129,10 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue'
import { On, Off } from '@wailsio/events'
import { ReadClipboardFiles } from '@bindings/u-desk/app'
import { renderMarkdown, parseMarkdown, isMarkedReady, preloadMarked, renderMermaidDiagrams, rerenderMermaidDiagrams, setCurrentFileDir, setFileServerBase } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme'
import { useTransferStore } from '@/stores/transfer'
// 导入子组件
import Toolbar from './components/Toolbar.vue'
@@ -129,12 +140,15 @@ import Sidebar from './components/Sidebar.vue'
import FileListPanel from './components/FileListPanel.vue'
import FileEditorPanel from './components/FileEditorPanel.vue'
import ContextMenu from './components/ContextMenu.vue'
import TransferPanel from '@/components/TransferPanel.vue'
// 导入 Composables
import { useFileOperations } from './composables/useFileOperations'
import { useFileClipboard } from './composables/useFileClipboard'
import { useFavorites } from './composables/useFavorites'
import { usePathNavigation } from './composables/usePathNavigation'
import { useFilePreview, resolveFileUrl, resolveFileServerBase } from './composables/useFilePreview'
import { resolvePreviewHandler, resolvePreviewComponent } from '@/plugin/registry'
import { useFileEdit } from './composables/useFileEdit'
import { useCommonPaths } from './composables/useCommonPaths'
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
@@ -142,9 +156,12 @@ import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMult
// 导入工具函数
import { getFileName, sortFileList } from '@/utils/fileUtils'
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
import { listDir, saveBase64File, watchFile, unwatchFile } from '@/api/system'
import { listDir, saveBase64File, watchFile, unwatchFile, pickDirectory } from '@/api/system'
import { connectionManager } from '@/api/connection-manager'
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { isAutoRestoreDisabled, clearAutoRestoreDisabled } from '@/utils/freezeWatchdog'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { errMessage } from '@/utils/errors'
import { createResizeHandler } from '@/utils/resize'
// 导入类型
@@ -260,6 +277,58 @@ const savePanelWidth = (width: { left: number; right: number }) => {
const panelWidth = ref(restorePanelWidth())
const workspaceRef = ref<HTMLElement | null>(null)
// 侧边栏宽度(拖拽调整,localStorage 持久化)
const restoreSidebarWidth = (): number => {
try {
const saved = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.SIDEBAR_WIDTH)
if (saved) {
const width = Number(saved)
if (Number.isFinite(width)) {
return Math.min(DEFAULTS.MAX_SIDEBAR_WIDTH, Math.max(DEFAULTS.MIN_SIDEBAR_WIDTH, width))
}
}
} catch (error) {
console.error('恢复侧边栏宽度失败:', error)
}
return DEFAULTS.SIDEBAR_WIDTH
}
const saveSidebarWidth = (width: number) => {
try {
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.SIDEBAR_WIDTH, String(width))
} catch (error) {
console.error('保存侧边栏宽度失败:', error)
}
}
const sidebarWidth = ref(restoreSidebarWidth())
const mainContentRef = ref<HTMLElement | null>(null)
const handleSidebarHorizontalResize = createResizeHandler(
() => mainContentRef.value,
() => sidebarWidth.value,
{
direction: 'horizontal',
outputMode: 'pixels',
minPercent: 0,
maxPercent: 100,
minPixels: DEFAULTS.MIN_SIDEBAR_WIDTH,
onResize: (px) => {
sidebarWidth.value = Math.min(DEFAULTS.MAX_SIDEBAR_WIDTH, Math.max(DEFAULTS.MIN_SIDEBAR_WIDTH, px))
},
onResizeEnd: () => {
isResizing.value = false
saveSidebarWidth(sidebarWidth.value)
},
}
)
// mousedown 时复用工作区遮罩,防止拖拽经过编辑器 iframe 时丢事件
const handleSidebarResizeWithOverlay = (e: MouseEvent) => {
isResizing.value = true
handleSidebarHorizontalResize(e)
}
// 系统路径(使用 composable)
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
@@ -284,20 +353,17 @@ const fileListPanelRef = ref()
// ========== Composables 初始化 ==========
// 文件操作
const fileOps = useFileOperations({
onSuccess: (operation, data) => {
// 操作成功处理
},
onError: (operation, error) => {
// 注意:具体的操作(如重命名、删除等)已经有自己的错误处理
// 这里只记录日志,避免重复显示错误消息
console.error(`[FileOperation] ${operation} 失败:`, error)
}
})
// 文件操作(纯代理层,copy/move 经此调用)
const fileOps = useFileOperations()
// 文件剪贴板(复制/剪切,内存态单例)
const { clipboard, clipboardEmpty, setCopy, setCut, clear: clearClipboard } = useFileClipboard()
// 传输进度面板
const transferStore = useTransferStore()
// 收藏夹
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, renameFavorite: renameFav, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
// 路径导航
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath, getProfileIdForPath } =
@@ -351,13 +417,21 @@ const sidebarConfig = computed(() => ({
draggingState: draggingState.value
}))
// 被剪切的文件路径(仅剪切来源为当前目录时高亮)
const cutFilePaths = computed(() =>
clipboard.value?.mode === 'cut' && clipboard.value.fromDir === filePath.value
? clipboard.value.items.map(i => i.path)
: []
)
// 文件列表面板配置
const fileListPanelConfig = computed(() => ({
fileList: filteredFileList.value,
fileLoading: fileLoading.value,
selectedFileItem: selectedFileItem.value,
editingFilePath: editingFilePath.value,
editingFileName: editingFileName.value
editingFileName: editingFileName.value,
cutPaths: cutFilePaths.value
}))
// Markdown 渲染状态(异步加载 marked 后触发重渲染)
@@ -439,9 +513,15 @@ const fileEditorPanelConfig = computed(() => {
currentFileExtension,
isBinaryFile: isBinaryFileRef.value,
fileMtime: selectedFileItem.value?.modified_time || '',
// 多 tab
previewTabs: multiPreview.tabs.value,
activeTabId: multiPreview.activeTabId.value
// 多 tab(按当前服务器过滤,跨服务器的标签不显示在浮动标签栏)
previewTabs: (() => {
const pid = connectionManager.activeProfile?.id
return multiPreview.tabs.value.filter(t => (t.profileId ?? 'local-default') === pid)
})(),
activeTabId: multiPreview.activeTabId.value,
// 预览插件(优先于内置 v-if 链;.drawio/.dio 命中,其余为 null)
activePlugin: currentFileName ? resolvePreviewHandler(currentFileName) : null,
activePluginComponent: currentFileName ? resolvePreviewComponent(currentFileName) : null
}
})
@@ -462,8 +542,9 @@ const handleRefresh = async () => {
// 文件变化事件处理:自动刷新预览(有未保存修改时跳过)
const handleFileChanged = async (event: any) => {
// Wails v3 On 回调参数是 WailsEvent 对象,路径在 event.data
const changedPath = event?.data ?? ''
const payload = event?.data
const changedPath = typeof payload === 'string' ? payload : payload?.path ?? ''
const mtime = typeof payload === 'object' ? payload?.mtime : undefined
if (!changedPath || !selectedFileItem.value) return
// 标准化路径比较
@@ -473,43 +554,53 @@ const handleFileChanged = async (event: any) => {
// 有未保存修改时不覆盖用户编辑
if (originalContent.value !== undefined && fileContent.value !== originalContent.value) return
// 非文本文件(图片/视频等):刷新预览 URL 并追加文件变更时间打破浏览器缓存
if (isMediaPreviewable(selectedFileItem.value.name)) {
const url = await resolveFileUrl(changedPath)
const sep = url.includes('?') ? '&' : '?'
previewUrl.value = `${url}${sep}_t=${mtime ?? Date.now()}`
Message.success('文件已更新')
return
}
// 文本文件:重新加载内容
await loadFileContent(changedPath)
Message.success('文件已更新')
}
// 程序化切换 profile 时抑制自动导航(如打开收藏/tab切换)
let _suppressAutoNav = false
let _lastActiveId = connectionManager.activeProfile?.id
// 连接切换后重置路径并刷新文件列表(仅在活跃连接实际变化时触发)
connectionManager.onStateChange(async (state) => {
const currentId = connectionManager.activeProfile?.id
if (state !== 'connected') return
if (currentId === _lastActiveId) return
_lastActiveId = currentId
if (_suppressAutoNav) return
// 连接切换唯一入口:谁切换连接谁负责刷新常用路径并设根路径加载。
// 同路径也强制重载:navigate 同路径早退时列表会永远不加载
const switchConnection = async (profileId: string) => {
await connectionManager.connect(profileId)
await syncPreviewToActiveProfile()
await loadCommonPaths()
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
filePath.value = targetPath
if (filePath.value === targetPath) {
await loadDirectory(targetPath)
})
} else {
await navigate(targetPath)
}
}
// 侧边栏服务器行/连接指示器选择:统一走 switchConnection,失败在此提示
const handleConnectionChanged = async (profileId: string) => {
try {
await switchConnection(profileId)
} catch (err) {
Message.error(`连接失败: ${errMessage(err)}`)
}
}
const handleSearchKeywordUpdate = (keyword: string) => {
searchKeyword.value = keyword
}
// 用户主动切换连接时重置到根路径
const handleConnectionChanged = async () => {
await loadCommonPaths()
await navigate(connectionManager.isRemote() ? '/' : 'C:/')
}
const handleGoToPath = async (path: string) => {
// 历史记录下拉选择时,检查是否需要切换 profile
const targetProfileId = getProfileIdForPath(path)
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
await connectionManager.connect(targetProfileId)
await syncPreviewToActiveProfile()
}
await navigate(path)
}
@@ -615,16 +706,13 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
const needSwitch = file.profileId && file.profileId !== currentProfileId
if (needSwitch) {
_suppressAutoNav = true
try {
await connectionManager.connect(file.profileId)
await loadCommonPaths()
} catch (e) {
console.error('切换连接失败:', e)
Message.error(`无法连接: ${e instanceof Error ? e.message : String(e)}`)
Message.error(`无法连接: ${errMessage(e)}`)
return
} finally {
_suppressAutoNav = false
}
} else if (!file.profileId) {
// 无 profileId(旧数据),按路径格式自动切换连接
@@ -633,8 +721,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
const isCurrentlyRemote = connectionManager.isRemote()
if (shouldBeRemote !== isCurrentlyRemote) {
_suppressAutoNav = true
try {
if (shouldBeRemote) {
const remoteProfile = connectionManager.profiles.find(p =>
p.type === 'remote' || p.type === 'sftp' || p.type === 'oss'
@@ -646,9 +732,6 @@ const handleOpenFavorite = async (file: FavoriteFile) => {
connectionManager.disconnect()
}
await loadCommonPaths()
} finally {
_suppressAutoNav = false
}
}
}
@@ -671,6 +754,10 @@ const handleToggleFavPin = (path: string) => {
togglePin(path)
}
const handleRenameFavorite = (path: string, displayName: string) => {
renameFav(path, displayName)
}
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
onLongPressStart(event, index)
}
@@ -868,7 +955,9 @@ const handleContextMenu = (event: MouseEvent, file: FileItem | null = null) => {
x: event.clientX,
y: event.clientY,
context: file ? 'file' : 'blank',
selectedFile: file || undefined
selectedFile: file || undefined,
isSftp: connectionManager.isSftp(),
clipboardEmpty: clipboardEmpty.value
}
}
@@ -887,6 +976,24 @@ const handleContextMenuAction = async (action: string, payload?: any) => {
case 'openWithSystem':
Message.info('系统打开功能开发中...')
break
case 'copy':
if (payload) {
handleCopyFiles([payload])
}
break
case 'cut':
if (payload) {
handleCutFiles([payload])
}
break
case 'download':
if (payload) {
await handleDownloadToLocal(payload)
}
break
case 'paste':
await handlePasteClipboard()
break
case 'rename':
if (payload) {
handleStartEditing(payload.path, payload.name)
@@ -901,6 +1008,145 @@ const handleContextMenuAction = async (action: string, payload?: any) => {
hideContextMenu()
}
// ========== 文件剪贴板(复制/剪切/粘贴) ==========
/** 复制到文件剪贴板(仅本机面板;远程面板给出提示而非静默) */
const handleCopyFiles = (items: FileItem[]) => {
if (connectionManager.isRemote()) {
Message.info('当前面板暂不支持复制')
return
}
setCopy(items, filePath.value)
Message.success(`已复制 ${items.length} 个项目`)
}
/** 剪切到文件剪贴板(仅本机面板;远程面板给出提示而非静默) */
const handleCutFiles = (items: FileItem[]) => {
if (connectionManager.isRemote()) {
Message.info('当前面板暂不支持剪切')
return
}
setCut(items, filePath.value)
Message.success(`已剪切 ${items.length} 个项目`)
}
/** 目标目录内的不重名文件名:「名称 - 副本」「名称 - 副本 (2)」(扩展名前插入) */
const uniqueNameInList = (name: string): string => {
const exists = (n: string) => fileList.value.some(f => f.name.toLowerCase() === n.toLowerCase())
if (!exists(name)) return name
const dot = name.lastIndexOf('.')
const base = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
let candidate = `${base} - 副本${ext}`
let i = 2
while (exists(candidate)) {
candidate = `${base} - 副本 (${i})${ext}`
i++
}
return candidate
}
/** 粘贴文件剪贴板(copy → 复制,cut → 移动;仅本地面板) */
const handlePasteClipboard = async () => {
const clip = clipboard.value
if (!clip || clip.items.length === 0) {
Message.info('剪贴板为空')
return
}
if (connectionManager.isRemote()) {
Message.info('当前面板暂不支持粘贴,可使用拖拽上传')
return
}
const targetDir = filePath.value
if (!targetDir) return
// 剪切源即目标目录时无操作(继续执行会把源文件改名成副本),Explorer 语义是清空剪贴板
if (clip.mode === 'cut' && clip.fromDir === targetDir) {
clearClipboard()
return
}
let failed = 0
for (const item of clip.items) {
const dstName = uniqueNameInList(item.name)
const dstPath = targetDir + getPathSeparator(targetDir) + dstName
try {
if (clip.mode === 'copy') {
await fileOps.copy(item.path, dstPath)
} else {
await fileOps.move(item.path, dstPath)
}
} catch (error) {
failed++
console.error('[Clipboard] 粘贴失败:', item.path, error)
Message.error(`粘贴失败: ${item.name} - ${errMessage(error)}`)
}
}
if (clip.mode === 'cut' && failed === 0) {
clearClipboard()
}
await loadDirectory(targetDir)
if (failed === 0) {
Message.success(`已粘贴 ${clip.items.length} 个项目`)
}
}
/** SFTP 文件/目录下载到本机(目录选择器 → 流式下载) */
const handleDownloadToLocal = async (file: FileItem) => {
try {
const dstDir = await pickDirectory()
if (!dstDir) return
const localPath = await connectionManager.getTransport().downloadToFile(file.path, dstDir)
Message.success(`已下载到: ${localPath}`)
} catch (error) {
Message.error(`下载失败: ${errMessage(error)}`)
}
}
// ========== 外部文件拖入(OS 资源管理器 → 文件列表) ==========
const handleExternalDrop = async (paths: string[]) => {
if (!paths || paths.length === 0) return
const targetDir = filePath.value
if (!targetDir) return
const transport = connectionManager.getTransport()
try {
if (connectionManager.isSftp()) {
// SFTP 面板:上传到服务器当前目录(目录递归)
for (const p of paths) {
await transport.uploadFromFile(p, targetDir)
}
await loadDirectory(targetDir)
Message.success(`已上传 ${paths.length} 个项目`)
} else if (connectionManager.isRemote()) {
Message.info('当前面板暂不支持拖入文件')
} else {
// 本机面板:复制进当前目录(重名自动副本)
for (const p of paths) {
const name = getFileName(p)
if (!name) continue
const dstName = uniqueNameInList(name)
await transport.copyPath(p, targetDir + getPathSeparator(targetDir) + dstName)
}
await loadDirectory(targetDir)
Message.success(`已复制 ${paths.length} 个项目`)
}
} catch (error) {
Message.error(`拖入失败: ${errMessage(error)}`)
await loadDirectory(targetDir)
}
}
/** 解析 external-file-drop 事件 payload(事件体或 .data 均可) */
const handleExternalDropEvent = (event: unknown) => {
const data: any = parseWailsEventData(event)
const files = Array.isArray(data) ? data : (data?.files ?? [])
handleExternalDrop(files.map(String))
}
// ========== 创建文件/文件夹 ==========
/**
@@ -1148,16 +1394,21 @@ const isMediaPreviewable = (filename: string): boolean => {
const activateTab = async (tab: PreviewTab) => {
// 如果 tab 属于不同 profile,自动切换连接
if (tab.profileId && tab.profileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try {
await connectionManager.connect(tab.profileId)
} catch (e) {
console.error('切换连接失败:', e)
} finally {
_suppressAutoNav = false
}
}
selectedFileItem.value = tab.fileItem
// 更新面包屑和文件列表到文件所在目录
const dir = getParentPath(tab.fileItem.path)
if (dir && dir !== filePath.value) {
filePath.value = dir
loadDirectory(dir).catch(e => Message.error(`目录加载失败: ${e}`))
}
if (tab.loaded) {
restoreTabState(tab)
} else {
@@ -1213,6 +1464,26 @@ const doCloseTab = (tabId: string) => {
}
}
/** 切换服务器后预览区联动:只显示当前服务器的标签与内容,避免旧服务器文档残留卡住 */
const syncPreviewToActiveProfile = async () => {
const currentProfileId = connectionManager.activeProfile?.id
const isMine = (t: PreviewTab) => (t.profileId ?? 'local-default') === currentProfileId
const active = multiPreview.activeTab.value
if (active && isMine(active)) return
// 面板上残留的是旧服务器内容,先缓存回它自己的 tab(loaded 标记让切回时秒恢复)
cacheCurrentTabState()
const mine = multiPreview.tabs.value.find(isMine)
if (mine) {
await activateTab(mine)
} else {
// 当前服务器没有已打开的预览:回到空白预览态
selectedFileItem.value = null
clearContent()
}
}
/** 缓存当前 tab 的编辑状态 */
const cacheCurrentTabState = () => {
multiPreview.cacheCurrentTab({
@@ -1262,12 +1533,24 @@ const selectFile = async (path: string) => {
}
}
// 加载顺序令牌:快速连续点击文件时,先发起的慢请求(远程下载/大文件)
// 若后完成会覆盖新文件的 previewUrl,导致图片预览 src 指向错误文件(显示为破损小方框)。
// 每次进入 loadFileContent 递增令牌,await 返回后校验,过期请求直接丢弃。
let loadFileSeq = 0
const loadFileContent = async (path: string) => {
// 切换文件时先取消前一个文件的监听
unwatchFile().catch(() => {})
const seq = ++loadFileSeq
try {
await updatePreviewUrl(path)
// await 期间用户已点开其它文件 → 本次结果是过期的,丢弃,不得污染全局状态
if (seq !== loadFileSeq) return
// 所有文件类型都启动监听(包括图片/视频等非文本文件)
watchFile(path).catch(() => {})
const fileName = getFileName(path)
const fileItem = selectedFileItem.value
@@ -1309,6 +1592,8 @@ const loadFileContent = async (path: string) => {
if (!ext || !FILE_EXTENSIONS.IMAGE.includes(ext)) {
try {
const detected = await detectByContent(path, fileSize)
// await 期间已切换到其它文件 → 丢弃
if (seq !== loadFileSeq) return
if (detected && detected.category === 'image') {
// 内容检测确认为图片,清空文本内容,让预览URL生效
clearContent()
@@ -1322,7 +1607,6 @@ const loadFileContent = async (path: string) => {
// 其他情况:加载为文本文件
await loadFile(path)
watchFile(path).catch(() => {})
} catch (error) {
Message.error(`读取文件失败: ${error}`)
}
@@ -1475,10 +1759,16 @@ onMounted(async () => {
window.addEventListener('paste', handlePaste)
On('file-changed', handleFileChanged)
// 传输进度事件(复制/移动/下载/上传)
transferStore.setupEventListeners()
// 外部文件拖入(Wails EnableFileDrop:OS 级拖放经 Go 转发为 external-file-drop 事件)
On('external-file-drop', handleExternalDropEvent)
// 2. 加载系统路径
await loadCommonPaths()
// SFTP 连接是异步的,未就绪时跳过初始加载,由 onStateChange('connected') 触发
// SFTP 握手未就绪时只设根路径不加载:此时列表请求会回落本地 transport,显示幻影本地文件
if (connectionManager.isSftp() && connectionManager.state !== 'connected') {
filePath.value = '/'
return
@@ -1503,14 +1793,20 @@ onMounted(async () => {
// 会话恢复(不阻塞首屏)
async function restoreSession() {
// 卡死自愈熔断:上次启动若触发了卡死自愈,本次主动跳过自动恢复(防 reload-loop)
if (isAutoRestoreDisabled()) {
clearAutoRestoreDisabled()
console.warn('[restoreSession] 检测到上次卡死自愈标记,本次跳过自动恢复历史文件')
return
}
const session = multiPreview.restoreSession()
if (session.paths.length > 0) {
const activeId = session.activePath
? session.profileMap.get(session.activePath.replace(/\\/g, '/').toLowerCase())
: undefined
if (activeId && activeId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) } finally { _suppressAutoNav = false }
try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) }
}
for (const path of session.paths) {
@@ -1554,6 +1850,8 @@ onUnmounted(() => {
window.removeEventListener('click', hideContextMenu)
window.removeEventListener('paste', handlePaste)
Off('file-changed')
Off('external-file-drop')
transferStore.removeEventListeners()
unwatchFile()
// 应用关闭时立即持久化当前会话
cacheCurrentTabState()
@@ -1598,12 +1896,7 @@ const handleKeyDown = async (event: KeyboardEvent) => {
try {
// 如果当前在远程连接,先切回本地
if (connectionManager.isRemote()) {
_suppressAutoNav = true
try {
await connectionManager.connect('local-default')
} finally {
_suppressAutoNav = false
}
await loadCommonPaths()
}
await navigate(drivePath)
@@ -1642,6 +1935,34 @@ const handleKeyDown = async (event: KeyboardEvent) => {
}
}
// Ctrl+C / Ctrl+X / Ctrl+V 文件剪贴板(输入框/行内编辑态放行系统默认行为)
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && ['c', 'x', 'v'].includes(event.key.toLowerCase())) {
const target = event.target as HTMLElement
const inEditable = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
if (inEditable || editingFilePath.value) return
const key = event.key.toLowerCase()
if (key === 'v') {
// 剪贴板有文件项时优先生效(远程面板由粘贴逻辑 toast 提示),否则放行走「粘贴剪贴板图片」路径
if (clipboard.value && clipboard.value.items.length > 0) {
event.preventDefault()
await handlePasteClipboard()
}
return
}
// 复制/剪切仅本机面板生效;SFTP 面板静默(菜单本就不提供),
// HTTP 远程/OSS 面板放行到 handler 给出提示
if (connectionManager.isSftp()) return
if (selectedFileItem.value) {
event.preventDefault()
if (key === 'c') {
handleCopyFiles([selectedFileItem.value])
} else {
handleCutFiles([selectedFileItem.value])
}
}
}
// Ctrl+S 保存
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault()
@@ -1684,8 +2005,7 @@ const handleKeyDown = async (event: KeyboardEvent) => {
try {
const targetProfileId = await back()
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
await connectionManager.connect(targetProfileId)
}
} finally {
isNavigating.value = false
@@ -1700,8 +2020,7 @@ const handleKeyDown = async (event: KeyboardEvent) => {
try {
const targetProfileId = await forward()
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
_suppressAutoNav = true
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
await connectionManager.connect(targetProfileId)
}
} finally {
isNavigating.value = false
@@ -1730,6 +2049,17 @@ const handlePaste = async (event: ClipboardEvent) => {
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
// 系统剪贴板文件(资源管理器复制,文件+文件夹)优先:
// 后端读 CF_HDROP 拿真实路径(DOM paste 拿不到文件夹),复用拖入导入链路
try {
const sysPaths = await ReadClipboardFiles()
if (sysPaths && sysPaths.length > 0) {
event.preventDefault()
await handleExternalDrop(sysPaths)
return
}
} catch { /* 剪贴板被占用等,回落图片粘贴 */ }
const items = event.clipboardData?.items
if (!items) return
@@ -1857,6 +2187,19 @@ watch(() => themeStore.isDark, async () => {
background: var(--color-primary-light-1);
}
/* 侧边栏拖宽把手(与文件列表区分隔条同款) */
.sidebar-resizer {
width: 3px;
background: var(--color-border);
cursor: col-resize;
transition: background 0.2s;
flex-shrink: 0;
}
.sidebar-resizer:hover {
background: var(--color-primary-light-1);
}
.resize-overlay {
position: absolute;
inset: 0;
+120
View File
@@ -0,0 +1,120 @@
<template>
<div v-if="transferStore.items.length" class="transfer-panel">
<div v-for="item in transferStore.items" :key="item.id" class="transfer-item">
<div class="transfer-item-info">
<span class="transfer-item-name" :title="item.name">
{{ item.name }}
</span>
<span class="transfer-item-meta">
<span v-if="item.status === 'error'" class="transfer-item-status error">失败</span>
<span v-else class="transfer-item-status">{{ percent(item) }}%</span>
<span class="transfer-item-close" title="关闭" @click="transferStore.dismiss(item.id)">×</span>
</span>
</div>
<a-progress
:percent="percent(item)"
:status="item.status === 'error' ? 'exception' : item.status === 'done' ? 'success' : 'normal'"
:show-text="false"
size="small"
/>
<div v-if="item.status === 'error' && item.error" class="transfer-item-error" :title="item.error">
{{ item.error }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTransferStore } from '../stores/transfer'
import type { TransferItem } from '../stores/transfer'
const transferStore = useTransferStore()
const percent = (item: TransferItem): number => {
if (item.total > 0) {
return Math.min(100, Math.round((item.copied / item.total) * 100))
}
// 总大小未知(空文件等)按完成度显示
return item.status === 'done' ? 100 : 0
}
</script>
<style scoped>
.transfer-panel {
position: fixed;
right: 16px;
bottom: 16px;
width: 280px;
max-height: 260px;
overflow-y: auto;
background: var(--color-bg-2);
border: 1px solid var(--color-border-2);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1100;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.transfer-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.transfer-item-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.transfer-item-name {
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer-item-meta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.transfer-item-status {
color: var(--color-text-3);
min-width: 30px;
text-align: right;
}
.transfer-item-status.error {
color: rgb(var(--danger-6));
}
.transfer-item-close {
cursor: pointer;
color: var(--color-text-3);
font-size: 14px;
line-height: 1;
padding: 0 2px;
border-radius: 3px;
}
.transfer-item-close:hover {
color: var(--color-text-1);
background: var(--color-fill-2);
}
.transfer-item-error {
font-size: 11px;
color: rgb(var(--danger-6));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+47 -41
View File
@@ -3,13 +3,12 @@
</template>
<script setup>
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
import { Modal, Message, Progress } from '@arco-design/web-vue'
import { computed, ref, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
import { Modal, Message, Progress, Button } from '@arco-design/web-vue'
import { useUpdateStore } from '../stores/update'
import { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
import { DownloadUpdate } from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
import { GetCurrentVersion } from '../wailsjs/v3-bindings/u-desk/app'
const props = defineProps({
modelValue: {
@@ -32,7 +31,9 @@ let confirmModalInstance = null
let progressModalInstance = null
// Computed
const currentVersion = computed(() => props.updateInfo?.current_version || '0.1.0')
// 更新信息未带 current_version 时从后端兜底获取
const backendVersion = ref('')
const currentVersion = computed(() => props.updateInfo?.current_version || backendVersion.value)
const latestVersion = computed(() => props.updateInfo?.latest_version || '')
const changelog = computed(() => props.updateInfo?.changelog || '')
const forceUpdate = computed(() => props.updateInfo?.force_update || false)
@@ -132,6 +133,15 @@ const showUpdateModal = () => {
)
}
// 跳过此版本(强制更新不可跳过)
if (!forceUpdate.value) {
elements.push(
h('div', { style: { marginTop: '8px', textAlign: 'right' } }, [
h(Button, { size: 'small', type: 'text', onClick: handleSkipVersion }, () => '跳过此版本')
])
)
}
return elements
},
okText: '立即更新',
@@ -186,23 +196,13 @@ const getProgressModalContent = () => {
]
}
// 安装中状态
if (updateStore.installing) {
// 安装中状态:下载完成后进入安装阶段,弹窗保持打开直到进程重启
return [
h('div', { style: { marginBottom: '16px' } }, [
h('div', { style: { marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' } }, '正在安装更新...')
]),
h('div', { style: { fontSize: '12px', color: 'var(--color-text-3)' } }, '请稍候,应用将在安装完成后自动重启...')
]
}
// 完成状态
return [
h('div', { style: { marginBottom: '16px', textAlign: 'center' } }, [
h('div', { style: { fontSize: '16px', color: 'rgb(var(--success-6))', marginBottom: '8px' } }, '✓ 更新完成')
]),
h('div', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '应用将在几秒后自动重启...')
]
}
// 更新进度弹窗内容
@@ -273,16 +273,23 @@ const closeModals = () => {
closeProgressModal()
}
// 下载更新
// 跳过此版本
const handleSkipVersion = async () => {
try {
await updateStore.skipVersion(props.updateInfo.latest_version)
} catch (error) {
console.error('跳过版本失败:', error)
}
closeModals()
emit('update:modelValue', false)
}
// 下载更新(进度与完成事件由 store 统一处理)
const handleDownload = async () => {
await showProgressModal()
try {
const result = await DownloadUpdate(props.updateInfo.download_url)
if (result.success) return
closeProgressModal()
Message.error(result.message || '下载启动失败')
await updateStore.downloadUpdate()
} catch (error) {
console.error('下载失败:', error)
closeProgressModal()
@@ -290,35 +297,34 @@ const handleDownload = async () => {
}
}
// 下载完成处理(本地覆盖:关闭弹窗)
const onDownloadComplete = async (event) => {
const data = typeof event === 'string' ? JSON.parse(event) : event
if (data.error) {
// 下载/安装状态收尾:下载失败收起进度弹窗;安装流程结束(重启前或失败)关闭弹窗
watch(
() => [updateStore.installing, updateStore.downloadStatus],
([installing, status], [prevInstalling]) => {
if (status === 'exception') {
closeProgressModal()
Message.error('下载失败:' + data.error)
return
}
if (!data.success || !data.file_path) {
closeProgressModal()
Message.error('下载完成但数据不完整')
return
}
// 等待安装完成
await new Promise(resolve => setTimeout(resolve, 3000))
if (prevInstalling && !installing) {
closeProgressModal()
emit('update:modelValue', false)
}
}
}
)
// 生命周期
onMounted(() => {
On('download-complete', onDownloadComplete)
onMounted(async () => {
try {
const result = await GetCurrentVersion()
if (result.success) {
backendVersion.value = result.data?.version || ''
}
} catch {
// 后端未就绪时忽略,留空兜底
}
})
onUnmounted(() => {
Off('download-complete')
closeModals()
})
</script>
+100 -14
View File
@@ -45,6 +45,30 @@
</a-button>
</a-space>
<!-- 更新设置 -->
<div class="config-section">
<div class="config-row">
<span class="config-label">自动检查更新</span>
<a-switch v-model="autoCheckEnabled" />
</div>
<div class="config-row">
<span class="config-label">检查间隔(分钟)</span>
<a-input-number v-model="checkIntervalMinutes" :min="5" :max="1440" style="width: 120px" />
</div>
<div class="config-row">
<span class="config-label">检查地址</span>
<a-input
v-model="checkURL"
placeholder="留空使用默认 https://c.1216.top/last-version.json"
style="width: 320px"
allow-clear
/>
</div>
<a-button type="primary" size="small" :loading="savingConfig" @click="handleSaveConfig">
保存设置
</a-button>
</div>
<!-- 更新信息 -->
<a-alert
v-if="updateInfo"
@@ -67,7 +91,7 @@
<template #icon>
<icon-download />
</template>
{{ downloadProgress > 0 ? '下载中...' : '下载更新' }}
{{ downloadButtonText }}
</a-button>
<a-button
v-if="downloadedFile"
@@ -89,7 +113,7 @@
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
<a-progress
:percent="downloadProgress"
:status="downloadStatus"
:status="progressBarStatus"
/>
<div class="progress-info">
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
@@ -113,15 +137,16 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
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 { parseMarkdown, isMarkedReady, preloadMarked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
import { GetCurrentVersion, GetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
import { parseWailsEventData } from '@/utils/wailsEvent'
import { GetCurrentVersion, GetUpdateConfig, SetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
import { Events } from '@wailsio/runtime'
// Emits
defineEmits(['open-version-history'])
@@ -138,6 +163,26 @@ const lastCheckTime = ref('-')
const installResult = ref(null)
const downloadedFile = ref(null)
// 更新设置
const autoCheckEnabled = ref(false)
const checkIntervalMinutes = ref(60)
const checkURL = ref('')
const savingConfig = ref(false)
// 下载按钮文案:下载中/安装中/可下载三态
const downloadButtonText = computed(() => {
if (downloading.value) return `下载中...${Math.round(downloadProgress.value)}%`
if (installing.value) return '安装中...'
return '下载更新'
})
// a-progress 状态映射:store 的 active/exception/success → arco 的 normal/danger/success
const progressBarStatus = computed(() => {
if (downloadStatus.value === 'exception') return 'danger'
if (downloadStatus.value === 'success') return 'success'
return 'normal'
})
/** 渲染 changelog(Markdown → HTML) */
function renderChangelog(text: string): string {
if (!text) return ''
@@ -162,13 +207,39 @@ const loadConfig = async () => {
const result = await GetUpdateConfig()
if (!result.success) return
const { last_check_time = '-' } = result.data || {}
const {
last_check_time = '-',
auto_check_enabled = false,
check_interval_minutes = 60,
check_url = ''
} = result.data || {}
lastCheckTime.value = last_check_time
autoCheckEnabled.value = auto_check_enabled
checkIntervalMinutes.value = check_interval_minutes
checkURL.value = check_url
} catch (error) {
console.error('加载配置失败:', error)
}
}
// 保存设置
const handleSaveConfig = async () => {
savingConfig.value = true
try {
const result = await SetUpdateConfig(autoCheckEnabled.value, checkIntervalMinutes.value, checkURL.value)
if (result.success) {
Message.success('设置已保存')
} else {
Message.error(result.message || '保存设置失败')
}
} catch (error) {
console.error('保存设置失败:', error)
Message.error('保存设置失败:' + (error as Error).message)
} finally {
savingConfig.value = false
}
}
// 检查更新
const handleCheckUpdate = async () => {
installResult.value = null
@@ -228,12 +299,7 @@ const handleInstall = async () => {
// 监听下载完成事件(本地覆盖:记录下载文件路径)
const onDownloadComplete = (event) => {
let data: any
try {
data = typeof event === 'string' ? JSON.parse(event) : event
} catch {
return
}
const data = parseWailsEventData(event)
if (data.success && data.file_path) {
downloadedFile.value = data.file_path
@@ -245,11 +311,11 @@ onMounted(async () => {
await loadConfig()
// 监听下载完成事件(仅用于记录文件路径)
On('download-complete', onDownloadComplete)
Events.On('download-complete', onDownloadComplete)
})
onUnmounted(() => {
Off('download-complete')
Events.Off('download-complete')
})
</script>
@@ -357,4 +423,24 @@ onUnmounted(() => {
font-size: 12px;
color: var(--color-text-3);
}
.config-section {
margin-top: 16px;
padding: 12px 16px;
background: var(--color-fill-1);
border-radius: 4px;
}
.config-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.config-label {
width: 110px;
flex-shrink: 0;
font-size: 13px;
color: var(--color-text-2);
}
</style>
+44 -14
View File
@@ -17,14 +17,20 @@
<!-- iframe 嵌入远程页面 -->
<div class="iframe-wrapper">
<iframe
ref="iframeRef"
:src="iframeSrc"
class="version-iframe"
frameborder="0"
@load="onIframeLoad"
/>
<div v-if="loading" class="iframe-loading">
<a-spin size="32" />
<div v-if="loadFailed" class="iframe-loading">
<p>加载失败,请检查网络</p>
<a-button type="primary" size="small" @click="handleRefresh">
<template #icon><icon-refresh /></template>
重试
</a-button>
</div>
<div v-else-if="loading" class="iframe-loading">
<a-spin :size="32" />
<p>加载中...</p>
</div>
</div>
@@ -32,33 +38,50 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { IconRefresh, IconExport } from '@arco-design/web-vue/es/icon'
import { GetCurrentVersion } from '../wailsjs/v3-bindings/u-desk/app'
// ==================== 常量 ====================
const BASE_URL = 'https://c.1216.top/u-desk/version.html'
const LOAD_TIMEOUT = 10000
// ==================== 状态 ====================
const currentVersion = ref('')
const loading = ref(true)
const refreshing = ref(false)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const loadFailed = ref(false)
const cacheBuster = ref(Date.now())
let loadTimer: number | undefined
// ==================== 计算属性 ====================
const iframeSrc = computed(() => {
if (currentVersion.value) {
return `${BASE_URL}?v=${currentVersion.value}&vis=u-desk`
}
return `${BASE_URL}?vis=u-desk`
const version = currentVersion.value ? `v=${currentVersion.value}&` : ''
return `${BASE_URL}?${version}vis=u-desk&_t=${cacheBuster.value}`
})
// ==================== 操作 ====================
function clearLoadTimer(): void {
if (loadTimer !== undefined) {
window.clearTimeout(loadTimer)
loadTimer = undefined
}
}
function startLoadTimer(): void {
clearLoadTimer()
loadFailed.value = false
loadTimer = window.setTimeout(() => {
if (loading.value) loadFailed.value = true
}, LOAD_TIMEOUT)
}
function handleRefresh(): void {
refreshing.value = true
loading.value = true
if (iframeRef.value) {
iframeRef.value.src = iframeSrc.value
}
// 更新 cache-buster 触发 iframe 重载(赋相同值不会重新加载)
cacheBuster.value = Date.now()
startLoadTimer()
}
function handleOpenExternal(): void {
@@ -66,21 +89,28 @@ function handleOpenExternal(): void {
}
function onIframeLoad(): void {
clearLoadTimer()
loading.value = false
refreshing.value = false
}
// ==================== 生命周期 ====================
onMounted(async () => {
startLoadTimer()
try {
const result = await window.go?.main?.App?.GetCurrentVersion?.()
const result = await GetCurrentVersion()
if (result?.success) {
currentVersion.value = result.data?.version || ''
startLoadTimer()
}
} catch {
// Wails 未就绪时忽略
// 后端未就绪时忽略,不带版本参数加载
}
})
onUnmounted(() => {
clearLoadTimer()
})
</script>
<style scoped>
@@ -1,354 +0,0 @@
/**
* 收藏夹管理 composable
*
* @module composables/useFavoriteFiles
* @description 封装收藏夹的增删改查逻辑,支持持久化存储
*/
import { ref, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 收藏夹 composable
* @param {string} storageKey - localStorage 键名
* @param {Object} [options] - 配置选项
* @param {number} [options.maxLength=50] - 最大收藏数量
* @param {Function} [options.onAdd] - 添加收藏回调
* @param {Function} [options.onRemove] - 移除收藏回调
* @returns {UseFavoriteFilesReturn} 收藏夹操作API
*
* @example
* const {
* favoriteFiles,
* isFavorite,
* toggleFavorite,
* removeFavorite,
* clearAll
* } = useFavoriteFiles('app-favorites')
*
* // 在模板中使用
* <a-button @click="toggleFavorite(file)">
* <icon-star-fill v-if="isFavorite(file.path)" />
* <icon-star v-else />
* </a-button>
*/
export function useFavoriteFiles(storageKey, options = {}) {
const {
maxLength = 50,
onAdd = () => {},
onRemove = () => {},
} = options
// 收藏列表
const favoriteFiles = ref([])
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)
}
}
/**
* 判断文件/目录是否已收藏
* @param {string} path - 文件/目录路径
* @returns {boolean} 是否已收藏
*/
const isFavorite = (path) => {
if (!path || !Array.isArray(favoriteFiles.value)) {
return false
}
return favoriteFiles.value.some(fav => fav.path === path)
}
/**
* 排序收藏列表(按创建时间倒序,最新的在上面)
*/
const sortFavorites = () => {
if (!Array.isArray(favoriteFiles.value)) {
return
}
favoriteFiles.value.sort((a, b) => {
const timeA = a.addedAt || 0
const timeB = b.addedAt || 0
return timeB - timeA // 倒序:最新的在上面
})
save()
}
/**
* 切换收藏状态
* @param {Object} item - 文件/目录信息
* @param {string} item.path - 路径
* @param {string} item.name - 名称
* @param {boolean} item.is_dir - 是否为目录
* @returns {boolean} 操作后是否为收藏状态
*/
const toggleFavorite = (item) => {
if (!item || !item.path) {
Message.warning('无效的文件信息')
return false
}
const index = favoriteFiles.value.findIndex(fav => fav.path === item.path)
if (index > -1) {
// 已收藏,执行取消收藏
favoriteFiles.value.splice(index, 1)
save(favoriteFiles.value) // 直接保存,不重新排序
onRemove(item)
Message.info(`已取消收藏: ${item.name}`)
return false
} else {
// 未收藏,执行添加收藏
if (favoriteFiles.value.length >= maxLength) {
Message.warning(`收藏夹已满,最多只能收藏 ${maxLength} 项`)
return false
}
favoriteFiles.value.push({
path: item.path,
name: item.name,
isDir: item.isDir || false,
addedAt: Date.now(),
})
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
onAdd(item)
Message.success(`已收藏: ${item.name}`)
return true
}
}
/**
* 移除收藏
* @param {string} path - 文件/目录路径
* @returns {boolean} 是否成功移除
*/
const removeFavorite = (path) => {
if (!path) {
Message.warning('请提供有效的路径')
return false
}
const index = favoriteFiles.value.findIndex(fav => fav.path === path)
if (index === -1) {
Message.warning('该路径不在收藏夹中')
return false
}
const item = favoriteFiles.value[index]
favoriteFiles.value.splice(index, 1)
save(favoriteFiles.value) // 直接保存,不重新排序
onRemove(item)
Message.info(`已取消收藏: ${item.name}`)
return true
}
/**
* 打开收藏的文件/目录
* @param {string} path - 文件/目录路径
* @param {Function} onOpen - 打开回调函数
* @returns {Promise<boolean>} 是否成功打开
*/
const openFavorite = async (path, onOpen) => {
if (!path || !onOpen) {
return false
}
const item = favoriteFiles.value.find(fav => fav.path === path)
if (!item) {
Message.warning('收藏项不存在')
return false
}
return await onOpen(item)
}
/**
* 清空所有收藏
* @param {boolean} [confirm=true] - 是否需要确认
* @returns {boolean} 是否成功清空
*/
const clearAll = (confirm = true) => {
const executeClear = () => {
const count = favoriteFiles.value.length
favoriteFiles.value = []
save([])
Message.success(`已清空 ${count} 个收藏项`)
return true
}
if (!confirm) {
return executeClear()
}
// 使用原生 confirm(简单场景)
if (window.confirm(`确定要清空所有 ${favoriteFiles.value.length} 个收藏项吗?`)) {
return executeClear()
}
return false
}
/**
* 获取收藏列表(按创建时间排序)
* @param {string} [order='desc'] - 排序方式:'asc'或'desc'
* @returns {Array} 排序后的收藏列表
*/
const getSortedFavorites = (order = 'desc') => {
const sorted = [...favoriteFiles.value]
sorted.sort((a, b) => {
const timeA = a.addedAt || 0
const timeB = b.addedAt || 0
return order === 'desc' ? timeB - timeA : timeA - timeB
})
return sorted
}
/**
* 按名称搜索收藏
* @param {string} keyword - 搜索关键词
* @returns {Array} 匹配的收藏列表
*/
const searchFavorites = (keyword) => {
if (!keyword || !keyword.trim()) {
return favoriteFiles.value
}
const lowerKeyword = keyword.toLowerCase().trim()
return favoriteFiles.value.filter(fav =>
fav.name.toLowerCase().includes(lowerKeyword) ||
fav.path.toLowerCase().includes(lowerKeyword)
)
}
/**
* 重新排序收藏列表(拖拽排序)
* @param {number} fromIndex - 源索引
* @param {number} toIndex - 目标索引
* @returns {boolean} 是否成功重排序
*/
const reorderFavorites = (fromIndex, toIndex) => {
if (!Array.isArray(favoriteFiles.value)) {
return false
}
if (fromIndex < 0 || fromIndex >= favoriteFiles.value.length ||
toIndex < 0 || toIndex >= favoriteFiles.value.length) {
return false
}
if (fromIndex === toIndex) {
return false
}
// 移动数组元素
const [movedItem] = favoriteFiles.value.splice(fromIndex, 1)
favoriteFiles.value.splice(toIndex, 0, movedItem)
// 保存新顺序
save(favoriteFiles.value)
return true
}
// 旧字段名 → 新字段名迁移(一次性,迁移后自动回写)
const migrateFieldNames = (list) => {
if (!Array.isArray(list)) return
const map = { is_dir: 'isDir', created_at: 'addedAt' }
let changed = false
list.forEach(item => {
for (const [old, newKey] of Object.entries(map)) {
if (old in item) {
if (!(newKey in item)) item[newKey] = item[old]
delete item[old]
changed = true
}
}
})
if (changed) save(list)
}
// 组件挂载时加载数据并迁移旧字段
onMounted(() => {
load()
migrateFieldNames(favoriteFiles.value)
})
return {
// 状态
favoriteFiles,
// 方法
isFavorite,
toggleFavorite,
removeFavorite,
openFavorite,
clearAll,
getSortedFavorites,
searchFavorites,
sortFavorites,
reorderFavorites,
load,
save,
}
}
/**
* @typedef {Object} UseFavoriteFilesReturn
* @property {Ref<Array>} favoriteFiles - 收藏列表(自动按时间倒序排列)
* @property {Function} isFavorite - 判断是否已收藏
* @property {Function} toggleFavorite - 切换收藏状态
* @property {Function} removeFavorite - 移除收藏
* @property {Function} openFavorite - 打开收藏项
* @property {Function} clearAll - 清空所有收藏
* @property {Function} getSortedFavorites - 获取排序后的列表
* @property {Function} searchFavorites - 搜索收藏
* @property {Function} sortFavorites - 手动排序收藏列表
* @property {Function} reorderFavorites - 拖拽重新排序
* @property {Function} load - 手动加载数据
* @property {Function} save - 手动保存数据
*/
/**
* 创建多个收藏夹管理实例
* @param {Object} config - 配置对象
* @returns {Object} 收藏夹管理实例集合
*
* @example
* const {
* filesystemFavs,
* deviceTestFavs
* } = createMultipleFavorites({
* filesystem: 'app-filesystem-favorites',
* deviceTest: 'app-device-test-favorites'
* })
*/
export function createMultipleFavorites(config) {
const result = {}
Object.keys(config).forEach(key => {
result[key] = useFavoriteFiles(config[key])
})
return result
}
@@ -1,373 +0,0 @@
/**
* 文件操作逻辑封装
*
* @module composables/useFileOperations
* @description 封装所有文件操作逻辑,提供统一的错误处理和状态管理
*/
import { ref, watch } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import {
listDir,
readFile as readFileApi,
writeFile as writeFileApi,
deletePath as deletePathApi,
} from '@/api'
/**
* LocalStorage 键名
*/
const STORAGE_KEY_LAST_PATH = 'app-filesystem-last-path'
/**
* 文件操作 composable
* @param {Object} [options] - 配置选项
* @param {Function} [options.onSuccess] - 操作成功回调
* @param {Function} [options.onError] - 操作失败回调
* @returns {UseFileOperationsReturn} 文件操作API
*
* @example
* const {
* filePath,
* fileContent,
* fileList,
* fileLoading,
* listDirectory,
* readFile,
* writeFile,
* deleteFile
* } = useFileOperations({
* onSuccess: (operation, data) => console.log(operation, data),
* onError: (operation, error) => console.error(operation, error)
* })
*/
export function useFileOperations(options = {}) {
const { onSuccess = () => {}, onError = () => {} } = options
// ========== 响应式状态 ==========
/**
* 当前文件/目录路径
* @type {Ref<string>}
*/
// 从 localStorage 恢复上次路径
const savedPath = localStorage.getItem(STORAGE_KEY_LAST_PATH)
const filePath = ref(savedPath || '')
/**
* 文件内容
* @type {Ref<string>}
*/
const fileContent = ref('')
/**
* 文件列表
* @type {Ref<Array>}
*/
const fileList = ref([])
/**
* 加载状态
* @type {Ref<boolean>}
*/
const fileLoading = ref(false)
/**
* 正在删除状态(防止并发删除)
* @type {Ref<boolean>}
*/
const isDeleting = ref(false)
// ========== 文件操作方法 ==========
/**
* 列出目录内容
* @param {string} [path] - 目录路径,不传则使用当前路径
* @returns {Promise<boolean>} 是否成功
*/
const listDirectory = async (path) => {
const targetPath = path || filePath.value
if (!targetPath) {
Message.error('请输入目录路径')
return false
}
fileLoading.value = true
try {
const result = await listDir(targetPath)
fileList.value = result
if (!path) {
// 如果没有传参,更新当前路径
filePath.value = targetPath
}
onSuccess('listDirectory', { path: targetPath, count: result.length })
return true
} catch (error) {
onError('listDirectory', error)
const errorMsg = error.message || error || '未知错误'
Message.error(`列出目录失败 [${targetPath}]: ${errorMsg}`)
return false
} finally {
fileLoading.value = false
}
}
/**
* 读取文件内容
* @param {string} [path] - 文件路径,不传则使用当前路径
* @returns {Promise<boolean>} 是否成功
*/
const readFile = async (path) => {
const targetPath = path || filePath.value
if (!targetPath) {
Message.error('请输入文件路径')
return false
}
fileLoading.value = true
try {
const content = await readFileApi(targetPath)
fileContent.value = content
if (!path) {
filePath.value = targetPath
}
onSuccess('readFile', { path: targetPath })
// 文件读取成功,静默无提示
return true
} catch (error) {
onError('readFile', error)
Message.error(`读取文件失败: ${error.message || error}`)
return false
} finally {
fileLoading.value = false
}
}
/**
* 写入文件内容
* @param {string} [content] - 要写入的内容,不传则使用当前内容
* @param {string} [path] - 文件路径,不传则使用当前路径
* @param {string} [fileName] - 文件名,用于成功提示显示
* @param {boolean} [isShortcut=false] - 是否是快捷键触发(快捷键不显示提示)
* @returns {Promise<boolean>} 是否成功
*/
const writeFile = async (content, path, fileName, isShortcut = false) => {
// 忽略事件对象(当点击按钮时 Vue 会传递事件对象)
const targetContent = (content !== undefined && typeof content === 'string') ? content : fileContent.value
const targetPath = (path !== undefined && typeof path === 'string') ? path : filePath.value
if (!targetPath) {
Message.error('请输入文件路径')
return false
}
fileLoading.value = true
try {
await writeFileApi(targetPath, targetContent)
if (content !== undefined) {
fileContent.value = targetContent
}
if (path) {
filePath.value = path
}
onSuccess('writeFile', { path: targetPath })
// 差异化反馈:快捷键不显示提示,按钮点击显示轻提示
if (!isShortcut) {
// 按钮点击保存:显示轻量 Toast 提示
if (fileName && typeof fileName === 'string') {
Message.success({
content: `✓ ${fileName} 已保存`,
duration: 1500, // 1.5秒后自动消失
position: 'bottom' // 底部显示,不打断操作
})
} else {
Message.success({
content: '文件已保存',
duration: 1500,
position: 'bottom'
})
}
}
// 快捷键保存:无提示(静默成功)
return true
} catch (error) {
onError('writeFile', error)
// 保存失败:总是显示醒目错误提示(需手动关闭)
Message.error({
content: `文件保存失败: ${error.message || error}`,
duration: 5000, // 错误提示显示更久
closable: true // 允许手动关闭
})
return false
} finally {
fileLoading.value = false
}
}
/**
* 删除文件或目录
* 🔒 安全修复:移除 confirm 参数,始终需要用户确认,防止绕过
* 🔒 安全修复:添加并发删除检查,防止批量删除攻击
* @param {string} [path] - 文件路径,不传则使用当前路径
* @returns {Promise<boolean>} 用户是否确认及操作是否成功
*/
const deleteFile = async (path) => {
const targetPath = path || filePath.value
if (!targetPath) {
Message.error('请输入文件路径')
return false
}
// 🔒 安全修复:添加调用频率限制(防止批量删除攻击)
if (isDeleting.value) {
Message.warning('正在删除中,请稍候...')
return false
}
const executeDelete = async () => {
isDeleting.value = true
fileLoading.value = true
try {
await deletePathApi(targetPath)
// 清空状态
filePath.value = ''
fileContent.value = ''
fileList.value = []
onSuccess('deleteFile', { path: targetPath })
Message.success('删除成功')
return true
} catch (error) {
onError('deleteFile', error)
Message.error(`删除失败: ${error.message || error}`)
return false
} finally {
fileLoading.value = false
isDeleting.value = false
}
}
// 🔒 安全修复:始终显示确认对话框,无法绕过
return new Promise((resolve) => {
Modal.confirm({
title: '⚠️ 确认删除',
content: `确定要删除 ${targetPath} 吗?此操作不可恢复!`,
okText: '确定删除',
cancelText: '取消',
okButtonProps: { status: 'danger' }, // 红色按钮提醒危险
onOk: async () => {
const result = await executeDelete()
resolve(result)
},
onCancel: () => {
resolve(false)
},
})
})
}
/**
* 选择文件(智能判断是文件还是目录)
* @param {string} path - 文件/目录路径
* @param {Array} fileListData - 文件列表数据
* @returns {Promise<boolean>} 是否成功
*/
const selectFile = async (path, fileListData) => {
if (!path) return false
filePath.value = path
// 从文件列表中查找该项
const item = fileListData.find(f => f.path === path)
if (!item) {
// 如果列表中找不到,尝试根据路径判断
// 简单判断:路径以 / 或 \ 结尾可能是目录
const isDir = path.endsWith('/') || path.endsWith('\\')
if (isDir) {
return await listDirectory(path)
} else {
return await readFile(path)
}
}
if (item.is_dir) {
// 是目录,列出内容
return await listDirectory(path)
} else {
// 是文件,读取内容
return await readFile(path)
}
}
/**
* 清空所有状态
*/
const clearAll = () => {
filePath.value = ''
fileContent.value = ''
fileList.value = []
}
// ========== 持久化 ==========
/**
* 监听路径变化,自动保存到 localStorage
* 用于下次启动时恢复上次访问的路径
*/
watch(filePath, (newPath) => {
try {
if (newPath) {
localStorage.setItem(STORAGE_KEY_LAST_PATH, newPath)
} else {
localStorage.removeItem(STORAGE_KEY_LAST_PATH)
}
} catch (e) {
console.warn('[useFileOperations] 保存路径失败:', e)
}
})
// ========== 返回公共API ==========
return {
// 状态
filePath,
fileContent,
fileList,
fileLoading,
// 方法
listDirectory,
readFile,
writeFile,
deleteFile,
selectFile,
clearAll,
}
}
/**
* @typedef {Object} UseFileOperationsReturn
* @property {Ref<string>} filePath - 当前文件路径
* @property {Ref<string>} fileContent - 文件内容
* @property {Ref<Array>} fileList - 文件列表
* @property {Ref<boolean>} fileLoading - 加载状态
* @property {Function} listDirectory - 列出目录
* @property {Function} readFile - 读取文件
* @property {Function} writeFile - 写入文件
* @property {Function} deleteFile - 删除文件
* @property {Function} selectFile - 智能选择文件
* @property {Function} clearAll - 清空所有状态
*/
-273
View File
@@ -1,273 +0,0 @@
/**
* 导航和路径管理 composable
*
* @module composables/useNavigation
* @description 封装文件系统的导航历史、路径操作等逻辑
*/
import { ref, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 路径历史 localStorage 键
*/
const STORAGE_KEY_PATH_HISTORY = 'app-filesystem-path-history'
/**
* 导航管理 composable
* @param {Object} options - 配置选项
* @param {Ref<string>} options.filePath - 当前路径 ref
* @param {Function} options.onListDirectory - 列出目录的函数
* @param {Function} options.onExitZipMode - 退出 ZIP 模式的函数
* @returns {UseNavigationReturn} 导航操作 API
*/
export function useNavigation(options = {}) {
const {
filePath,
onListDirectory,
onExitZipMode,
} = options
// ========== 导航历史记录(支持后退/前进) ==========
/**
* 导航历史栈
* @type {Ref<Array<string>>}
*/
const navHistory = ref([])
/**
* 当前在历史栈中的位置
* @type {Ref<number>}
*/
const navIndex = ref(-1)
/**
* 是否正在导航(防止重复记录)
* @type {Ref<boolean>}
*/
const isNavigating = ref(false)
/**
* 路径历史记录(用于下拉列表)
* @type {Ref<Array<string>>}
*/
const pathHistory = ref([])
// ========== 计算属性 ==========
/**
* 是否可以后退
*/
const canGoBack = computed(() => navIndex.value > 0)
/**
* 是否可以前进
*/
const canGoForward = computed(() => navIndex.value < navHistory.value.length - 1)
// ========== 导航操作 ==========
/**
* 添加到路径历史记录
* @param {string} path - 路径
*/
const addToHistory = (path) => {
if (!path || path === filePath.value) return
// 去重:如果路径已在历史中,先删除
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
// 添加到开头
pathHistory.value.unshift(path)
// 限制历史记录数量
if (pathHistory.value.length > 50) {
pathHistory.value = pathHistory.value.slice(0, 50)
}
// 持久化
try {
localStorage.setItem(STORAGE_KEY_PATH_HISTORY, JSON.stringify(pathHistory.value))
} catch (e) {
// 忽略 localStorage 错误
}
}
/**
* 推送到导航历史栈
* @param {string} path - 路径
*/
const pushNav = (path) => {
if (isNavigating.value) {
return
}
// 如果当前位置不在历史末尾,删除后续历史
if (navIndex.value < navHistory.value.length - 1) {
navHistory.value = navHistory.value.slice(0, navIndex.value + 1)
}
// 添加到历史
navHistory.value.push(path)
navIndex.value = navHistory.value.length - 1
// 同时添加到路径历史
addToHistory(path)
}
/**
* 后退
* @returns {Promise<boolean>} 是否成功
*/
const goBack = async () => {
if (!canGoBack.value) {
return false
}
isNavigating.value = true
try {
navIndex.value--
const path = navHistory.value[navIndex.value]
await onListDirectory(path)
return true
} catch (error) {
Message.error(`后退失败: ${error.message || error}`)
return false
} finally {
isNavigating.value = false
}
}
/**
* 前进
* @returns {Promise<boolean>} 是否成功
*/
const goForward = async () => {
if (!canGoForward.value) {
return false
}
isNavigating.value = true
try {
navIndex.value++
const path = navHistory.value[navIndex.value]
await onListDirectory(path)
return true
} catch (error) {
Message.error(`前进失败: ${error.message || error}`)
return false
} finally {
isNavigating.value = false
}
}
// ========== 路径操作 ==========
/**
* 路径选择(从下拉列表)
* @param {string} value - 选中的路径
*/
const onPathSelect = (value) => {
if (value && value !== filePath.value) {
goToPath(value)
}
}
/**
* 路径输入框回车事件
*/
const onPathEnter = () => {
const path = filePath.value?.trim()
if (path) {
goToPath(path)
}
}
/**
* 跳转到指定路径
* @param {string} path - 目标路径
* @returns {Promise<boolean>} 是否成功
*/
const goToPath = async (path) => {
if (!path) {
return false
}
// 退出 ZIP 模式
if (onExitZipMode) {
onExitZipMode()
}
return await onListDirectory(path)
}
/**
* 浏览目录(打开系统文件选择对话框)
*/
const browseDirectory = async () => {
Message.info('请手动输入目录路径')
}
// ========== 初始化 ==========
/**
* 加载路径历史记录
*/
const loadPathHistory = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY_PATH_HISTORY)
if (saved) {
pathHistory.value = JSON.parse(saved)
}
} catch (e) {
console.warn('[useNavigation] 加载路径历史失败:', e)
}
}
// 初始化
loadPathHistory()
return {
// 状态
navHistory,
navIndex,
isNavigating,
pathHistory,
canGoBack,
canGoForward,
// 方法
addToHistory,
pushNav,
goBack,
goForward,
onPathSelect,
onPathEnter,
goToPath,
browseDirectory,
loadPathHistory,
}
}
/**
* @typedef {Object} UseNavigationReturn
* @property {Ref<Array<string>>} navHistory - 导航历史栈
* @property {Ref<number>} navIndex - 当前在历史栈中的位置
* @property {Ref<boolean>} isNavigating - 是否正在导航
* @property {Ref<Array<string>>} pathHistory - 路径历史记录(下拉列表)
* @property {ComputedRef<boolean>} canGoBack - 是否可以后退
* @property {ComputedRef<boolean>} canGoForward - 是否可以前进
* @property {Function} addToHistory - 添加到路径历史记录
* @property {Function} pushNav - 推送到导航历史栈
* @property {Function} goBack - 后退
* @property {Function} goForward - 前进
* @property {Function} onPathSelect - 路径选择
* @property {Function} onPathEnter - 路径输入框回车事件
* @property {Function} goToPath - 跳转到指定路径
* @property {Function} browseDirectory - 浏览目录
* @property {Function} loadPathHistory - 加载路径历史记录
*/
+5
View File
@@ -4,6 +4,8 @@ import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { useThemeStore } from './stores/theme'
import { startFreezeWatchdog } from './utils/freezeWatchdog'
import '@/plugin/built-in' // 注册内置预览插件(drawio 等)
const app = createApp(App)
const pinia = createPinia()
@@ -14,5 +16,8 @@ app.use(pinia)
const themeStore = useThemeStore()
themeStore.initTheme()
// 启动主线程卡死看门狗(尽早注册 pong 回调;自愈绝不 reload,防 loop)
startFreezeWatchdog()
app.mount('#app')
@@ -0,0 +1,22 @@
// drawio 预览插件注册
// canHandle 严格匹配 .drawio / .dio 扩展名,不认 .xml(避免误吞配置文件,配置文件走代码编辑器)。
import type { Component } from 'vue'
import { registerPreviewHandler } from '../registry'
import type { FilePreviewHandlerDefinition } from '../types'
// 仅 .drawio / .dio;.drawio.png / .drawio.svg 本就走图片预览,不冲突
const DRAWIO_EXT_REGEX = /\.(drawio|dio)$/i
const drawioHandler: FilePreviewHandlerDefinition = {
id: 'drawio-preview',
name: 'Draw.io 查看器',
icon: '📊',
priority: 90,
canHandle: (filename: string): boolean => DRAWIO_EXT_REGEX.test(filename || ''),
getComponent: (): Promise<Component> =>
import('../../components/FileSystem/components/DrawioPreview.vue').then(m => m.default),
supportsEdit: false,
}
registerPreviewHandler(drawioHandler)
+4
View File
@@ -0,0 +1,4 @@
// 内置预览插件聚合:import 此文件即触发注册(副作用模块)
// main.js 顶部 import '@/plugin/built-in' 完成全部内置插件注册。
import './drawio-handler'
+59
View File
@@ -0,0 +1,59 @@
// 预览插件注册中心
// 模块级单例:registerPreviewHandler 注册,resolvePreviewHandler 按优先级解析。
// 现有 9 类预览不进此注册表(仍走 FileEditorPanel 的 v-if/v-else-if 链);
// 仅当 resolvePreviewHandler 返回非 null 时,模板的 <component> 分支命中,优先于老链。
import { defineAsyncComponent, type Component } from 'vue'
import type { FilePreviewHandlerDefinition } from './types'
// 按 priority 降序维护的注册表
const previewHandlers: FilePreviewHandlerDefinition[] = []
/**
* 注册一个文件预览处理器
* 重复 id 会覆盖旧注册(便于热替换/测试),注册后保持 priority 降序。
*/
export function registerPreviewHandler(handler: FilePreviewHandlerDefinition): void {
const idx = previewHandlers.findIndex(h => h.id === handler.id)
if (idx >= 0) {
previewHandlers[idx] = handler
} else {
previewHandlers.push(handler)
}
previewHandlers.sort((a, b) => b.priority - a.priority)
}
/**
* 解析文件名对应的预览处理器
* 按 priority 降序遍历,返回第一个 canHandle=true 的 handler,无匹配返回 null。
*/
export function resolvePreviewHandler(filename: string): FilePreviewHandlerDefinition | null {
if (!filename) return null
for (const handler of previewHandlers) {
try {
if (handler.canHandle(filename)) return handler
} catch (e) {
console.warn(`[PreviewRegistry] handler ${handler.id} canHandle 异常:`, e)
}
}
return null
}
// 组件缓存:同一插件的异步组件只创建一次,避免重复 defineAsyncComponent
const componentCache = new Map<string, Component>()
/**
* 解析文件名对应的预览组件(Vue 异步组件,内部缓存)。
* 供 <component :is> 直接使用;无匹配返回 null。
* 与 resolvePreviewHandler 分离:handler 用于标题(icon/name),component 用于渲染。
*/
export function resolvePreviewComponent(filename: string): Component | null {
const handler = resolvePreviewHandler(filename)
if (!handler?.getComponent) return null
let comp = componentCache.get(handler.id)
if (!comp) {
comp = defineAsyncComponent(handler.getComponent)
componentCache.set(handler.id, comp)
}
return comp
}
+22
View File
@@ -0,0 +1,22 @@
// 预览插件类型定义
// 对齐 docs/02-架构设计/插件化架构方案.md 3.2 节 FilePreviewHandlerDefinition,day-1 简化到 drawio 所需字段。
import type { Component } from 'vue'
/** 文件预览处理器定义 */
export interface FilePreviewHandlerDefinition {
/** 插件唯一标识,如 'drawio-preview' */
id: string
/** 展示名,用于面板标题 */
name: string
/** 图标(emoji 或图标名) */
icon: string
/** 优先级,越大越优先(resolvePreviewHandler 按 priority 降序遍历) */
priority: number
/** 判断是否能处理该文件名(严格扩展名匹配,避免吞其他类型) */
canHandle: (filename: string) => boolean
/** 异步加载预览组件(配合 defineAsyncComponent 懒加载,返回 Promise<Component>) */
getComponent?: () => Promise<Component>
/** 是否支持编辑(day-1 drawio 只读) */
supportsEdit?: boolean
}
+1 -1
View File
@@ -97,7 +97,7 @@ export const useConfigStore = defineStore('config', () => {
appConfig.value = {
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
visibleTabs: mergedVisible,
defaultTab: defaultTab || 'file-system',
defaultTab: allKeys.includes(defaultTab) ? defaultTab : 'file-system',
sidebarSections: Array.isArray(sidebarSections)
? sidebarSections.filter((s: string) => validSections.includes(s))
: defaultSidebar
+77
View File
@@ -0,0 +1,77 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { On, Off } from '@wailsio/events'
import { parseWailsEventData } from '@/utils/wailsEvent'
/** 传输条目(与后端 transfer-progress 事件 payload 对应) */
export interface TransferItem {
id: string
name: string
copied: number
total: number
status: 'running' | 'done' | 'error'
error?: string
}
/** 完成条目自动移除延迟 */
const DONE_REMOVE_DELAY = 2000
/**
* 传输进度 Store — 监听后端 transfer-progress 事件
* 下载/上传/本地复制共用,done 后 2s 自动移除,error 手动关闭
*/
export const useTransferStore = defineStore('transfer', () => {
const items = ref<TransferItem[]>([])
const handleProgress = (event: unknown) => {
const data = parseWailsEventData(event)
const id = String(data.id || '')
if (!id) return
const status = (data.status as TransferItem['status']) || 'running'
const index = items.value.findIndex(i => i.id === id)
if (index === -1) {
items.value.push({
id,
name: String(data.name || ''),
copied: Number(data.copied) || 0,
total: Number(data.total) || 0,
status,
error: data.error ? String(data.error) : undefined
})
} else {
const item = items.value[index]
// 结束事件只带状态,保留上一帧的名称/进度
if (data.name) item.name = String(data.name)
if (data.copied !== undefined) item.copied = Number(data.copied) || item.copied
if (data.total !== undefined) item.total = Number(data.total) || item.total
item.status = status
if (data.error) item.error = String(data.error)
}
if (status === 'done') {
const item = items.value.find(i => i.id === id)
if (item) {
item.copied = item.total
setTimeout(() => dismiss(id), DONE_REMOVE_DELAY)
}
}
}
/** 手动移除条目 */
const dismiss = (id: string) => {
items.value = items.value.filter(i => i.id !== id)
}
/** 设置事件监听 */
const setupEventListeners = () => {
On('transfer-progress', handleProgress)
}
/** 移除事件监听 */
const removeEventListeners = () => {
Off('transfer-progress')
}
return { items, setupEventListeners, removeEventListeners, dismiss }
})
+76 -26
View File
@@ -2,11 +2,17 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
import { parseWailsEventData } from '@/utils/wailsEvent'
import {
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate, InstallUpdateWithHash
} from '../wailsjs/v3-bindings/u-desk/app'
import { On, Off } from '@wailsio/events'
// 本次应用运行内已自动弹过的版本(升级后自动恢复提醒)
let sessionNotifiedVersion = ''
// 下载完成事件携带的远端声明哈希,安装时用于完整性校验
let lastExpectedSha256 = ''
/**
* 更新管理 Store
* 统一管理版本检查、下载、安装等更新相关逻辑
@@ -35,47 +41,70 @@ export const useUpdateStore = defineStore('update', () => {
const MIN_DISPLAY_TIME = 5000 // 5 秒最小显示时间
// ==================== 工具函数 ====================
const parseEventData = (event: unknown) => {
try {
return typeof event === 'string' ? JSON.parse(event) : (event as Record<string, unknown>)
} catch {
return {}
}
}
const formatSpeed = (bytesPerSecond: number): string => {
return formatFileSize(bytesPerSecond) + '/s'
}
// ==================== 核心方法 ====================
// 用户显式跳过的版本(localStorage):该版本不再自动弹窗,手动检查不受限
const SKIPPED_VERSION_KEY = 'udesk-update-skipped-version'
/**
* 展示更新弹窗。自动路径(静默检查/后端广播)按版本去重(用户已跳过或本次运行已弹过则不弹),手动检查强制弹
*/
const tryShowUpdate = (data: UpdateInfo, force = false) => {
const version = String(data?.latest_version || '')
if (!force && version) {
if (localStorage.getItem(SKIPPED_VERSION_KEY) === version) return
if (sessionNotifiedVersion === version) return
}
updateInfo.value = data
showUpdate.value = true
if (version) {
sessionNotifiedVersion = version
}
if (!force) {
// 自动发现时的系统通知
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `发现新版本 ${version},点击查看`
})
} catch { /* 通知不可用时忽略 */ }
}
}
/**
* 稍后提醒:记录用户显式跳过的版本,该版本不再自动弹窗(手动检查仍强制弹)
*/
const skipVersion = (version: string) => {
if (version) {
localStorage.setItem(SKIPPED_VERSION_KEY, version)
}
showUpdate.value = false
}
/**
* 检查更新
* @param silent 是否静默模式(不显示消息)
* @param manual 是否手动触发(跳过 auto_check_enabled 门禁)
* @returns 检查是否实际完成(false=后端未就绪或网络失败,调用方可重试)
*/
const checkForUpdates = async (silent = false) => {
if (checking.value) return
const checkForUpdates = async (silent = false, manual = false): Promise<boolean> => {
if (checking.value) return true
checking.value = true
try {
const configResult = await GetUpdateConfig()
if (!configResult.success) return
if (!configResult.success) return false
const { auto_check_enabled } = configResult.data || {}
if (!auto_check_enabled) return
if (!manual && !auto_check_enabled) return true
const result = await CheckUpdate()
if (result.success && result.data?.has_update) {
updateInfo.value = result.data
showUpdate.value = true
// 系统通知:发现新版本
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `发现新版本 ${result.data.latest_version},点击查看`
})
} catch { /* 通知不可用时忽略 */ }
tryShowUpdate(result.data, !silent)
if (!silent) {
Message.success('发现新版本!')
@@ -83,11 +112,13 @@ export const useUpdateStore = defineStore('update', () => {
} else if (!silent) {
Message.success('已是最新版本')
}
return result.success === true
} catch (error) {
if (!silent) {
console.error('检查更新失败:', error)
Message.error('检查更新失败:' + (error as Error).message)
}
return false
} finally {
checking.value = false
}
@@ -141,7 +172,10 @@ export const useUpdateStore = defineStore('update', () => {
installing.value = true
try {
const result = await InstallUpdate(filePath, true)
// 优先带哈希校验安装(期望哈希来自下载完成事件的远端声明)
const result = lastExpectedSha256
? await InstallUpdateWithHash(filePath, true, lastExpectedSha256, 'sha256')
: await InstallUpdate(filePath, true)
if (result.success) {
Message.success({
content: '安装成功!应用将在几秒后重启...',
@@ -167,7 +201,7 @@ export const useUpdateStore = defineStore('update', () => {
}
lastUpdateTime = now
const data = parseEventData(event)
const data = parseWailsEventData(event)
progressInfo.value = {
speed: (data.speed as number) || 0,
@@ -184,7 +218,7 @@ export const useUpdateStore = defineStore('update', () => {
* 下载完成处理
*/
const onDownloadComplete = (event: unknown) => {
const data = parseEventData(event)
const data = parseWailsEventData(event)
// 错误处理
if (data.error) {
@@ -207,6 +241,8 @@ export const useUpdateStore = defineStore('update', () => {
// 完成下载
downloadProgress.value = 100
downloadStatus.value = 'success'
// 记录远端声明的期望哈希,安装时用于校验
lastExpectedSha256 = String(data.expected_sha256 || '')
const fileSize = (data.file_size as number) || 0
@@ -234,12 +270,24 @@ export const useUpdateStore = defineStore('update', () => {
}, remainingTime)
}
/**
* 后端广播"发现新版本"处理(启动即查与周期检查命中时后端都会发)
*/
const onUpdateAvailable = (event: unknown) => {
const data = parseWailsEventData(event) as UpdateInfo
if (!data?.latest_version) {
return
}
tryShowUpdate(data)
}
/**
* 设置事件监听
*/
const setupEventListeners = () => {
On('download-progress', onDownloadProgress)
On('download-complete', onDownloadComplete)
On('update-available', onUpdateAvailable)
}
/**
@@ -248,6 +296,7 @@ export const useUpdateStore = defineStore('update', () => {
const removeEventListeners = () => {
Off('download-progress')
Off('download-complete')
Off('update-available')
}
// ==================== 返回 ====================
@@ -266,6 +315,7 @@ export const useUpdateStore = defineStore('update', () => {
checkForUpdates,
downloadUpdate,
installUpdate,
skipVersion,
setupEventListeners,
removeEventListeners,
formatFileSize,
+124
View File
@@ -429,3 +429,127 @@ body.resizing {
.arco-tooltip-content-white::before {
background: var(--color-bg-1) !important;
}
/* ========== 暗色模式覆盖 ========== */
/* --- hljs 代码高亮暗色 --- */
body[arco-theme*='dark'] .hljs {
background: #1e1e2e;
color: #cdd6f4;
}
body[arco-theme*='dark'] .hljs-comment,
body[arco-theme*='dark'] .hljs-quote {
color: #6c7086;
}
body[arco-theme*='dark'] .hljs-keyword,
body[arco-theme*='dark'] .hljs-selector-tag,
body[arco-theme*='dark'] .hljs-addition {
color: #cba6f7;
}
body[arco-theme*='dark'] .hljs-number,
body[arco-theme*='dark'] .hljs-string,
body[arco-theme*='dark'] .hljs-meta .hljs-string,
body[arco-theme*='dark'] .hljs-literal,
body[arco-theme*='dark'] .hljs-doctag,
body[arco-theme*='dark'] .hljs-regexp {
color: #a6e3a1;
}
body[arco-theme*='dark'] .hljs-title,
body[arco-theme*='dark'] .hljs-section,
body[arco-theme*='dark'] .hljs-name,
body[arco-theme*='dark'] .hljs-selector-id,
body[arco-theme*='dark'] .hljs-selector-class {
color: #89b4fa;
}
body[arco-theme*='dark'] .hljs-attribute,
body[arco-theme*='dark'] .hljs-attr,
body[arco-theme*='dark'] .hljs-variable,
body[arco-theme*='dark'] .hljs-template-variable,
body[arco-theme*='dark'] .hljs-class .hljs-title,
body[arco-theme*='dark'] .hljs-type {
color: #fab387;
}
body[arco-theme*='dark'] .hljs-symbol,
body[arco-theme*='dark'] .hljs-bullet,
body[arco-theme*='dark'] .hljs-subst,
body[arco-theme*='dark'] .hljs-meta,
body[arco-theme*='dark'] .hljs-meta .hljs-keyword,
body[arco-theme*='dark'] .hljs-selector-attr,
body[arco-theme*='dark'] .hljs-selector-pseudo,
body[arco-theme*='dark'] .hljs-link {
color: #f9e2af;
}
body[arco-theme*='dark'] .hljs-built_in,
body[arco-theme*='dark'] .hljs-deletion {
color: #f38ba8;
}
body[arco-theme*='dark'] .hljs-formula {
background: #313244;
}
body[arco-theme*='dark'] .hljs-addition {
background-color: rgba(166, 227, 161, 0.15);
}
body[arco-theme*='dark'] .hljs-deletion {
background-color: rgba(243, 139, 168, 0.15);
}
/* --- Markdown 预览暗色 --- */
body[arco-theme*='dark'] .markdown-body {
color: #cdd6f4;
}
body[arco-theme*='dark'] .markdown-body h1 {
border-bottom-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body h2 {
border-bottom-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body h6 {
color: #a6adc8;
}
body[arco-theme*='dark'] .markdown-body blockquote {
color: #a6adc8;
border-left-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body code {
background-color: rgba(205, 214, 244, 0.1);
}
body[arco-theme*='dark'] .markdown-body pre {
background-color: #1e1e2e;
}
body[arco-theme*='dark'] .markdown-body table th,
body[arco-theme*='dark'] .markdown-body table td {
border-color: #45475a;
}
body[arco-theme*='dark'] .markdown-body table th {
background-color: #313244;
}
body[arco-theme*='dark'] .markdown-body table tr:nth-child(even) {
background-color: #1e1e2e;
}
body[arco-theme*='dark'] .markdown-body img {
background-color: transparent;
}
body[arco-theme*='dark'] .markdown-body hr {
background-color: #45475a;
}
+12
View File
@@ -33,6 +33,8 @@ export interface FavoriteFile extends FileItem {
pinnedAt?: number
/** 关联的连接配置 ID,用于打开收藏时自动切换连接 */
profileId?: string
/** 展示名,空串或缺省表示未设置(显示时回退原名) */
displayName?: string
}
/**
@@ -147,6 +149,8 @@ export interface FileListPanelConfig {
editingFilePath: string
/** 编辑中的文件名 */
editingFileName: string
/** 被剪切的文件路径(行样式半透明) */
cutPaths?: string[]
}
/**
@@ -209,6 +213,10 @@ export interface FileEditorPanelConfig {
previewTabs?: any[]
/** 当前激活的 Tab ID */
activeTabId?: string | null
/** 预览插件处理器(用于面板标题;null 则走内置 v-if 链) */
activePlugin?: import('@/plugin/types').FilePreviewHandlerDefinition | null
/** 预览插件组件(用于渲染;null 则走内置 v-if 链) */
activePluginComponent?: import('vue').Component | null
}
/**
@@ -230,6 +238,10 @@ export interface ContextMenuConfig {
context: ContextMenuContext
/** 选中的文件(file-list 上下文) */
selectedFile?: FileItem
/** 是否 SFTP 面板(file 上下文显示「下载到本机」) */
isSftp?: boolean
/** 文件剪贴板是否为空(blank 上下文「粘贴」项禁用) */
clipboardEmpty?: boolean
}
/**
+30
View File
@@ -89,6 +89,36 @@ export async function loadLanguageExtension(language) {
extension = StreamLanguage.define(powerShell)
break
}
case 'batch': {
// 自写 DOS batch 流式模式:REM/:: 整行注释,%var% 变量,常用关键字
const { StreamLanguage } = await import('@codemirror/language')
const batchKeywords = new Set('echo off on set setlocal endlocal enabledelayedexpansion if not exist defined equ neq lss leq gtr geq for in do goto call exit exitb start del erase copy move ren rename md mkdir rd rmdir cd chdir type cls pause shift errorlevel title ver vol prompt pushd popd xcopy robocopy powershell cmd'.split(' '))
extension = StreamLanguage.define({
name: 'batch',
token(stream) {
if (stream.sol()) {
stream.eatSpace()
// 行首 REM(大小写不敏感)或 :: 开头的整行注释;:标签名 视作 keyword
if (stream.match(/^rem(?:\s|$)/i) || stream.match(/^::/)) {
stream.skipToEnd()
return 'comment'
}
if (stream.match(/^:[\w.-]+/)) return 'keyword'
}
if (stream.eatSpace()) return null
if (stream.match(/^"(?:[^"\r\n]|"")*"?/)) return 'string'
if (stream.eat('^')) return 'operator'
// !var! 延迟变量、%var% 环境变量、%~d0 类批处理参数、%1 位置参数、%%i for 循环变量
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i) || stream.match(/^%\d/) || stream.match(/^%%\w+/)) return 'atom'
if (stream.match(/^\w+/)) {
return batchKeywords.has(stream.current().toLowerCase()) ? 'keyword' : null
}
stream.next()
return null
}
})
break
}
case 'dart': {
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
extension = StreamLanguage.define(dart)
+7 -2
View File
@@ -23,6 +23,7 @@ export const STORAGE_KEYS = {
PATH_HISTORY: 'app-filesystem-path-history',
FILE_CONTENT_HEIGHT: 'app-filesystem-file-content-height',
PANEL_WIDTH: 'app-filesystem-panel-width',
SIDEBAR_WIDTH: 'app-filesystem-sidebar-width',
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
FAVORITE_FILES: 'app-filesystem-favorite-files',
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
@@ -76,7 +77,7 @@ export const FILE_EXTENSIONS = {
CODE: [
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'cmd', 'ps1',
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
'dockerfile', 'm', 'r', 'matlab'
],
@@ -334,8 +335,12 @@ export const DEFAULTS = {
MAX_PANEL_WIDTH: 80,
DEFAULT_PANEL_WIDTH: 50,
// 侧边栏宽度(px)
// 侧边栏默认宽度(px)
SIDEBAR_WIDTH: 220,
// 侧边栏宽度范围(px)
MIN_SIDEBAR_WIDTH: 180,
MAX_SIDEBAR_WIDTH: 420,
}
/**
+7
View File
@@ -0,0 +1,7 @@
/**
* 任意抛出值转错误文案(Error 取 message,其余 String 化)
*/
export function errMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Excel 解析 Worker
* 把 XLSX.read() 与 sheet_to_json() 这两个【同步阻塞】操作隔离到独立线程,
* 避免大表解析冻结 webview 主线程(导致整个窗口点击无响应)。
*
* 协议:
* 主线程 -> { type: 'parse', reqId, wid, buffer: ArrayBuffer } (buffer 会被 transfer)
* 主线程 -> { type: 'sheet', reqId, wid, idx }
* worker -> { type: 'ready', reqId, wid, sheetNames: string[] }
* worker -> { type: 'sheet', reqId, wid, idx, data: any[][] }
* worker -> { type: 'stale', reqId } // wid 不匹配当前已加载工作簿
* worker -> { type: 'error', reqId, message }
*
* wid(workbook id)由主线程递增分配;切到新文件时 wid 变化,
* 旧 wid 的 sheet 请求会被识别为 stale,防止乱序回包覆盖新内容。
*/
let XLSX = null
let workbook = null
let loadedWid = 0
async function ensureXlsx() {
if (!XLSX) XLSX = await import('xlsx')
return XLSX
}
self.onmessage = async (e) => {
const msg = e.data || {}
const { type, reqId, wid, idx, buffer } = msg
try {
if (type === 'parse') {
const X = await ensureXlsx()
workbook = X.read(buffer, { type: 'array' })
loadedWid = wid
self.postMessage({ type: 'ready', reqId, wid, sheetNames: workbook.SheetNames || [] })
return
}
if (type === 'sheet') {
// wid 不匹配(已切到别的文件)→ 丢弃,避免旧回包覆盖
if (!workbook || wid !== loadedWid) {
self.postMessage({ type: 'stale', reqId })
return
}
const X = await ensureXlsx()
const ws = workbook.Sheets[workbook.SheetNames[idx]]
const data = X.utils.sheet_to_json(ws, { header: 1, defval: '' })
self.postMessage({ type: 'sheet', reqId, wid, idx, data })
return
}
} catch (err) {
self.postMessage({ type: 'error', reqId, message: err && err.message ? err.message : String(err) })
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Excel 解析 Worker 单例客户端
*
* 职责:
* 1. 懒加载并复用单个 excel.worker.js;
* 2. 用 wid(generation)机制忽略过期解析结果——快速切换文件时,旧文件的
* 解析回包会被 isCurrent(wid) 判定为 stale 而丢弃;
* 3. Worker 不可用时退化为「主线程同步解析」,保证功能不丢(极少数老 webview)。
*
* 根治点:主线程不再出现 XLSX.read() 同步调用,从源头消除卡死。
*/
let worker = null
let useFallback = false
const pending = new Map() // reqId -> { resolve, reject }
let nextReqId = 1
let currentWid = 0 // 每次 parseWorkbook 自增;切文件 / 自愈时也会变
// ---- fallback(无 Worker 环境的同步退化实现)----
let fbXlsx = null
let fbWorkbook = null
let fbWid = 0
function getWorker() {
if (worker) return worker
if (useFallback) return null
try {
worker = new Worker(new URL('./excel.worker.js', import.meta.url), { type: 'module' })
worker.onmessage = (e) => {
const msg = e.data || {}
const p = pending.get(msg.reqId)
if (!p) return
pending.delete(msg.reqId)
if (msg.type === 'error') p.reject(new Error(msg.message || 'excel worker error'))
else p.resolve(msg)
}
worker.onerror = (e) => {
// worker 崩溃:reject 所有在途请求,并退化为同步
for (const p of pending.values()) p.reject(new Error(e.message || 'excel worker crash'))
pending.clear()
useFallback = true
worker = null
}
return worker
} catch (e) {
console.warn('[excelWorkerClient] Worker 创建失败,退化为同步解析:', e)
useFallback = true
return null
}
}
function send(type, payload, transfer = []) {
const w = getWorker()
if (!w) return Promise.reject(new Error('worker unavailable'))
const reqId = nextReqId++
return new Promise((resolve, reject) => {
pending.set(reqId, { resolve, reject })
w.postMessage({ type, reqId, ...payload }, transfer)
})
}
async function ensureFallbackXlsx() {
if (!fbXlsx) fbXlsx = await import('xlsx')
return fbXlsx
}
async function fallbackParse(buffer, wid) {
const X = await ensureFallbackXlsx()
fbWorkbook = X.read(buffer, { type: 'array' })
fbWid = wid
return { wid, sheetNames: fbWorkbook.SheetNames || [] }
}
/**
* 解析整个工作簿(在 worker 线程)。buffer 会被 transfer,主线程不再持有。
* @returns {Promise<{ wid: number, sheetNames: string[] }>}
*/
export async function parseWorkbook(buffer) {
const wid = ++currentWid
if (useFallback || !getWorker()) {
return fallbackParse(buffer, wid)
}
try {
const res = await send('parse', { wid, buffer }, [buffer])
return { wid, sheetNames: res.sheetNames || [] }
} catch (e) {
// worker 通道异常 → 本次及后续退化为同步
console.warn('[excelWorkerClient] parse 走 worker 失败,退化为同步:', e)
useFallback = true
return await fallbackParse(buffer, wid)
}
}
/**
* 取某个 sheet 的二维数据(在 worker 线程)。
* @returns {Promise<any[][]>}
*/
export async function getSheet(wid, idx) {
if (wid !== currentWid) throw new Error('stale')
if (useFallback) {
if (wid !== fbWid || !fbWorkbook) throw new Error('stale')
const X = await ensureFallbackXlsx()
const ws = fbWorkbook.Sheets[fbWorkbook.SheetNames[idx]]
return X.utils.sheet_to_json(ws, { header: 1, defval: '' })
}
const res = await send('sheet', { wid, idx })
if (res.type === 'stale') throw new Error('stale')
return res.data
}
/** 指定 wid 是否仍是当前(最新)解析任务,用于丢弃过期回包 */
export function isCurrent(wid) {
return wid === currentWid
}
/**
* 让所有在途解析结果立即作废(wid 推进)。
* 卡死自愈时调用,确保旧解析不会在恢复后覆盖界面。
*/
export function invalidateExcelPreview() {
currentWid++
}
+54 -33
View File
@@ -1,30 +1,36 @@
/**
* Office 文件预览处理器
*
* Excel 解析已迁移到 Web Worker(见 excel.worker.js + excelWorkerClient.js),
* 主线程不再出现 XLSX.read() 同步调用,避免大表解析冻结整个界面。
*/
import { escapeHtml } from './fileUtils'
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
import { parseWorkbook, getSheet, isCurrent } from './excelWorkerClient'
// 每批加载行数
const BATCH_ROWS = 200
const LOAD_MORE_THRESHOLD = 100
// Excel 预览处理器
// Excel 预览处理器(解析在 Web Worker 中进行,不阻塞主线程)
export async function previewExcel(file, container) {
const XLSX = await import('xlsx')
// 1. 取二进制并交给 worker 解析(XLSX.read / sheet_to_json 全在 worker 线程)
const arrayBuffer = await file.arrayBuffer()
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
const { wid, sheetNames } = await parseWorkbook(arrayBuffer)
// 切到别的文件时 wid 会推进,丢弃本次结果
if (!isCurrent(wid)) return { success: true }
// 渲染标签页
const tabs = workbook.SheetNames.map((name, i) =>
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
// 2. 渲染骨架(标签页 + 信息栏 + 内容区)
const tabs = sheetNames.map((name, i) =>
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${escapeHtml(name)}</button>`
).join('')
container.innerHTML = `
<div class="excel-preview">
<div class="excel-tabs">${tabs}</div>
<div class="excel-info"></div>
<div class="excel-content"></div>
<div class="excel-content"><div class="loading-hint">解析中...</div></div>
</div>
<style>
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
@@ -41,6 +47,7 @@ export async function previewExcel(file, container) {
.excel-content th.row-num{z-index:3;top:0;left:0}
.excel-content tr:hover td{background:var(--color-fill-1)}
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
.loading-hint{padding:24px;text-align:center;color:var(--color-text-3);font-size:13px}
</style>
`
@@ -49,19 +56,15 @@ export async function previewExcel(file, container) {
const tabsEl = container.querySelector('.excel-tabs')
// 当前 sheet 状态
let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
// 获取 sheet 数据
const getSheetData = (idx) => {
const ws = workbook.Sheets[workbook.SheetNames[idx]]
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
}
let currentSheet = { idx: -1, data: null, renderedRows: 0, totalRows: 0 }
// 渲染令牌:切 sheet 自增,丢弃乱序回包
let renderToken = 0
// 渲染表格(带行号)
const renderTable = (data, startRow = 0) => {
let html = '<table><thead><tr><th class="row-num">#</th>'
if (data[0]) {
data[0].forEach((cell, i) => {
data[0].forEach((cell) => {
html += `<th>${escapeHtml(cell)}</th>`
})
}
@@ -80,7 +83,7 @@ export async function previewExcel(file, container) {
return html
}
// 追加行
// 追加行(从内存中的 data 追加,不再过 worker)
const appendRows = (data, fromRow, toRow) => {
const tbody = contentEl.querySelector('tbody')
if (!tbody) return
@@ -107,17 +110,35 @@ export async function previewExcel(file, container) {
}
}
// 切换 sheet
const loadSheet = (idx) => {
currentSheet = {
idx,
data: getSheetData(idx),
renderedRows: BATCH_ROWS,
totalRows: 0
}
currentSheet.totalRows = currentSheet.data.length
// 切换 sheet(worker 异步解析单个 sheet)
const loadSheet = async (idx) => {
const token = ++renderToken
const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1)
// 标签高亮
tabsEl.querySelectorAll('.excel-tab').forEach(t => {
t.classList.toggle('active', parseInt(t.dataset.idx, 10) === idx)
})
// 该 sheet 尚未解析过 → 显示解析中
if (currentSheet.idx !== idx) {
contentEl.innerHTML = '<div class="loading-hint">解析中...</div>'
}
let data
try {
data = await getSheet(wid, idx)
} catch (e) {
if (isCurrent(wid) && token === renderToken) {
contentEl.innerHTML = `<div class="preview-error"><p>❌ Excel 解析失败</p><p class="error-detail">${escapeHtml(e && e.message ? e.message : String(e))}</p></div>`
}
return
}
// 丢弃过期 / 乱序回包
if (!isCurrent(wid) || token !== renderToken) return
currentSheet = { idx, data, renderedRows: BATCH_ROWS, totalRows: data.length }
const displayData = data.slice(0, BATCH_ROWS + 1)
contentEl.innerHTML = renderTable(displayData)
updateInfo()
}
@@ -127,7 +148,7 @@ export async function previewExcel(file, container) {
const { scrollTop, scrollHeight, clientHeight } = contentEl
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
const { data, renderedRows, totalRows } = currentSheet
if (renderedRows < totalRows - 1) {
if (data && renderedRows < totalRows - 1) {
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
appendRows(data, renderedRows + 1, newEnd)
currentSheet.renderedRows = newEnd
@@ -136,17 +157,17 @@ export async function previewExcel(file, container) {
}
}
loadSheet(0)
// 标签页切换
tabsEl.onclick = (e) => {
const tab = e.target.closest('.excel-tab')
if (!tab) return
tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
loadSheet(parseInt(tab.dataset.idx, 10))
const idx = parseInt(tab.dataset.idx, 10)
if (idx === currentSheet.idx) return
loadSheet(idx)
}
await loadSheet(0)
return { success: true }
}
@@ -233,7 +254,7 @@ export async function previewCsv(file, container) {
container.innerHTML = `
<div class="csv-preview">
<div class="csv-info">📋 ${file.name}</div>
<div class="csv-info">📋 ${escapeHtml(file.name)}</div>
<div class="csv-content"></div>
</div>
<style>
+5 -3
View File
@@ -29,13 +29,15 @@ export const PREVIEWABLE_TYPES = [
*/
export const KNOWN_BINARY_TYPES = [
// 可执行文件
'exe', 'dll', 'so', 'bin',
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite',
// 压缩文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
// 编译/中间产物
'pdb', 'idb', 'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
// 安装包
'msi', 'jar', 'war', 'ear', 'apk',
// Office PowerPoint(暂不支持预览)
'ppt', 'pptx',
// 其他二进制
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
]
/**
+110
View File
@@ -0,0 +1,110 @@
/**
* 主线程卡死检测 + 事后自愈(主线程侧控制器)
*
* 能力边界(必须如实告知):
* - 主线程冻结「进行中」无法自救(回调被一起冻住);
* - 本模块只能「事后」检测到冻结(心跳断流),并在主线程恢复后做自愈;
* - 自愈动作绝不包含 location.reload(),否则会与「启动自动预览历史 Excel」
* 形成死循环(reload → 再次自动预览 → 再次冻结 → 再次 reload)。
*
* 自愈动作(每次冻结事件只执行一次,有 healing 防抖):
* 1. invalidateExcelPreview() —— 推进 wid,丢弃在途 Excel 解析回包;
* 2. 清掉持久化会话(LAST_OPENED_FILE + MULTI_PREVIEW_TABS),断开 reload-loop;
* 3. 置位 autorestore-disabled 标记,下次启动 restoreSession 主动跳过自动恢复;
* 4. toast 提示用户。
*/
import { STORAGE_KEYS } from './constants'
import { invalidateExcelPreview } from './excelWorkerClient'
const AUTORESTORE_DISABLED_KEY = 'app-filesystem-autorestore-disabled'
let started = false
let healing = false
export function isAutoRestoreDisabled(): boolean {
try {
return localStorage.getItem(AUTORESTORE_DISABLED_KEY) === '1'
} catch {
return false
}
}
export function clearAutoRestoreDisabled(): void {
try {
localStorage.removeItem(AUTORESTORE_DISABLED_KEY)
} catch {
/* ignore */
}
}
function setAutoRestoreDisabled(): void {
try {
localStorage.setItem(AUTORESTORE_DISABLED_KEY, '1')
} catch {
/* ignore */
}
}
function heal(freezeMs: number) {
if (healing) return // 同一次冻结可能触发多次 recovered,只处理一次
healing = true
console.error(`[freezeWatchdog] 检测到主线程冻结约 ${(freezeMs / 1000).toFixed(1)}s,执行自愈`)
// 1. 丢弃在途 Excel 解析
try {
invalidateExcelPreview()
} catch {
/* ignore */
}
// 2. 清持久化会话 —— 断开 reload-loop 的关键
try {
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.LAST_OPENED_FILE)
localStorage.removeItem(STORAGE_KEYS.FILESYSTEM.MULTI_PREVIEW_TABS)
} catch {
/* ignore */
}
// 3. 置位熔断标记:下次启动跳过自动恢复(防御纵深,即便上一步失败也不会 loop)
setAutoRestoreDisabled()
// 4. 提示用户(懒加载避免循环依赖)
import('@arco-design/web-vue')
.then(({ Message }) => {
Message.warning(`检测到界面卡顿 ${(freezeMs / 1000).toFixed(0)}s,已自动恢复并清理历史预览会话`)
})
.catch(() => {
/* ignore */
})
// 短暂冷却后释放防抖锁,允许后续「新的」冻结事件再次自愈
setTimeout(() => {
healing = false
}, 5000)
}
/** 启动看门狗(幂等,全进程只需启动一次) */
export function startFreezeWatchdog(): void {
if (started) return
if (typeof Worker === 'undefined') return // 环境不支持,静默跳过
try {
const w = new Worker(new URL('./freezeWatchdog.worker.js', import.meta.url), { type: 'module' })
w.onmessage = (e) => {
const msg = e.data || {}
if (msg.type === 'ping') {
// 主线程活着 → 立即回 pong(这一行在冻结期间跑不到,正是检测依据)
w.postMessage({ type: 'pong' })
} else if (msg.type === 'recovered') {
heal(msg.freezeMs || 0)
}
}
w.onerror = (e) => {
console.warn('[freezeWatchdog] worker 异常,看门狗停止:', e.message)
}
started = true
} catch (e) {
console.warn('[freezeWatchdog] 启动失败:', e)
}
}
@@ -0,0 +1,41 @@
/**
* 卡死看门狗 Worker
*
* 原理:主线程被【同步任务】阻塞时,连定时器回调都跑不了。
* 因此由独立 worker 线程周期性 ping 主线程;主线程收到立即 pong。
* 若 worker 在 FREEZE_MS 内收不到 pong,即可断定主线程被冻结。
*
* 注意:worker 线程本身不被主线程的同步阻塞影响,故能在冻结期间持续计时。
* 这里只有一个 2s 的低频定时器,不是忙循环,不会消耗 CPU。
*/
const PING_MS = 2000 // 每 2s 探测一次
const FREEZE_MS = 8000 // 超过 8s 没 pong 即判定冻结(正常异步 IO 不会触发)
let lastPongTs = Date.now()
let frozen = false
let freezeStartTs = 0
self.setInterval(() => {
self.postMessage({ type: 'ping' })
const sincePong = Date.now() - lastPongTs
if (!frozen && sincePong > FREEZE_MS) {
// 进入冻结态,记录起点(用最后一次 pong 时间近似冻结起点)
frozen = true
freezeStartTs = lastPongTs
}
}, PING_MS)
self.onmessage = (e) => {
const msg = e.data || {}
if (msg.type !== 'pong') return
const now = Date.now()
if (frozen) {
// 主线程恢复响应 → 报告本次冻结时长
frozen = false
self.postMessage({ type: 'recovered', freezeMs: now - freezeStartTs })
}
lastPongTs = now
}
+4 -1
View File
@@ -65,7 +65,10 @@ const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
shell: { hljs: 'bash', cm: 'shell' },
zsh: { hljs: 'bash', cm: 'shell' },
ps1: { hljs: 'powershell', cm: 'powershell' },
bat: { hljs: 'dos', cm: 'text' },
psm1: { hljs: 'powershell', cm: 'powershell' },
psd1: { hljs: 'powershell', cm: 'powershell' },
cmd: { hljs: 'dos', cm: 'batch' },
bat: { hljs: 'dos', cm: 'batch' },
ahk: { hljs: 'autohotkey', cm: 'text' },
lua: { hljs: 'lua', cm: 'text' },
r: { hljs: 'r', cm: 'text' },
+16
View File
@@ -0,0 +1,16 @@
/**
* Wails 事件 payload 解析(统一入口)
*
* 运行时 On 回调收到的是 WailsEvent 对象,真实载荷在其 .data 字段;
* 不同版本/路径下 payload 可能是 JSON 字符串或对象,这里统一兼容:
* 解包 .data → 字符串则 JSON.parse → 异常/空值回落空对象。
*/
export function parseWailsEventData(event: unknown): any {
const data = (event as any)?.data ?? event
try {
return typeof data === 'string' ? JSON.parse(data) : (data ?? {})
} catch {
return {}
}
}
+73 -8
View File
@@ -15,6 +15,9 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as filesystem$0 from "./internal/filesystem/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -53,6 +56,13 @@ export function ClearCache(): $CancellablePromise<void> {
return $Call.ByID(1413834504);
}
/**
* CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
*/
export function CopyPath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(1297703531, src, dst);
}
/**
* CreateDir 创建目录
*/
@@ -116,6 +126,25 @@ export function EmptyRecycleBin(): $CancellablePromise<void> {
return $Call.ByID(4176312624);
}
/**
* EnsureDrawioViewer 确保本地已缓存 drawio viewer(首次预览时下载),前端打开 .drawio 前调用。
* 通过 drawio-download-progress 事件推送下载进度。
*/
export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1345697171).then(($result: any) => {
return $$createType2($result);
});
}
/**
* ExportHtmlFileToPDF 将HTML文件导出为PDF
*/
export function ExportHtmlFileToPDF(path: string, saveName: string): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(3012957793, path, saveName).then(($result: any) => {
return $$createType2($result);
});
}
/**
* ExportPDF 导出PDF文件
*/
@@ -318,6 +347,13 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
});
}
/**
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除),跨盘时经 transfer-progress 事件推送进度
*/
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(2028899675, src, dst);
}
/**
* OpenPath 使用系统默认程序打开文件或目录
*/
@@ -325,6 +361,9 @@ export function OpenPath(path: string): $CancellablePromise<void> {
return $Call.ByID(1591734570, path);
}
/**
* OssConnect 连接 OSS 存储
*/
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
return $Call.ByID(3667022538, req);
}
@@ -448,6 +487,23 @@ export function OssWriteFile(connID: string, key: string, content: string): $Can
return $Call.ByID(39773277, connID, key, content);
}
/**
* PickDirectory 打开系统目录选择器,返回选中目录(取消时返回空串)
*/
export function PickDirectory(): $CancellablePromise<string> {
return $Call.ByID(1347829059);
}
/**
* ReadClipboardFiles 读取系统剪贴板中的文件/文件夹路径列表。
* 剪贴板为文本/截图等非文件内容时返回空列表,前端据此回落图片粘贴等原有链路。
*/
export function ReadClipboardFiles(): $CancellablePromise<string[]> {
return $Call.ByID(2636780132).then(($result: any) => {
return $$createType7($result);
});
}
/**
* ReadFile 读取文件
*/
@@ -496,8 +552,9 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
/**
* SaveAppConfig 保存应用配置
* 直接复用 api.SaveAppConfigRequest,避免 main 包副本转发时丢字段
*/
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
export function SaveAppConfig(req: api$0.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1942219977, req).then(($result: any) => {
return $$createType2($result);
});
@@ -516,13 +573,6 @@ export function SaveConnectionProfile(req: $models.SaveProfileRequest): $Cancell
});
}
/**
* SelectPDFSaveDirectory 选择PDF保存目录
*/
export function SelectPDFSaveDirectory(): $CancellablePromise<string> {
return $Call.ByID(1403263131);
}
/**
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
*/
@@ -594,6 +644,13 @@ export function SftpDownloadSiteForPreview(connID: string, remotePath: string):
return $Call.ByID(1591575570, connID, remotePath);
}
/**
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径
*/
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
}
/**
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
*/
@@ -660,6 +717,13 @@ export function SftpRenamePath(req: $models.SftpRenamePathRequest): $Cancellable
});
}
/**
* SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
*/
export function SftpUploadFromFile(connID: string, localPath: string, remoteDir: string): $CancellablePromise<void> {
return $Call.ByID(3304594335, connID, localPath, remoteDir);
}
/**
* SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等)
*/
@@ -747,3 +811,4 @@ const $$createType3 = filesystem$0.FileOperationResult.createFrom;
const $$createType4 = $Create.Nullable($$createType3);
const $$createType5 = $Create.Array($$createType2);
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
const $$createType7 = $Create.Array($Create.Any);
@@ -11,7 +11,6 @@ export {
OssConnectRequest,
OssRenamePathRequest,
RenamePathRequest,
SaveAppConfigRequest,
SaveBase64FileRequest,
SaveProfileRequest,
SftpConnectRequest,
@@ -2,5 +2,6 @@
// This file is automatically generated. DO NOT EDIT
export {
AppTabDefinition
AppTabDefinition,
SaveAppConfigRequest
} from "./models.js";
@@ -40,3 +40,56 @@ export class AppTabDefinition {
return new AppTabDefinition($$parsedSource as Partial<AppTabDefinition>);
}
}
/**
* SaveAppConfigRequest 保存应用配置请求(前端格式)
*/
export class SaveAppConfigRequest {
"tabs": AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
"sidebarSections": string[];
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
if (!("sidebarSections" in $$source)) {
this["sidebarSections"] = [];
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
const $$createField3_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
if ("sidebarSections" in $$parsedSource) {
$$parsedSource["sidebarSections"] = $$createField3_0($$parsedSource["sidebarSections"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
// Private type creation functions
const $$createType0 = AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
@@ -5,10 +5,6 @@
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as api$0 from "./internal/api/models.js";
/**
* BgmPlaylistItem 播放列表条目
*/
@@ -134,46 +130,6 @@ export class RenamePathRequest {
}
}
/**
* SaveAppConfigRequest 保存应用配置请求
*/
export class SaveAppConfigRequest {
"tabs": api$0.AppTabDefinition[];
"visibleTabs": string[];
"defaultTab": string;
/** Creates a new SaveAppConfigRequest instance. */
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
if (!("tabs" in $$source)) {
this["tabs"] = [];
}
if (!("visibleTabs" in $$source)) {
this["visibleTabs"] = [];
}
if (!("defaultTab" in $$source)) {
this["defaultTab"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new SaveAppConfigRequest instance from a string or object.
*/
static createFrom($$source: any = {}): SaveAppConfigRequest {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("tabs" in $$parsedSource) {
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
}
if ("visibleTabs" in $$parsedSource) {
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
}
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
}
}
/**
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
*/
@@ -422,8 +378,3 @@ export class WriteFileRequest {
return new WriteFileRequest($$parsedSource as Partial<WriteFileRequest>);
}
}
// Private type creation functions
const $$createType0 = api$0.AppTabDefinition.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Array($Create.Any);
+3 -1
View File
@@ -15,8 +15,10 @@
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@/*": ["src/*"]
"@/*": ["src/*"],
"@bindings/*": ["bindings/*"]
}
},
"include": [
+6
View File
@@ -25,6 +25,12 @@ export default defineConfig({
'@wailsio/events': fileURLToPath(new URL('./node_modules/@wailsio/runtime/dist/events.js', import.meta.url))
}
},
// Excel 解析 / 卡死看门狗使用 module worker(new Worker(url,{type:'module'})),
// 且 worker 内部用了动态 import('xlsx') 需要代码分割,故 worker 格式必须为 es。
// 目标平台 Windows WebView2(Chromium) 完整支持 module worker。
worker: {
format: 'es'
},
build: {
outDir: 'dist',
emptyOutDir: true,
+31 -29
View File
@@ -7,14 +7,15 @@ require (
github.com/chromedp/chromedp v0.15.1
github.com/fsnotify/fsnotify v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/labstack/echo/v4 v4.15.1
github.com/labstack/echo/v4 v4.15.2
github.com/pkg/sftp v1.13.10
github.com/shirou/gopsutil/v3 v3.24.5
github.com/wailsapp/wails/v3 v3.0.0-alpha.80
github.com/yuin/goldmark v1.7.16
golang.org/x/crypto v0.47.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.33.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.92
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.51.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.44.0
golang.org/x/text v0.37.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.31.1
)
@@ -22,7 +23,7 @@ require (
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
@@ -30,13 +31,13 @@ require (
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.19.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // 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
@@ -48,39 +49,40 @@ require (
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/shoenig/go-m1cpu v0.2.1 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/wails/webview2 v1.0.24 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
modernc.org/sqlite v1.50.1 // indirect
)

Some files were not shown because too many files have changed in this diff Show More