新增: 文件操作增强与预览扩展

- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
This commit is contained in:
lxy
2026-09-15 22:26:12 +08:00
parent f1bc5b49f1
commit e7a2c5148e
59 changed files with 3185 additions and 2047 deletions
+115 -1
View File
@@ -3,10 +3,31 @@ package main
import (
"fmt"
"log/slog"
"path/filepath"
"time"
"u-desk/internal/filesystem"
"github.com/wailsapp/wails/v3/pkg/application"
)
// 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) {
return a.filesystem.ReadFile(path)
@@ -62,6 +83,98 @@ func (a *App) RenamePath(req RenamePathRequest) (*filesystem.FileOperationResult
return a.filesystem.RenamePath(req.OldPath, req.NewPath)
}
// CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度
func (a *App) CopyPath(src, dst string) error {
emitter := newTransferEmitter(a, "copy", filepath.Base(filepath.FromSlash(src)))
err := a.filesystem.CopyPath(src, dst, func(copied, total int64) {
emitter.progress(emitter.name, copied, total)
})
emitter.finish(err)
return err
}
// MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
func (a *App) MovePath(src, dst string) error {
return a.filesystem.MovePath(src, dst)
}
// 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) {
return a.filesystem.GetFileInfo(path)
@@ -161,7 +274,8 @@ func (a *App) initFilesystemModule() {
return
}
go a.startFileServer()
// startFileServer 内部 listen 同步(极快)、Serve 异步,无需再包 goroutine
a.startFileServer()
slog.Info("文件系统模块初始化完成")
}
+19 -7
View File
@@ -6,13 +6,24 @@ import (
"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) {
if a.pdfAPI == nil {
pdfAPI, err := a.requirePdfAPI()
if err != nil {
return map[string]interface{}{
"success": false,
"message": "PDF导出功能未初始化",
}, fmt.Errorf("PDF导出功能未初始化")
"message": err.Error(),
}, err
}
req := api.PdfExportRequest{
@@ -24,7 +35,7 @@ func (a *App) ExportPDF(content string, title string, fileName string, fontSize
PageHeight: pageHeight,
}
result, err := a.pdfAPI.ExportMarkdownToPDF(req)
result, err := pdfAPI.ExportMarkdownToPDF(req)
if err != nil {
return map[string]interface{}{
"success": false,
@@ -42,9 +53,10 @@ func (a *App) ExportPDF(content string, title string, fileName string, fontSize
// SelectPDFSaveDirectory 选择PDF保存目录
func (a *App) SelectPDFSaveDirectory() (string, error) {
if a.pdfAPI == nil {
return "", fmt.Errorf("PDF导出功能未初始化")
pdfAPI, err := a.requirePdfAPI()
if err != nil {
return "", err
}
return a.pdfAPI.SelectDirectory()
return pdfAPI.SelectDirectory()
}
+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 条 → 单独拆文件
- 已稳定决策合并精简,只留结论 + 关键原因
**更新优先于新增**:决策点已存在就改原条,演进用 `→` 串联,不留重复版本。
+45
View File
@@ -53,6 +53,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 +123,16 @@ 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);
});
}
/**
* ExportPDF 导出PDF文件
*/
@@ -318,6 +335,13 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
});
}
/**
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
*/
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(2028899675, src, dst);
}
/**
* OpenPath 使用系统默认程序打开文件或目录
*/
@@ -451,6 +475,13 @@ 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);
}
/**
* ReadFile 读取文件
*/
@@ -597,6 +628,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 下载远程文件到本地临时目录(用于预览)
*/
@@ -663,6 +701,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 编码的二进制文件(粘贴图片等)
*/
+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",
+87 -15
View File
@@ -18,7 +18,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 +59,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 +94,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 +120,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 +149,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 +164,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 +268,7 @@ class ConnectionManagerImpl {
}
}
}
} catch (e) { info._error = true; info._errorMsg = e instanceof Error ? e.message : String(e) }
} catch (e) { info._error = true; info._errorMsg = errMsg(e) }
this._sysInfoCache.set(profileId, info)
this._sysInfoChangeCallbacks.forEach(cb => cb(profileId, info))
@@ -268,11 +284,32 @@ 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)) {
this._activeId = profileId
this.setState('connected')
return
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),失败时回退
@@ -282,6 +319,7 @@ class ConnectionManagerImpl {
await this.buildAndPool(profileId, profile)
} catch (err) {
this._activeId = prevActiveId
this.setProfileState(profileId, 'error', err instanceof Error ? err.message : String(err))
this.notifyChange()
throw err
}
@@ -294,6 +332,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 +352,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 +409,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', errMsg(err))
this.setState('error')
throw err
}
@@ -381,15 +441,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', errMsg(err))
this.setState('error')
throw err
}
@@ -397,22 +460,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', errMsg(e))
if (this._activeId !== profileId) return
this.setState('error')
})
} catch (err) {
this._pool.delete(profileId)
this.setProfileState(profileId, 'error', errMsg(err))
this.setState('error')
throw err
}
@@ -421,6 +488,11 @@ class ConnectionManagerImpl {
export const connectionManager = new ConnectionManagerImpl()
/** 任意抛出值转错误文案 */
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
/** snake_case → camelCase */
function snakeToCamel(obj: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
+24 -2
View File
@@ -10,9 +10,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 +83,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 操作在远程模式暂未实现')
}
-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()
}
+49
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>
@@ -76,4 +87,42 @@ export interface FsTransport {
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
@@ -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>
@@ -464,21 +467,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) {
@@ -486,6 +479,10 @@ function onKeyDown(e: KeyboardEvent) {
e.preventDefault()
toggleFullscreen()
}
// ESC 退出窗口内全屏
if (e.key === 'Escape' && isFullscreen.value) {
isFullscreen.value = false
}
}
// Props
@@ -798,12 +795,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 公共函数
@@ -950,23 +947,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 = `
@@ -1088,6 +1096,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) => {
// 安全检查:接受来自本地文件服务器或同源的消息
@@ -1110,7 +1132,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
@@ -1122,7 +1143,6 @@ onUnmounted(() => {
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
}
window.removeEventListener('message', handleHtmlIframeMessage)
document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('keydown', onKeyDown)
Off('toggle-bgm-bar')
copyCleanup()
@@ -1140,9 +1160,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;
}
@@ -1162,7 +1184,7 @@ defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHid
}
/* 仅全屏模式下 header 可拖动窗口 */
.file-editor-panel:fullscreen .panel-header {
.file-editor-panel.is-fullscreen .panel-header {
--wails-draggable: drag;
}
@@ -1612,54 +1634,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 提供 */
/* ========== 深色模式适配 ========== */
@@ -1730,12 +1705,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;
}
@@ -1746,6 +1725,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 {
@@ -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
// 清除草稿
@@ -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 result = await listDir(path)
onSuccess?.('listDirectory', result)
return result
}
/**
* 读取文件内容
*/
/** 读取文件内容 */
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 content = await readFileApi(path)
onSuccess?.('readFile', { path, size: content.length })
return content
}
/**
* 写入文件内容
*/
const writeFile = async (
path: string,
content: string,
createBackup: boolean = false
): Promise<void> => {
try {
await writeFileApi(path, content)
onSuccess?.('writeFile', { path, size: content.length })
} catch (error) {
const err = error as Error
onError?.('writeFile', err)
throw err
}
/** 写入文件内容 */
const writeFile = async (path: string, content: string): Promise<void> => {
await writeFileApi(path, content)
onSuccess?.('writeFile', { path, size: content.length })
}
/**
* 删除路径(文件或目录),返回被删除的文件信息
*/
/** 删除路径 */
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 result = await deletePathApi(path)
onSuccess?.('deletePath', { path, result })
return result as FileItem
}
/**
* 创建新文件,返回创建的文件信息
*/
const createNewFile = async (
dirPath: string,
filename: string
): Promise<FileItem> => {
try {
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 createNewFile = async (dirPath: string, filename: string): Promise<FileItem> => {
const result = await createFile(dirPath, filename)
onSuccess?.('createFile', { dirPath, filename, result })
return result as FileItem
}
/**
* 创建新目录,返回创建的目录信息
*/
/** 创建新目录 */
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 result = await createDir(parentPath, dirname)
onSuccess?.('createDir', { parentPath, dirname, result })
return result as FileItem
}
/**
* 重命名文件或目录,返回新文件信息
*/
/** 重命名 */
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
}
const result = await renamePathApi(oldPath, newPath)
onSuccess?.('rename', { oldPath, newPath, result })
return result as FileItem
}
/**
* 复制文件或目录
*/
/** 复制(递归,进度经 transfer-progress 事件) */
const copy = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现复制逻辑
Message.warning('复制功能暂未实现')
onSuccess?.('copy', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('copy', err)
throw err
}
await copyPathApi(fromPath, toPath)
onSuccess?.('copy', { fromPath, toPath })
}
/**
* 移动文件或目录
*/
/** 移动(跨盘自动回退复制+删除) */
const move = async (fromPath: string, toPath: string): Promise<void> => {
try {
// TODO: 实现移动逻辑
Message.warning('移动功能暂未实现')
onSuccess?.('move', { fromPath, toPath })
} catch (error) {
const err = error as Error
onError?.('move', err)
throw err
}
await movePathApi(fromPath, toPath)
onSuccess?.('move', { fromPath, toPath })
}
/**
* 列出 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
}
const result = await listZipContentsApi(zipPath)
onSuccess?.('listZipContents', { zipPath, count: result.length })
return result
}
/**
* 从 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
}
const content = await extractFileFromZip(zipPath, filePath)
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
return content
}
/**
* 从 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
}
const tempPath = await extractZipToTempApi(zipPath, filePath)
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
return tempPath
}
/**
* 获取文件服务器 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
}
const url = await getFileServerUrlApi()
onSuccess?.('getFileServerURL', { url })
return url
}
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
+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>
@@ -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
}
+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 }
})
+124
View File
@@ -428,4 +428,128 @@ 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;
}
+10
View File
@@ -147,6 +147,8 @@ export interface FileListPanelConfig {
editingFilePath: string
/** 编辑中的文件名 */
editingFileName: string
/** 被剪切的文件路径(行样式半透明) */
cutPaths?: string[]
}
/**
@@ -209,6 +211,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 +236,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 类批处理参数
if (stream.match(/^!\w+!/) || stream.match(/^%\w+%/) || stream.match(/^%~[a-z]*[\d*]/i)) 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)
+1 -1
View File
@@ -76,7 +76,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'
],
+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 {}
}
}
@@ -53,6 +53,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 创建目录
*/
@@ -175,6 +182,15 @@ export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }
});
}
/**
* EnsureDrawioViewer 确保 drawio viewer 已下载就绪(用于 .drawio/.dio 预览)
*/
export function EnsureDrawioViewer(): $CancellablePromise<{ [_ in string]?: any }> {
return $Call.ByID(1345697171).then(($result: any) => {
return $$createType2($result);
});
}
/**
* GetCurrentVersion 获取当前版本号
*/
@@ -318,6 +334,13 @@ export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?:
});
}
/**
* MovePath 移动文件或目录(同盘重命名,跨盘复制后删除)
*/
export function MovePath(src: string, dst: string): $CancellablePromise<void> {
return $Call.ByID(2028899675, src, dst);
}
/**
* OpenPath 使用系统默认程序打开文件或目录
*/
@@ -448,6 +471,13 @@ 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);
}
/**
* ReadFile 读取文件
*/
@@ -608,6 +638,20 @@ export function SftpDownloadToTempCached(connID: string, remotePath: string, fil
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
}
/**
* SftpDownloadToFile 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本)
*/
export function SftpDownloadToFile(connID: string, remotePath: string, localDstDir: string): $CancellablePromise<string> {
return $Call.ByID(2935422351, connID, remotePath, localDstDir);
}
/**
* SftpUploadFromFile 上传本地文件/目录到远程目录(流式,目录递归,重名自动副本)
*/
export function SftpUploadFromFile(connID: string, localPath: string, remoteDir: string): $CancellablePromise<void> {
return $Call.ByID(3304594335, connID, localPath, remoteDir);
}
/**
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
*/
+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,
+1
View File
@@ -13,6 +13,7 @@ require (
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
+117 -59
View File
@@ -115,6 +115,7 @@ func StartLocalFileServer() (string, error) {
mux := http.NewServeMux()
mux.HandleFunc("/localfs/", handleLocalFileRequest)
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
mux.HandleFunc("/drawio-viewer/", handleDrawioViewerRequest)
addr, srv, err := listenWithFallback(DefaultFileServerPort, mux)
if err != nil {
@@ -155,26 +156,50 @@ func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *h
// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口)
func GetLocalFileServerAddr() string {
if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) }
if localFileServer == nil {
return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort)
}
return localFileServer.addr
}
// writeCORSHeaders 写入 CORS 头并处理 OPTIONS 预检,返回 true 表示已处理(调用方应 return)
func writeCORSHeaders(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return true
}
return false
}
// ensureGetMethod 检查请求是否为 GET,非 GET 返回 405,返回 true 表示不是 GET
func ensureGetMethod(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return true
}
return false
}
// handlePathValidationError 将路径校验错误转换为 HTTP 响应
func handlePathValidationError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
// handleLocalFileRequest 处理本地文件请求
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
// CORS 头:允许所有源访问(因为这是本地文件服务器)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
// 处理 OPTIONS 预检请求
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// 只处理 GET 请求
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
return
}
@@ -195,26 +220,11 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
pathPart = "/" + pathPart
}
if pathPart == "" || pathPart == r.URL.Path {
log.Printf("[LocalFileHandler] 路径前缀无效")
http.Error(w, "Invalid path. Use: /localfs/C:/path/to/file", http.StatusBadRequest)
return
}
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
// 校验路径安全性
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
if err != nil {
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
switch {
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
handlePathValidationError(w, err)
return
}
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
@@ -515,20 +525,7 @@ func isAbsoluteURL(path string) bool {
// - path: HTML 文件绝对路径(URL 编码)
// - theme: 主题(light / dark)
func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
// CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
// 处理 OPTIONS 预检请求
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// 只处理 GET 请求
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
return
}
@@ -539,20 +536,11 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
theme = "light"
}
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
// 校验路径安全性
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
if err != nil {
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
switch {
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
handlePathValidationError(w, err)
return
}
@@ -569,14 +557,21 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
// 获取文件所在目录(用于解析相对路径)
baseDir := filepath.Dir(filePath)
// 摘出 <script>/<style> 块做占位保护,防止资源路径正则误改 JS 字符串中的 HTML 文本
// (如模板字符串 `style="${x==='a'?...}"` 被引号重包裹破坏,导致整页脚本 SyntaxError 白屏)
protected := protectScriptStyleBlocks(string(content), baseDir)
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
processedContent := transformHtmlResourcePaths(string(content), baseDir)
processedContent := transformHtmlResourcePaths(protected.html, baseDir)
// 注入路径拦截脚本(处理 webpack 等动态加载的绝对路径资源)
processedContent = injectPathInterceptor(processedContent, baseDir)
// 注入链接点击拦截脚本
finalContent := injectLinkInterceptor(processedContent)
processedContent = injectLinkInterceptor(processedContent)
// 回填受保护的 script 内容;style 内容单独走 CSS url() 重写后回填
finalContent := restoreScriptStyleBlocks(processedContent, protected, baseDir)
// 返回处理后的 HTML
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -586,6 +581,69 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
log.Printf("[HtmlPreview] 处理完成: %s (%d -> %d bytes)", filePath, len(content), len(finalContent))
}
// scriptStylePlaceholder 占位标记(含随机性低的固定前缀,正常 HTML 不会出现)
const scriptStyleMarker = "UDESK_PROTECTED_BLOCK_"
// protectedBlocks 记录摘出的块信息
type protectedBlocks struct {
scripts []string // <script>...</script> 原文(含标签),回填时不做任何改写
styles []styleBlock
}
// styleBlock 单个 <style> 块
type styleBlock struct {
openTag string // <style ...> 开标签
content string // 标签间内容(CSS)
}
// extractScriptStyleRegex 摘块正则(非贪婪,覆盖有无属性的 script)
var extractScriptStyleRegex = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>|<style(\s[^>]*)?>(.*?)</style>`)
// protectScriptStyleBlocks 将所有 <script>/<style> 块替换为占位标记。
// 外链 script(带 src 属性)先重写 src 指向本地服务器,内容不做任何改写。
func protectScriptStyleBlocks(html string, baseDir string) protectedBlocksResult {
blocks := protectedBlocks{}
out := extractScriptStyleRegex.ReplaceAllStringFunc(html, func(match string) string {
lower := strings.ToLower(match)
if strings.HasPrefix(lower, "<script") {
// 外链 script: 重写 src 属性(内联 script 无 src,attrRegex 不命中则原样)
rewritten := replaceHtmlTagAttribute(match, htmlScriptTagRegex, "src", baseDir)
idx := len(blocks.scripts)
blocks.scripts = append(blocks.scripts, rewritten)
return scriptStyleMarker + fmt.Sprintf("SCRIPT%d_", idx) + scriptStyleMarker
}
// <style> 块:拆开标签,内容交给后续 CSS 重写
sm := extractScriptStyleRegex.FindStringSubmatch(match)
// style 分支: sm[3]=属性 sm[4]=内容
openTag := "<style>"
if sm[3] != "" {
openTag = "<style" + sm[3] + ">"
}
idx := len(blocks.styles)
blocks.styles = append(blocks.styles, styleBlock{openTag: openTag, content: sm[4]})
return scriptStyleMarker + fmt.Sprintf("STYLE%d_", idx) + scriptStyleMarker
})
return protectedBlocksResult{html: out, blocks: blocks}
}
// protectedBlocksResult 摘块结果
type protectedBlocksResult struct {
html string
blocks protectedBlocks
}
// restoreScriptStyleBlocks 回填 script 原文;style 内容做 CSS url()/@import 重写后回填
func restoreScriptStyleBlocks(html string, protected protectedBlocksResult, baseDir string) string {
for i, s := range protected.blocks.scripts {
html = strings.Replace(html, scriptStyleMarker+fmt.Sprintf("SCRIPT%d_", i)+scriptStyleMarker, s, 1)
}
for i, b := range protected.blocks.styles {
content := transformCssContent(b.content, baseDir)
html = strings.Replace(html, scriptStyleMarker+fmt.Sprintf("STYLE%d_", i)+scriptStyleMarker, b.openTag+content+"</style>", 1)
}
return html
}
// transformHtmlResourcePaths 转换 HTML 中的资源路径为本地文件服务器 URL
func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
if baseDir == "" {
+52 -56
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -18,6 +19,7 @@ const (
OperationDelete AuditOperation = "delete" // 删除文件
OperationCreate AuditOperation = "create" // 创建目录
OperationRename AuditOperation = "rename" // 重命名
OperationCopy AuditOperation = "copy" // 复制
OperationMove AuditOperation = "move" // 移动
OperationList AuditOperation = "list" // 列出目录
OperationDownload AuditOperation = "download" // 下载
@@ -39,43 +41,45 @@ type AuditLogEntry struct {
// AuditLogger 审计日志记录器
type AuditLogger struct {
logFile *os.File
logPath string
mu sync.Mutex
buffer []AuditLogEntry
logFile *os.File
logDir string
mu sync.Mutex
buffer []AuditLogEntry
bufferSize int
stopChan chan struct{}
stopChan chan struct{}
initialized bool // 懒初始化标志(失败时允许重试)
}
// NewAuditLogger 创建审计日志记录器
// NewAuditLogger 创建审计日志记录器(延迟初始化,不立即打开文件)
func NewAuditLogger(logDir string) (*AuditLogger, error) {
// 创建日志目录
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, fmt.Errorf("创建日志目录失败: %v", err)
}
// 生成日志文件名(按日期)
timestamp := time.Now().Format("2006-01-02")
logPath := filepath.Join(logDir, fmt.Sprintf("audit_%s.log", timestamp))
// 打开日志文件(追加模式)
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("打开日志文件失败: %v", err)
}
logger := &AuditLogger{
logFile: logFile,
logPath: logPath,
return &AuditLogger{
logDir: logDir,
buffer: make([]AuditLogEntry, 0, 100),
bufferSize: 100, // 缓冲100条记录后批量写入
bufferSize: 100,
stopChan: make(chan struct{}),
}, nil
}
// ensureReady 首次写入时才创建目录、打开文件、启动后台协程
// 在 a.mu.Lock 保护下调用,失败时下次 Log 会重试
func (a *AuditLogger) ensureReady() error {
if a.initialized {
return nil
}
// 启动后台协程,定期刷新缓冲区
go logger.backgroundFlush()
return logger, nil
if err := os.MkdirAll(a.logDir, 0755); err != nil {
return fmt.Errorf("创建日志目录失败: %v", err)
}
timestamp := time.Now().Format("2006-01-02")
f, err := os.OpenFile(
filepath.Join(a.logDir, fmt.Sprintf("audit_%s.log", timestamp)),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
a.logFile = f
a.initialized = true
go a.backgroundFlush()
return nil
}
// Log 记录操作日志
@@ -88,6 +92,13 @@ func (a *AuditLogger) Log(entry AuditLogEntry) error {
a.mu.Lock()
defer a.mu.Unlock()
// 懒初始化:首次写入时才打开文件
if a.logFile == nil {
if err := a.ensureReady(); err != nil {
return err
}
}
// 添加到缓冲区
a.buffer = append(a.buffer, entry)
@@ -211,6 +222,11 @@ func (a *AuditLogger) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
// 从未写入过日志,无需关闭
if a.logFile == nil {
return nil
}
// 刷新剩余缓冲区
if err := a.flush(); err != nil {
return err
@@ -251,35 +267,15 @@ func GetRecentLogs(logDir string, limit int) ([]AuditLogEntry, error) {
return entries, nil
}
// parseLines 解析文本为行
// parseLines 解析文本为行(过滤空行)
func parseLines(text string) []string {
lines := make([]string, 0)
current := ""
for _, ch := range text {
if ch == '\n' {
if current != "" {
lines = append(lines, current)
current = ""
}
} else {
current += string(ch)
raw := strings.Split(text, "\n")
lines := make([]string, 0, len(raw))
for _, line := range raw {
if line != "" {
lines = append(lines, line)
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
// 全局审计日志记录器
var globalAuditLogger *AuditLogger
var auditLoggerOnce sync.Once
// GetAuditLogger 获取全局审计日志记录器
func GetAuditLogger() *AuditLogger {
return globalAuditLogger
}
+4
View File
@@ -288,6 +288,8 @@ func getAllowedExtensions() map[string]bool {
".md": true,
".json": true,
".xml": true,
".drawio": true,
".dio": true,
".html": true,
".css": true,
".js": true,
@@ -369,6 +371,8 @@ func getMIMETypeMapping() map[string]string {
".js": "application/javascript",
".json": "application/json",
".xml": "application/xml",
".drawio": "application/xml",
".dio": "application/xml",
".md": "text/markdown",
// 表格
".csv": "text/csv; charset=utf-8",
+278
View File
@@ -0,0 +1,278 @@
package filesystem
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"u-desk/internal/common"
"u-desk/internal/service"
)
// drawio viewer 运行时下载缓存方案:
// 首次预览 .drawio 时,从自建下载源拉取 drawio webapp zip → 解压到 ~/.u-desk/drawio-viewer/
// → 文件服务器 /drawio-viewer/ 从缓存目录托管静态资源 → 之后离线可用。
// 升级 drawio 时:改下面的版本号 + URL + SHA256。
const (
drawioViewerVersion = "30.3.8" // drawio 版本,升级时改这里
drawioViewerZipURL = "https://c.1216.top/u-desk/drawio-viewer-30.3.8.war" // 自建下载源(draw.war 解压即 webapp)
drawioViewerSHA256 = "e12a3e190adda7c1486572bd28c1af9fffbe932df2efead3c9aa8347862505da" // draw.war v30.3.8 SHA256
drawioViewerDirName = "drawio-viewer"
versionFileName = "VERSION"
)
// 并发保护:多个 .drawio 同时打开时只下载一次,其余等待
var (
drawioEnsureMu sync.Mutex
drawioEnsuring bool
)
// DrawioViewerVersion 返回当前配置的 viewer 版本(供 binding 返回前端)
func DrawioViewerVersion() string { return drawioViewerVersion }
// drawioCacheDir 缓存目录 ~/.u-desk/drawio-viewer/
func drawioCacheDir() string {
return filepath.Join(common.GetUserDataDir(), drawioViewerDirName)
}
// isDrawioViewerReady 缓存目录存在且 VERSION 文件匹配当前版本
func isDrawioViewerReady() bool {
data, err := os.ReadFile(filepath.Join(drawioCacheDir(), versionFileName))
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == drawioViewerVersion
}
// EnsureDrawioViewer 确保本地已缓存 drawio viewer。未就绪则下载 zip + 解压。
// progress 回调推送下载进度(可为 nil)。并发安全:同时调用只下载一次,其余等待。
func EnsureDrawioViewer(progress service.DownloadProgress) error {
if isDrawioViewerReady() {
return nil
}
// 并发保护:已有线程在下载 → 轮询等待就绪
drawioEnsureMu.Lock()
if drawioEnsuring {
drawioEnsureMu.Unlock()
for i := 0; i < 3600; i++ { // 最多等待 30 分钟(与下载超时对齐)
if isDrawioViewerReady() {
return nil
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("等待 drawio viewer 下载超时")
}
drawioEnsuring = true
drawioEnsureMu.Unlock()
defer func() {
drawioEnsureMu.Lock()
drawioEnsuring = false
drawioEnsureMu.Unlock()
}()
cacheDir := drawioCacheDir()
// 1. 下载 zip(复用 service.DownloadUpdate,自带断点续传 + 哈希)
res, err := service.DownloadUpdate(drawioViewerZipURL, progress)
if err != nil {
return fmt.Errorf("下载 drawio viewer 失败: %w", err)
}
// 2. SHA256 校验(若常量非空)。DownloadUpdate 成功时必返回 SHA256Hash
if drawioViewerSHA256 != "" {
if !strings.EqualFold(res.SHA256Hash, drawioViewerSHA256) {
_ = os.Remove(res.FilePath) // 删除损坏文件,下次重试
return fmt.Errorf("drawio viewer 哈希校验失败: 期望 %s, 实际 %s", drawioViewerSHA256, res.SHA256Hash)
}
}
// 3. 解压到临时目录,成功后原子替换缓存目录(避免半解压状态)
tmpDir := cacheDir + ".tmp-" + drawioViewerVersion
_ = os.RemoveAll(tmpDir)
if err := unzipToDir(res.FilePath, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("解压 drawio viewer 失败: %w", err)
}
// 4. 写 VERSION 文件
if err := os.WriteFile(filepath.Join(tmpDir, versionFileName), []byte(drawioViewerVersion), 0644); err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("写入 VERSION 失败: %w", err)
}
// 5. 原子替换缓存目录
_ = os.RemoveAll(cacheDir)
if err := os.Rename(tmpDir, cacheDir); err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("移动 drawio viewer 到缓存目录失败: %w", err)
}
// 安装成功后删除下载的 war 包,避免长期占用磁盘(失败路径保留以支持断点续传)
_ = os.Remove(res.FilePath)
log.Printf("[DrawioViewer] 缓存就绪: %s (版本 %s)", cacheDir, drawioViewerVersion)
return nil
}
// handleDrawioViewerRequest /drawio-viewer/* → 从缓存目录读静态资源(绕过文件类型白名单)
// viewer 资源含 .woff2/.stencil 等白名单外扩展名,故独立路由,不走 handleLocalFileRequest。
func handleDrawioViewerRequest(w http.ResponseWriter, r *http.Request) {
if writeCORSHeaders(w, r) || ensureGetMethod(w, r) {
return
}
if !isDrawioViewerReady() {
http.Error(w, "drawio viewer not ready, please call EnsureDrawioViewer first", http.StatusServiceUnavailable)
return
}
// 剥离 /drawio-viewer/ 前缀
subPath := strings.TrimPrefix(r.URL.Path, "/drawio-viewer/")
if subPath == "" {
subPath = "index.html"
}
// 路径清理 + 防遍历:限制在缓存目录内
cleanPath, ok := sanitizeSubPath(subPath)
if !ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if cleanPath == "." || cleanPath == "" {
cleanPath = "index.html"
}
absPath, ok := joinWithinBase(drawioCacheDir(), cleanPath)
if !ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
data, err := os.ReadFile(absPath)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", getDrawioViewerMIME(filepath.Ext(cleanPath)))
if cleanPath == "index.html" {
w.Header().Set("Cache-Control", "no-cache") // 入口短缓存,便于升级
} else {
w.Header().Set("Cache-Control", "public, max-age=86400")
}
w.Write(data)
}
// sanitizeSubPath 将子路径(URL 或 zip 条目名)清理为正斜杠相对路径,含 .. 视为可疑,返回 false
func sanitizeSubPath(sub string) (string, bool) {
cleaned := strings.TrimPrefix(filepath.ToSlash(filepath.Clean("/"+sub)), "/")
if strings.Contains(cleaned, "..") {
return "", false
}
return cleaned, true
}
// joinWithinBase 将相对路径拼进 base 并确认结果仍在 base 内(防符号链接绕过),越界返回 false
func joinWithinBase(base, sub string) (string, bool) {
absPath := filepath.Join(base, filepath.FromSlash(sub))
if rel, err := filepath.Rel(base, absPath); err != nil || strings.HasPrefix(rel, "..") {
return "", false
}
return absPath, true
}
// getDrawioViewerMIME 返回 viewer 静态资源的 MIME
func getDrawioViewerMIME(ext string) string {
switch strings.ToLower(ext) {
case ".html", ".htm":
return "text/html; charset=utf-8"
case ".js", ".mjs":
return "application/javascript; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".woff":
return "font/woff"
case ".woff2":
return "font/woff2"
case ".ttf":
return "font/ttf"
case ".xml":
return "application/xml"
default:
return "application/octet-stream"
}
}
// ==================== 辅助函数 ====================
// unzipToDir 解压整个 zip 到目标目录(覆盖已存在文件)
func unzipToDir(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
for _, f := range r.File {
if err := extractZipFile(f, destDir); err != nil {
return err
}
}
return nil
}
// extractZipFile 解压单个 zip 条目到目标目录(带路径遍历防护)
func extractZipFile(f *zip.File, destDir string) error {
name, ok := sanitizeSubPath(f.Name)
if !ok {
return fmt.Errorf("可疑路径: %s", f.Name)
}
destPath, ok := joinWithinBase(destDir, name)
if !ok {
return fmt.Errorf("逃逸路径: %s", f.Name)
}
if f.FileInfo().IsDir() {
return os.MkdirAll(destPath, 0755)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}
-16
View File
@@ -50,19 +50,3 @@ func OpenPath(path string) error {
return nil
}
// ========== 工具函数 ==========
// FormatBytes 格式化字节大小为人类可读格式(导出供 sftp 等外部包使用)
func FormatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
-39
View File
@@ -103,43 +103,4 @@ func (l *Logger) log(level LogLevel, levelStr, format string, args ...interface{
}
}
// LogOperation 记录操作日志(辅助函数)
func LogOperation(operation, path string, success bool, err error) {
logger := GetGlobalLogger()
if logger == nil {
return
}
if success {
logger.Info("操作: %s %s - 成功", operation, path)
} else {
logger.Error("操作: %s %s - 失败: %v", operation, path, err)
}
}
// LogError 记录错误日志(辅助函数)
func LogError(operation string, path string, err error) {
logger := GetGlobalLogger()
if logger == nil {
return
}
logger.Error("错误: %s %s - %v", operation, path, err)
// 如果是调试模式,输出堆栈跟踪
if os.Getenv("UDESK_DEBUG") == "1" {
logger.Debug("堆栈:\n%s", GetStackTrace(2))
}
}
// ========== 全局日志记录器(向后兼容)==========
var (
globalLogger *Logger
)
// GetGlobalLogger 获取全局日志记录器
func GetGlobalLogger() *Logger {
return globalLogger
}
+5 -35
View File
@@ -4,8 +4,8 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"math/big"
"u-desk/internal/common"
"os"
"path/filepath"
"sync"
@@ -288,7 +288,7 @@ func copyRecursively(src, dst string) error {
return copyDirectory(src, dst)
}
return copyFile(src, dst)
return common.CopyFile(src, dst)
}
// copyDirectory 复制目录
@@ -314,7 +314,7 @@ func copyDirectory(src, dst string) error {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
if err := common.CopyFile(srcPath, dstPath); err != nil {
return err
}
}
@@ -323,36 +323,6 @@ func copyDirectory(src, dst string) error {
return nil
}
// copyFile 复制文件
func copyFile(src, dst string) error {
// 打开源文件
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
// 创建目标文件
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
// 复制内容
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
// 复制文件权限
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
return os.Chmod(dst, srcInfo.Mode())
}
// getDevice 获取文件所在设备(盘符)
func getDevice(path string) string {
absPath, err := filepath.Abs(path)
@@ -377,8 +347,8 @@ func generateRandomString(length int) string {
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
// 如果加密随机数生成失败,回退到时间戳(极低概率)
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
// crypto/rand 失败时用时间戳+索引偏移避免碰撞
b[i] = charset[(time.Now().UnixNano()+int64(i))%int64(len(charset))]
continue
}
b[i] = charset[n.Int64()]
+206 -30
View File
@@ -5,6 +5,8 @@ import (
"encoding/base64"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"os/exec"
@@ -143,11 +145,10 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
}
s.logRead(path, int64(len(data)), nil)
return BytesToString(data), nil
return common.BytesToString(data), nil
}
// Write 写入文件内容(实现 FileService 接口)
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
// writeFileWithLog 内部写入实现(路径验证+大小检查+写入+日志)
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
if err := s.validatePath(path); err != nil {
return err
@@ -229,28 +230,20 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
}
}
// 执行删除
var deleteErr error
if info.IsDir() {
deleteErr = os.RemoveAll(path)
} else {
deleteErr = os.Remove(path)
}
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
if deleteErr != nil {
return nil, fmt.Errorf("删除失败: %v", deleteErr)
}
// 如果启用回收站,移动到回收站而非永久删除
if s.recycleBin != nil {
// 检查是否已在回收站中
if !isInRecycleBin(path) {
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
// 回收站失败,记录但继续
slog.Warn("移动到回收站失败", "error", err)
// 回收站优先:先尝试移动,失败再永久删除
if s.recycleBin != nil && !isInRecycleBin(path) {
if err := s.recycleBin.MoveToRecycleBin(path); err != nil {
slog.Warn("回收站移动失败,改为永久删除", "error", err)
if err := s.permanentDelete(path, info); err != nil {
return nil, err
}
} else {
s.logDelete(path, info.IsDir(), info.Size(), nil)
}
} else {
// 无回收站或已在回收站中,永久删除
if err := s.permanentDelete(path, info); err != nil {
return nil, err
}
}
@@ -259,7 +252,7 @@ func (s *FileSystemService) DeletePathWithContext(ctx context.Context, path stri
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: info.IsDir(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -366,7 +359,7 @@ func (s *FileSystemService) CreateDir(path string) (*FileOperationResult, error)
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: true,
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -414,7 +407,7 @@ func (s *FileSystemService) CreateFile(path string) (*FileOperationResult, error
Path: filepath.ToSlash(path), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: false,
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -439,7 +432,7 @@ func (s *FileSystemService) GetFileInfo(path string) (map[string]interface{}, er
"name": info.Name(),
"path": filepath.ToSlash(path), // 统一使用正斜杠
"size": info.Size(),
"size_str": FormatBytes(info.Size()),
"size_str": common.FormatBytesInt(info.Size()),
"is_dir": info.IsDir(),
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
"mode": info.Mode().String(),
@@ -495,7 +488,7 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
Path: filepath.ToSlash(newPath), // 统一使用正斜杠
Name: info.Name(),
Size: info.Size(),
SizeStr: FormatBytes(info.Size()),
SizeStr: common.FormatBytesInt(info.Size()),
IsDir: info.IsDir(),
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
Mode: info.Mode().String(),
@@ -503,6 +496,174 @@ func (s *FileSystemService) RenamePath(oldPath, newPath string) (*FileOperationR
}, nil
}
// ========== 复制与移动 ==========
// checkCopyMovePaths 复制/移动共用的前置校验:路径合法、源存在、目标不存在
func (s *FileSystemService) checkCopyMovePaths(src, dst string) (os.FileInfo, error) {
if err := s.validatePath(src); err != nil {
return nil, err
}
if err := s.validatePath(dst); err != nil {
return nil, err
}
info, err := os.Lstat(src)
if err != nil {
return nil, fmt.Errorf("源路径不存在: %v", err)
}
if _, err := os.Lstat(dst); err == nil {
return nil, fmt.Errorf("目标已存在: %s", dst)
}
return info, nil
}
// logCopyMoveAudit 记录复制/移动成功审计日志
func (s *FileSystemService) logCopyMoveAudit(op AuditOperation, src, dst string, isDir bool) {
s.logAudit(AuditLogEntry{
Timestamp: getCurrentTimestamp(),
Operation: op,
Path: dst,
OldPath: src,
IsDirectory: isDir,
Success: true,
})
}
// CopyPath 递归复制文件或目录到目标路径(dst 必须不存在,唯一性由调用方保证)
// onProgress 每读取一块回调一次(可为 nil),copied/total 单位字节
func (s *FileSystemService) CopyPath(src, dst string, onProgress func(copied, total int64)) error {
info, err := s.checkCopyMovePaths(src, dst)
if err != nil {
return err
}
total, err := pathSize(src)
if err != nil {
return fmt.Errorf("统计源大小失败: %v", err)
}
var copied int64
if err := copyPathRecursive(src, dst, info, &copied, total, onProgress); err != nil {
return fmt.Errorf("复制失败: %w", err)
}
s.logCopyMoveAudit(OperationCopy, src, dst, info.IsDir())
return nil
}
// MovePath 移动文件或目录:同盘直接重命名,跨盘回退为复制后永久删除源(不走回收站)
func (s *FileSystemService) MovePath(src, dst string) error {
info, err := s.checkCopyMovePaths(src, dst)
if err != nil {
return err
}
// 快路径:同盘 rename(不发进度)
if err := os.Rename(src, dst); err == nil {
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
return nil
}
// 跨盘:复制 + 删除源
if err := s.CopyPath(src, dst, nil); err != nil {
return err
}
if err := os.RemoveAll(src); err != nil {
return fmt.Errorf("移动后删除源失败: %v", err)
}
s.logCopyMoveAudit(OperationMove, src, dst, info.IsDir())
return nil
}
// UniquePath 返回不冲突的目标路径:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)
// 命名循环复用 common.UniqueName(与 SFTP 远端重名逻辑同源)
func UniquePath(path string) string {
dir, name := filepath.Split(path)
unique, err := common.UniqueName(name, func(candidate string) (bool, error) {
_, err := os.Lstat(filepath.Join(dir, candidate))
return err == nil, nil
})
if err != nil {
return path // exists 恒不返回错误,防御
}
return filepath.Join(dir, unique)
}
// copyPathRecursive 递归复制单个路径(文件/目录/符号链接)
func copyPathRecursive(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
switch {
case info.IsDir():
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
entryInfo, err := entry.Info()
if err != nil {
return err
}
if err := copyPathRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name()), entryInfo, copied, total, onProgress); err != nil {
return err
}
}
return nil
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(src)
if err != nil {
return err
}
return os.Symlink(target, dst)
default:
return copyFileWithProgress(src, dst, info, copied, total, onProgress)
}
}
// copyFileWithProgress 复制单个普通文件,按读取量累计回调进度
func copyFileWithProgress(src, dst string, info os.FileInfo, copied *int64, total int64, onProgress func(copied, total int64)) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
var reader io.Reader = in
if onProgress != nil {
reader = &common.CountingReader{R: in, OnN: func(n int64) {
*copied += n
onProgress(*copied, total)
}}
}
_, err = io.Copy(out, reader)
return err
}
// pathSize 统计路径总大小(文件为自身大小,目录递归求和,跳过符号链接)
func pathSize(path string) (int64, error) {
var total int64
err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
if info, e := d.Info(); e == nil {
total += info.Size()
}
}
return nil
})
return total, err
}
// ========== ZIP操作接口 ==========
// ListZipContents 列出ZIP文件内容(别名,保持向后兼容)
@@ -532,12 +693,27 @@ func getCurrentTimestamp() time.Time {
return time.Now()
}
// permanentDelete 永久删除路径(目录 RemoveAll / 文件 Remove)并记录删除日志
func (s *FileSystemService) permanentDelete(path string, info os.FileInfo) error {
var deleteErr error
if info.IsDir() {
deleteErr = os.RemoveAll(path)
} else {
deleteErr = os.Remove(path)
}
s.logDelete(path, info.IsDir(), info.Size(), deleteErr)
if deleteErr != nil {
return fmt.Errorf("删除失败: %v", deleteErr)
}
return nil
}
// isInRecycleBin 检查路径是否在回收站中
func isInRecycleBin(path string) bool {
recycleBinPath := filepath.Join(common.GetUserDataDir(), "recycle_bin")
cleanPath := filepath.Clean(path)
cleanBinPath := filepath.Clean(recycleBinPath)
return len(cleanPath) >= len(cleanBinPath) && cleanPath[:len(cleanBinPath)] == cleanBinPath
return strings.HasPrefix(cleanPath, cleanBinPath)
}
// ========== 辅助方法 ==========
+6 -2
View File
@@ -116,11 +116,15 @@ func (w *Watcher) consumeEvents() {
}
debounceTimer = time.AfterFunc(debounceDelay, func() {
// 文件已不存在则跳过(如被删除)
if _, err := os.Stat(target); err != nil {
info, err := os.Stat(target)
if err != nil {
return
}
if w.emitEvent != nil {
w.emitEvent("file-changed", target)
w.emitEvent("file-changed", map[string]any{
"path": target,
"mtime": info.ModTime().UnixMilli(),
})
}
})
+15
View File
@@ -7,7 +7,9 @@ import (
"os"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"u-desk/internal/hotkey"
"u-desk/internal/service"
)
//go:embed all:frontend/dist
@@ -21,6 +23,9 @@ var (
)
func main() {
// 启动时完成上次更新遗留的 exe 替换
service.ReplacePendingFile()
app := NewApp()
wailsApp := application.New(application.Options{
@@ -74,6 +79,7 @@ func main() {
BackgroundColour: application.NewRGB(45, 45, 45),
URL: "/",
Frameless: true,
EnableFileDrop: true,
Windows: application.WindowsWindow{
Theme: application.SystemDefault,
CustomTheme: application.ThemeSettings{
@@ -99,6 +105,15 @@ func main() {
app.SetMainWindow(window)
// 外部文件拖入窗口(OS 资源管理器拖放):把本地路径转发给前端处理
// (本机面板=复制进当前目录,SFTP 面板=上传)
window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
files := event.Context().DroppedFiles()
if len(files) > 0 {
app.emitEvent("external-file-drop", files)
}
})
if err := wailsApp.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}