diff --git a/app_filesystem.go b/app_filesystem.go index 0d96f5a..8297699 100644 --- a/app_filesystem.go +++ b/app_filesystem.go @@ -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("文件系统模块初始化完成") } diff --git a/app_pdf.go b/app_pdf.go index 22368bd..c007e65 100644 --- a/app_pdf.go +++ b/app_pdf.go @@ -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() } diff --git a/docs/02-架构设计/功能决策记录.md b/docs/02-架构设计/功能决策记录.md new file mode 100644 index 0000000..37d96dc --- /dev/null +++ b/docs/02-架构设计/功能决策记录.md @@ -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` 正则独立提取;排除 `` 导航链接和非资源扩展名。 +- **原因/取舍**:旧 `data=` 误匹配 `data-id` 等无关属性产生无效拉取;srcset 是响应式图片标准属性旧逻辑完全遗漏;`` 导航链接不应下载。 +- **状态**:✅ 已落地(`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 返回空域名列表时直接返回错误,不伪造 `-.qiniudns.com`。 +- **原因/取舍**:跨账号桶 `GetBucketDomains` 返回空,伪造域名不存在致 403;代码无法修跨账号问题。 +- **状态**:✅ 已落地 + +### 阿里云不适用此逻辑 +- **决策**:阿里云 OSS 不加域名探测/降级/签名 URL 逻辑。 +- **原因/取舍**:阿里云下载 URL 直接 `..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` 缓存,限 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`,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` 改为 `` + `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` 跨导航缓存。 +- **原因/取舍**:`/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 用裸 `
` 也在 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 条 → 单独拆文件 +- 已稳定决策合并精简,只留结论 + 关键原因 + +**更新优先于新增**:决策点已存在就改原条,演进用 `→` 串联,不留重复版本。 diff --git a/frontend/bindings/u-desk/app.ts b/frontend/bindings/u-desk/app.ts index d7e29b6..f960a49 100644 --- a/frontend/bindings/u-desk/app.ts +++ b/frontend/bindings/u-desk/app.ts @@ -53,6 +53,13 @@ export function ClearCache(): $CancellablePromise { return $Call.ByID(1413834504); } +/** + * CopyPath 复制文件或目录(递归),通过 transfer-progress 事件推送进度 + */ +export function CopyPath(src: string, dst: string): $CancellablePromise { + return $Call.ByID(1297703531, src, dst); +} + /** * CreateDir 创建目录 */ @@ -116,6 +123,16 @@ export function EmptyRecycleBin(): $CancellablePromise { 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 { + 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 { + 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 { + 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 { + return $Call.ByID(3304594335, connID, localPath, remoteDir); +} + /** * SftpWriteBase64File SFTP 写入 base64 编码的二进制文件(粘贴图片等) */ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e9de754..1cd434c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/api/connection-manager.ts b/frontend/src/api/connection-manager.ts index 584ad64..be832e9 100644 --- a/frontend/src/api/connection-manager.ts +++ b/frontend/src/api/connection-manager.ts @@ -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() private _sysInfoChangeCallbacks: ((profileId: string, info: SystemInfo) => void)[] = [] private _sysInfoCache = new Map() @@ -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 { + 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 | undefined + const alive = await Promise.race([ + t.isAlive().catch(() => false), + new Promise(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 | 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): Promise { + // 防重复: 同名且同地址的配置已存在时直接返回既有条目(表单双击/重试等场景) + 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 { 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): Record { const result: Record = {} diff --git a/frontend/src/api/http-transport.ts b/frontend/src/api/http-transport.ts index 91af236..4bda45e 100644 --- a/frontend/src/api/http-transport.ts +++ b/frontend/src/api/http-transport.ts @@ -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('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath }) } + // ====== 复制/移动/传输(不支持)====== + + async copyPath(_src: string, _dst: string): Promise { + throw new Error('远程模式暂不支持复制/粘贴') + } + + async movePath(_src: string, _dst: string): Promise { + throw new Error('远程模式暂不支持剪切/粘贴') + } + + async downloadToFile(_path: string, _localDstDir: string): Promise { + throw new Error('远程模式暂不支持下载到本机') + } + + async uploadFromFile(_localPath: string, _remoteDir: string): Promise { + throw new Error('远程模式暂不支持上传') + } + async listZipContents(zipPath: string): Promise { // Wave 3 实现 throw new Error('ZIP 操作在远程模式暂未实现') } + async watchFile(_path: string): Promise { /* 远程 HTTP 模式不支持文件监听 */ } + async unwatchFile(): Promise { /* 远程 HTTP 模式不支持文件监听 */ } + async extractFileFromZip(_zipPath: string, _filePath: string): Promise { throw new Error('ZIP 操作在远程模式暂未实现') } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index dd5a947..76ab290 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -2,5 +2,4 @@ * API 统一导出 */ -export * from './types' export * from './system' diff --git a/frontend/src/api/oss-transport.ts b/frontend/src/api/oss-transport.ts index 74d733d..77c2db4 100644 --- a/frontend/src/api/oss-transport.ts +++ b/frontend/src/api/oss-transport.ts @@ -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 { + return probeAlive(this.connID, id => OssListDir(id, '')) + } + get sessionId(): string | null { return this.connID } async disconnect(): Promise { @@ -84,13 +81,11 @@ export class OssTransport implements FsTransport { // ====== 文件操作 ====== async createFile(dirPath: string, filename: string): Promise { - const fullPath = dirPath.replace(/\/$/, '') + '/' + filename - return OssCreateFile(this.requireConn(), fullPath) + return OssCreateFile(this.requireConn(), joinPath(dirPath, filename)) } async createDir(parentPath: string, dirname: string): Promise { - const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname - return OssCreateDir(this.requireConn(), fullPath) + return OssCreateDir(this.requireConn(), joinPath(parentPath, dirname)) } async deletePath(path: string): Promise { @@ -105,6 +100,24 @@ export class OssTransport implements FsTransport { }) } + // ====== 复制/移动/传输(不支持)====== + + async copyPath(_src: string, _dst: string): Promise { + throw new Error('OSS 面板暂不支持复制/粘贴') + } + + async movePath(_src: string, _dst: string): Promise { + throw new Error('OSS 面板暂不支持剪切/粘贴') + } + + async downloadToFile(_path: string, _localDstDir: string): Promise { + throw new Error('OSS 下载到本机暂未实现') + } + + async uploadFromFile(_localPath: string, _remoteDir: string): Promise { + throw new Error('OSS 上传暂未实现') + } + // ====== ZIP 操作(不支持)====== async listZipContents(_zipPath: string): Promise { @@ -177,4 +190,7 @@ export class OssTransport implements FsTransport { async getSignedUrl(key: string): Promise { return OssGetSignedURL(this.requireConn(), key) } + + async watchFile(_path: string): Promise { /* OSS 模式不支持文件监听 */ } + async unwatchFile(): Promise { /* OSS 模式不支持文件监听 */ } } diff --git a/frontend/src/api/sftp-transport.ts b/frontend/src/api/sftp-transport.ts index 67ebf35..1fead36 100644 --- a/frontend/src/api/sftp-transport.ts +++ b/frontend/src/api/sftp-transport.ts @@ -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 { + return probeAlive(this.connID, id => SftpGetFileInfo(id, '/')) + } + get sessionId(): string | null { return this.connID } async disconnect(): Promise { @@ -90,13 +88,11 @@ export class SftpTransport implements FsTransport { // ====== 文件操作 ====== async createFile(dirPath: string, filename: string): Promise { - const fullPath = dirPath.replace(/\/$/, '') + '/' + filename - return SftpCreateFile(this.requireConn(), fullPath) + return SftpCreateFile(this.requireConn(), joinPath(dirPath, filename)) } async createDir(parentPath: string, dirname: string): Promise { - const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname - return SftpCreateDir(this.requireConn(), fullPath) + return SftpCreateDir(this.requireConn(), joinPath(parentPath, dirname)) } async deletePath(path: string): Promise { @@ -111,6 +107,28 @@ export class SftpTransport implements FsTransport { }) } + // ====== 复制/移动(粘贴仅本地面板支持)====== + + async copyPath(_src: string, _dst: string): Promise { + throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传') + } + + async movePath(_src: string, _dst: string): Promise { + throw new Error('SFTP 面板暂不支持粘贴,可使用拖拽上传') + } + + // ====== 传输 ====== + + /** 下载远程文件/目录到用户指定目录(流式无上限,重名自动副本),返回本地落地路径 */ + async downloadToFile(remotePath: string, localDstDir: string): Promise { + return SftpDownloadToFile(this.requireConn(), remotePath, localDstDir) + } + + /** 上传本地文件/目录到远程目录(流式,目录递归) */ + async uploadFromFile(localPath: string, remoteDir: string): Promise { + await SftpUploadFromFile(this.requireConn(), localPath, remoteDir) + } + // ====== ZIP 操作(不支持)====== async listZipContents(_zipPath: string): Promise { @@ -179,4 +197,7 @@ export class SftpTransport implements FsTransport { async downloadSiteForPreview(remotePath: string): Promise { return SftpDownloadSiteForPreview(this.requireConn(), remotePath) } + + async watchFile(_path: string): Promise { /* SFTP 模式不支持文件监听 */ } + async unwatchFile(): Promise { /* SFTP 模式不支持文件监听 */ } } diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 38933c2..19925b4 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -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 { - return transformFileList(await t().listDir(path)) +export async function listDir(path: string): Promise { + return toFileItemList(await t().listDir(path)) } export async function readFile(path: string): Promise { @@ -69,12 +68,31 @@ export async function renamePath(oldPath: string, newPath: string): Promise return t().renamePath(oldPath, String(newPath)) } -export async function getEnvVars(): Promise> { - try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} } +// 复制/移动(本机面板粘贴;进度经 transfer-progress 事件推送) +export async function copyPath(src: string, dst: string): Promise { + await t().copyPath(src, dst) } -export async function listZipContents(zipPath: string): Promise { - return transformFileList(await t().listZipContents(zipPath)) +export async function movePath(src: string, dst: string): Promise { + await t().movePath(src, dst) +} + +// 下载远程文件/目录到本机指定目录(SFTP),返回本地落地路径 +export async function downloadToFile(path: string, localDstDir: string): Promise { + return t().downloadToFile(path, localDstDir) +} + +// 上传本地文件/目录到远程目录(SFTP) +export async function uploadFromFile(localPath: string, remoteDir: string): Promise { + await t().uploadFromFile(localPath, remoteDir) +} + +export async function getEnvVars(): Promise> { + try { return (await window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} } +} + +export async function listZipContents(zipPath: string): Promise { + return toFileItemList(await t().listZipContents(zipPath)) } export async function extractFileFromZip(zipPath: string, filePath: string): Promise { @@ -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 { - return transformFile(await t().getZipFileInfo(zipPath, filePath)) +export async function getZipFileInfo(zipPath: string, filePath: string): Promise { + return toFileItem(await t().getZipFileInfo(zipPath, filePath)) } export async function openPath(path: string): Promise { @@ -109,19 +127,16 @@ export async function getCommonPaths() { return t().getCommonPaths() } -// 文件监听(仅本地模式,直接调用 Wails 绑定) +// 打开系统目录选择器(取消返回空串) +export async function pickDirectory(): Promise { + return PickDirectory() +} + +// 文件监听(通过 Transport 层,本地模式走 Wails IPC,远程模式空操作) export async function watchFile(path: string): Promise { - 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 { - if (connectionManager.isRemote()) return - try { - const { UnwatchFile } = await import('../wailsjs/v3-bindings/u-desk/app') - await UnwatchFile() - } catch { /* 忽略 */ } + await t().unwatchFile() } diff --git a/frontend/src/api/transport.ts b/frontend/src/api/transport.ts index cf9108d..db87ef3 100644 --- a/frontend/src/api/transport.ts +++ b/frontend/src/api/transport.ts @@ -34,6 +34,9 @@ export type DetectTypeResult = { } export interface FsTransport { + /** 检测连接是否仍然存活 */ + isAlive?(): Promise + // 文件列表与信息 listDir(path: string): Promise getFileInfo(path: string): Promise> @@ -49,6 +52,14 @@ export interface FsTransport { deletePath(path: string): Promise renamePath(oldPath: string, newPath: string): Promise + // 复制/移动(本机面板粘贴;SFTP/OSS/HTTP 暂不支持) + copyPath(src: string, dst: string): Promise + movePath(src: string, dst: string): Promise + + // 传输(SFTP 下载到本机 / 本地文件上传到 SFTP;进度经 transfer-progress 事件推送) + downloadToFile(path: string, localDstDir: string): Promise + uploadFromFile(localPath: string, remoteDir: string): Promise + // ZIP 操作(Wave 3) listZipContents(zipPath: string): Promise extractFileFromZip(zipPath: string, filePath: string): Promise @@ -76,4 +87,42 @@ export interface FsTransport { downloadSiteForPreview?(path: string): Promise /** 获取预签名 URL(仅 OSS 实现,其他返回空串) */ getSignedUrl?(path: string): Promise + + // 文件监听(仅本地 Wails 模式有效) + watchFile(path: string): Promise + unwatchFile(): Promise +} + +// ========== 共享工具函数 ========== + +// 说明:后端蛇形 → 前端驼峰(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, +): Promise { + if (!connId) return false + try { + await probe(connId) + return true + } catch { + return false + } } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts deleted file mode 100644 index bd64a8a..0000000 --- a/frontend/src/api/types.ts +++ /dev/null @@ -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 -} diff --git a/frontend/src/api/wails-transport.ts b/frontend/src/api/wails-transport.ts index 5807e94..b68f653 100644 --- a/frontend/src/api/wails-transport.ts +++ b/frontend/src/api/wails-transport.ts @@ -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 { + // 蛇形→驼峰转换统一由 system.ts 的 toFileItemList 完成,此处仅类型适配 return transformFileList(await ListDir(path)) } @@ -43,13 +38,11 @@ export class WailsTransport implements FsTransport { } async createFile(dirPath: string, filename: string): Promise { - const fullPath = dirPath.replace(/\/$/, '') + '/' + filename - return CreateFile(fullPath) + return CreateFile(joinPath(dirPath, filename)) } async createDir(parentPath: string, dirname: string): Promise { - const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname - return CreateDir(fullPath) + return CreateDir(joinPath(parentPath, dirname)) } async deletePath(path: string): Promise { @@ -60,6 +53,22 @@ export class WailsTransport implements FsTransport { return RenamePath({ oldPath: String(oldPath), newPath: String(newPath) }) } + async copyPath(src: string, dst: string): Promise { + await CopyPath(String(src), String(dst)) + } + + async movePath(src: string, dst: string): Promise { + await MovePath(String(src), String(dst)) + } + + async downloadToFile(path: string, _localDstDir: string): Promise { + return path // 本地文件无需下载 + } + + async uploadFromFile(_localPath: string, _remoteDir: string): Promise { + // 本地面板无上传语义(外部文件拖入走 copyPath) + } + async listZipContents(zipPath: string): Promise { return transformFileList(await ListZipContents(zipPath)) } @@ -73,7 +82,7 @@ export class WailsTransport implements FsTransport { } async getZipFileInfo(zipPath: string, filePath: string): Promise { - return transform(await GetZipFileInfo(zipPath, filePath)) + return transformFile(await GetZipFileInfo(zipPath, filePath)) } async openPath(path: string): Promise { @@ -120,4 +129,12 @@ export class WailsTransport implements FsTransport { async downloadForPreview(path: string): Promise { return path } + + async watchFile(path: string): Promise { + await WatchFile(path) + } + + async unwatchFile(): Promise { + await UnwatchFile() + } } diff --git a/frontend/src/components/FileSystem/components/ConnectionDialog.vue b/frontend/src/components/FileSystem/components/ConnectionDialog.vue index ec6f078..d8e767b 100644 --- a/frontend/src/components/FileSystem/components/ConnectionDialog.vue +++ b/frontend/src/components/FileSystem/components/ConnectionDialog.vue @@ -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 = { 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 { @@ -166,8 +169,10 @@ async function handleOk(): Promise { 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 diff --git a/frontend/src/components/FileSystem/components/ContextMenu.vue b/frontend/src/components/FileSystem/components/ContextMenu.vue index 9d1cd48..80ca4a3 100644 --- a/frontend/src/components/FileSystem/components/ContextMenu.vue +++ b/frontend/src/components/FileSystem/components/ContextMenu.vue @@ -18,6 +18,16 @@ 新建文件夹 Ctrl+Shift+N
+
+ 📋 + 粘贴 + Ctrl+V +
@@ -34,13 +44,35 @@
🚀 系统默认程序打开
+ +
✏️ @@ -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') diff --git a/frontend/src/components/FileSystem/components/FileEditor/BgmBar.vue b/frontend/src/components/FileSystem/components/FileEditor/BgmBar.vue index 5f9c456..11f7775 100644 --- a/frontend/src/components/FileSystem/components/FileEditor/BgmBar.vue +++ b/frontend/src/components/FileSystem/components/FileEditor/BgmBar.vue @@ -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 ({ 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 延迟解析),并选中上次播放的曲目 diff --git a/frontend/src/components/FileSystem/components/FileEditorPanel.vue b/frontend/src/components/FileSystem/components/FileEditorPanel.vue index 869f869..2afbfc2 100644 --- a/frontend/src/components/FileSystem/components/FileEditorPanel.vue +++ b/frontend/src/components/FileSystem/components/FileEditorPanel.vue @@ -1,10 +1,11 @@