Files
u-desk/docs/02-架构设计/功能决策记录.md
lxy e7a2c5148e 新增: 文件操作增强与预览扩展
- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
2026-09-15 22:26:12 +08:00

45 KiB
Raw Permalink Blame History

功能决策记录

日常开发中对各功能做的细节取舍(功能粒度,补架构决策之下的实现层)。聚焦「为什么这么定」。 创建: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 → 纯内核 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(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。

维护说明

本文档为初始整理,覆盖 7 个历史会话 + 当前会话,共 ~90 条去重决策。

压缩信号:当前 ~19 功能域、超 300 行。后续触发以下任一条件时压缩:

  • 老 Sprint 决策超 3 个 → 归档到 功能决策记录-归档.md
  • 某功能域 > 10 条 → 单独拆文件
  • 已稳定决策合并精简,只留结论 + 关键原因

更新优先于新增:决策点已存在就改原条,演进用 → 串联,不留重复版本。