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

- 文件剪贴板复制剪切粘贴,重名自动副本
- SFTP下载到本机与外部文件拖入,目录递归
- 传输进度浮动面板,transfer-progress事件链
- Drawio预览,Excel工作线程,冻结看门狗
- cmd与bat自写batch语法高亮,psm1等映射补齐
This commit is contained in:
lxy
2026-09-15 22:26:12 +08:00
parent f1bc5b49f1
commit e7a2c5148e
59 changed files with 3185 additions and 2047 deletions
+664
View File
@@ -0,0 +1,664 @@
# 功能决策记录
> 日常开发中对各功能做的细节取舍(功能粒度,补架构决策之下的实现层)。聚焦「为什么这么定」。
> 创建:2026-06-12 | 维护:随开发追加
>
> 来源:历史会话挖掘 + 当前会话。覆盖 u-desk 0.3.x → 0.4.x(fs-only-v3)开发期。
---
## 启动 / 关闭速度
### ServiceStartup 非关键路径并行化
- **决策**:SQLite + ConfigAPI 前置同步,PdfAPI/FileSystemService/CleanupExpiredCache/CleanupSiteCacheDirs 移入 goroutine 并行。
- **原因/取舍**:前置依赖必须先就绪;非关键子系统串行会阻塞 Wails 窗口加载前端,并行后首屏不受拖累。
- **状态**:✅ 已落地(`app.go` ServiceStartup)
### 审计日志懒初始化 ensureReady
- **决策**:审计日志从启动全量初始化改为首条写入时才 `ensureReady`(开文件 + 起 flush 协程)。
- **原因/取舍**:原 `NewAuditLogger` 启动即建目录/开文件/起协程拖慢启动。懒初始化零启动开销;用 mutex+initialized 标志替代 `sync.Once`,因 sync.Once 消费后不再执行、初始化失败后后续 Log 静默丢失。
- **状态**:✅ 已落地(`audit_log.go`)
### 回收站 metadata 延迟加载
- **决策**:回收站 `metadata.json` 从启动加载改为首次访问时 `ensureLoaded`。
- **原因/取舍**:用户可能整个会话不用回收站;延迟加载减启动 IO 和内存。
- **状态**:✅ 已落地(`recycle_bin.go`)
### startFileServer 同步启动
- **决策**:`startFileServer` 从 `go a.startFileServer()` 改为同步调用。
- **原因/取舍**:该函数只注册 HTTP handler 不阻塞;异步启动反而有 handler 未注册完成时前端已请求的竞态。
- **状态**:✅ 已落地(`app_filesystem.go`)
### loadFromDB 延迟 500ms 自动连接
- **决策**:`loadFromDB` 中系统信息采集 + 自动连接包进 `setTimeout(500)`。
- **原因/取舍**:远程 profile 自动连接并发网络 IO 阻塞首屏;延迟 500ms 让空壳 UI 先渲染。代价:首屏 500ms 内远程不可用,且与 session restore 存在同 profile 双连接竞争(见连接管理域)。
- **状态**:✅ 已落地(`connection-manager.ts`)
### session restore 延迟到 rAF
- **决策**:会话恢复(路径/连接)延迟到 `requestAnimationFrame`。
- **原因/取舍**:`onMounted` 恢复阻塞 Vue 首帧渲染;rAF 让首帧先绘制再恢复。
- **状态**:✅ 已落地(`index.vue` onMounted)
### Markdown / highlight.js 动态懒加载
- **决策**:`marked` + `highlight.js` 从静态 import 改为按需动态 import。
- **原因/取舍**:两库 ~200KB+,全量导入阻塞首屏;文件预览是按需功能不应拖慢所有用户。代价:首次开 MD 有原文闪烁。
- **状态**:✅ 已落地(`markedExtensions.ts`)
### 启动后 3s 延迟静默检查更新
- **决策**:启动后 `setTimeout(3000)` 静默检查更新。
- **原因/取舍**:启动即发网络请求增加首屏时间;延迟 3s 让 UI 先渲染,静默不弹窗。
- **状态**:✅ 已落地(`App.vue`)
### ServiceShutdown errgroup 并行 + 5s 总超时
- **决策**:关闭用 errgroup 并行关 4 个子系统,共享 5s 总超时。
- **原因/取舍**:原串行关闭耗时叠加;并行后总耗时取最慢子系统,5s 兜底防无限挂起。
- **状态**:✅ 已落地(`app.go` ServiceShutdown)
### 热键轮询递增间隔
- **决策**:热键轮询从固定 200ms 改为 50→100→200ms 递增。
- **原因/取舍**:首检快 150ms(50ms 捕获),后续逐步放宽降 CPU。
- **状态**:✅ 已落地(`app.go`)
---
## 文件删除 / 回收站
### 回收站优先策略
- **决策**:删除文件时**先尝试移入回收站**,移动失败才永久删除。
- **原因/取舍**:旧逻辑「先永久删除再移回收站」——文件已删,移回收站必然失败,回收站形同虚设。改为先移后删保证误删可恢复。永久删除仅在跨卷/权限失败时降级兜底。
- **状态**:✅ 已落地(`filesystem/service.go`)
### isInRecycleBin 用 HasPrefix
- **决策**:判断路径是否在回收站内用 `strings.HasPrefix` 而非切片前缀比较。
- **原因/取舍**:旧 `cleanPath[:len(cleanBinPath)]` 当 `len(cleanPath) < len(cleanBinPath)` 时切片越界 panic。HasPrefix 自带长度判断。
- **状态**:✅ 已落地
### 回收站 entries 并发安全
- **决策**:`RecycleBin.entries` 加 `sync.Mutex`,`ListEntries` 返回副本。
- **原因/取舍**:`autoCleanup` 后台协程与用户操作并发读写 entries 存在竞争;返回副本避免外部遍历被 cleanup 修改 panic。
- **状态**:✅ 已落地(`recycle_bin.go`)
### 随机字符串加索引偏移
- **决策**:`generateRandomString` 每个字符对 nanoseed 加索引 `i` 偏移再取模。
- **原因/取舍**:同一次调用内多字符同纳秒生成、seed 相同导致全串重复字符。加索引打破局部相同。非密码学随机,简单偏移足够。
- **状态**:✅ 已落地(`recycle_bin.go`)
### IsFileLocked 去 O_CREAT
- **决策**:`IsFileLocked` 去掉 `O_CREAT` 改纯只读探测。
- **原因/取舍**:`O_CREAT` 检查锁时会意外创建空文件——目标已删时被「复活」成 0 字节误导。纯探测不应有写入语义。
- **状态**:✅ 已落地(`file_lock.go`)
---
## 远程 HTML 站点预览
### 确定性站点缓存目录
- **决策**:远程 HTML 预览用 `common.SiteCacheDir`(SHA-256 哈希 transport+connID+path+size+modTime)生成确定性路径,命中直接返回;SFTP 与 OSS 统一。
- **原因/取舍**:旧 OSS 方案每次 `os.MkdirTemp` 全量重下,二次打开仍慢。确定性哈希让同文件二次打开命中缓存,size+modTime 进哈希源文件变动自动失效。取舍:路径可预测(非随机名),去重收益远大于此。
- **状态**:✅ 已落地(`common/utils.go` + `ossdrv`/`sftp` DownloadSiteForPreview)
### 资源下载范围演进
- **决策**:全量下载 → 仅补充已发现资源所在目录 → `supplementDir` 限制 50 文件 / 单文件 5MB。
- **原因/取舍**:用户指出「网站可能 100G」全量不可行;改为只补充资源所在目录(webpack chunk 等)。最终加 50 文件 + 5MB 上限防失控,SFTP/OSS 统一。
- **状态**:✅ 已落地(`sftp/service.go`、`ossdrv/service.go`)
### OSS 补充分页
- **决策**:OSS `supplementDir` 列目录循环翻页(`IsTruncated` + `NextMarker`),取完或达上限止。
- **原因/取舍**:旧 `MaxKeys:200` 单次调用,目录超 200 文件直接截断漏下载。分页保完整 + 上限防失控。
- **状态**:✅ 已落地(`ossdrv/service.go`)
### 资源下载并发策略
- **决策**:相对路径资源 8 并发、绝对路径资源串行。
- **原因/取舍**:绝对路径 siteRoot 嗅探是共享状态需顺序探测;相对路径无依赖可并行。20 资源从 20×RTT 降到 ~3 轮;8 并发信号量防连接数爆炸。
- **状态**:✅ 已落地(`sftp`/`ossdrv` DownloadSiteForPreview)
### 绝对路径 siteRoot 向上嗅探
- **决策**:绝对路径 `/assets/js/main.js` 的根含义通过从 HTML 所在目录逐级向上尝试下载直到桶根来确定,非简单去 `/`。
- **原因/取舍**:不同 HTML 的 `/` 可能指向不同根(多站点共存);简单相对 HTML 目录在嵌套场景出错(`pages/about.html` 引 `/assets/` 应到桶根而非 pages/assets)。
- **状态**:✅ 已落地(`ossdrv` resolveAbsoluteResourcePath)
### siteRoot 不跨 HTML 共享
- **决策**:每个 HTML 独立嗅探 siteRoot,不跨调用共享。
- **原因/取舍**:同桶多个不同网站的 `/` 可能指不同根;共享会跨站污染。
- **状态**:✅ 已落地
### 路径重写:正则 → JS setter 拦截
- **决策**:用 JS 拦截脚本覆盖 `HTMLScriptElement.src` 等 setter 重写绝对路径,替代 `locationPathRegex` 正则替换。
- **原因/取舍**:正则替换 `location.pathname` 在 inline JS 字符串内破坏引号致 SyntaxError;正则无法处理 webpack 动态 `import()` chunk。setter 拦截覆盖静态 + 动态两种。
- **状态**:✅ 已落地(`asset_handler.go` injectPathInterceptor)
### 拦截脚本 base 用 baseDir
- **决策**:拦截脚本 base 直接用 HTML 文件所在目录,不用 `findSiteRootDir` 嗅探的网站根。
- **原因/取舍**:SFTP 场景 `findSiteRootDir` 返回临时目录根,实际网站根在子目录;用 baseDir 与 `transformHtmlResourcePaths` 解析一致避免层级错位。
- **状态**:✅ 已落地(`asset_handler.go`/`sftp/service.go`)
### CSS MIME 按扩展名显式设置
- **决策**:文件服务器按扩展名设 Content-Type(CSS → `text/css`),不依赖 `http.DetectContentType`。
- **原因/取舍**:`DetectContentType` 对 CSS 返回 `text/plain`,浏览器 strict MIME checking 拒绝应用样式。
- **状态**:✅ 已落地(`asset_handler.go`)
### 资源探测用 try-download
- **决策**:用 try-download 探测资源存在性,而非先 `GetFileInfo`/`Exists`。
- **原因/取舍**:七牛 `GetFileInfo` 可能走签名域名返回 EOF,接口无 `Exists`;直接尝试下载更稳妥,失败即跳过。
- **状态**:✅ 已落地(`ossdrv` tryDownloadResource)
### OSS HTML 预览路由:/proxy → /localfs
- **决策**:OSS 的 HTML 预览从 `/proxy/html-preview`(远程代理路由)改为先下载到临时目录再用 `/localfs/html-preview`。
- **原因/取舍**:本地文件服务器只注册了 `/localfs/html-preview`,`/proxy/` 仅在 agent server;OSS 路径不是本地 FS 路径,代理路由无法处理。
- **状态**:✅ 已落地(`useFilePreview.ts` htmlPreviewUrl)
### 仅 HTML 类型走整站流程
- **决策**:仅 HTML 预览走独立整站下载流程,其他类型(图片/视频/音频)走 `resolveWithTransport()` 单文件。
- **原因/取舍**:其他类型已通过 `downloadForPreview`+`buildLocalUrl` 走 `/localfs/`;只有 HTML 需下载关联资源。
- **状态**:✅ 已落地(`useFilePreview.ts`)
### HTML 资源正则收窄 + srcset 补全
- **决策**:`htmlResRegex` 中 `data` 收窄为 `data-src|data-url`;新增 `srcset` 正则独立提取;排除 `<a href>` 导航链接和非资源扩展名。
- **原因/取舍**:旧 `data=` 误匹配 `data-id` 等无关属性产生无效拉取;srcset 是响应式图片标准属性旧逻辑完全遗漏;`<a href>` 导航链接不应下载。
- **状态**:✅ 已落地(`common/utils.go`、`site_resource.go`)
### 资源提取逻辑 DRY 统一
- **决策**:OSS 和 SFTP 的 `extractResources`/`shouldSkipResource` 重复代码统一到 `filesystem/site_resource.go`。
- **原因/取舍**:两 service 各复制一份相同逻辑违反 DRY;统一净减 114 行。
- **状态**:✅ 已落地
---
## 七牛 OSS
### 域名分类:临时域名优先
- **决策**:下载域名解析临时域名优先(按后缀 `*.qiniudns.com`/`*.clouddn.com`/`*.qbox.me` 分类),自定义域名降级。
- **原因/取舍**:用户桶绑定的自定义域名不可达导致 EOF;临时域名更稳定。后缀 + 前缀双重匹配防误判。
- **状态**:✅ 已落地(`qiniu/client.go` resolveDownloadDomain)
### 域名探测策略演进:TCP → HTTP HEAD → 移除
- **决策**:域名可达性探测从 TCP 连接 → HTTP HEAD → 最终移除,改用签名 URL 直接下载。
- **原因/取舍**:TCP 探测端口开放但 HTTP 返回 EOF 无法发现;HTTP HEAD 对 5xx 无法区分真假可用;签名 URL 可绕防盗链,探测逻辑不再需要。
- **状态**:✅ 已落地(`qiniu/client.go`,probeDomain 已删)
### 签名 URL 绕防盗链
- **决策**:`Download` 内部改用签名 URL 而非裸 HTTP GET;签名格式从 `URL\ndeadline` 修正为 `URL?e=deadline`。
- **原因/取舍**:裸 GET 遇 CDN 防盗链(478)被拒;签名 URL 绕过防盗链;参照 kodo-browser 始终用签名 URL。
- **状态**:✅ 已落地(`qiniu/client.go` GetSignedURL/Download)
### region 从域名提取
- **决策**:从 API 返回的临时域名提取真实 region(`detectRegionFromDomains`),不依赖用户配置的 `config.Region`。
- **原因/取舍**:用户填的 Region 可能错误(填 z0 实际 as0),构造的降级域名不存在(502);从 `u-res-as0.qiniudns.com` 提取才准确。
- **状态**:✅ 已落地
### 跨账号桶返回错误
- **决策**:`resolveDownloadDomain` 在 API 返回空域名列表时直接返回错误,不伪造 `<bucket>-<region>.qiniudns.com`。
- **原因/取舍**:跨账号桶 `GetBucketDomains` 返回空,伪造域名不存在致 403;代码无法修跨账号问题。
- **状态**:✅ 已落地
### 阿里云不适用此逻辑
- **决策**:阿里云 OSS 不加域名探测/降级/签名 URL 逻辑。
- **原因/取舍**:阿里云下载 URL 直接 `<bucket>.<region>.aliyuncs.com`,endpoint 固定官方域名始终可达;用户要求不影响阿里云正确访问。
- **状态**:✅ 已落地
---
## 阿里云 OSS
### 手动签名不用官方 SDK
- **决策**:采用手动签名,不引入阿里云官方 SDK。
- **原因/取舍**:项目已有手动签名实现,避免重量级 SDK 依赖;需自行处理 base64(StdEncoding→URLEncoding)、URL 编码(PathEscape + QueryEscape)。
- **状态**:✅ 已落地(`oss/aliyun/client.go`)
### endpoint 信任用户配置
- **决策**:endpoint 直接传用户配置值,信任用户配置,不自动清空/覆盖。
- **原因/取舍**:旧代码 region 非空时清空 endpoint 让 NewClient 从 region 派生,破坏 VPC/自定义域名场景;改为信任配置,NewClient 在 endpoint 空时才从 region 派生(已有此逻辑)。
- **状态**:✅ 已落地(`ossdrv/service.go`)
### region 全量缓存 + 探测
- **决策**:`Connect()` 时立即缓存所有桶 region 到 `bucketRegions`,未命中时主动 `ListBuckets` 探测。
- **原因/取舍**:app 重启后直接跳上次浏览的桶时缓存无数据,客户端用默认 hangzhou 创建致跨 region 访问失败。
- **状态**:✅ 已落地(`oss/aliyun/`)
### 缓存 key:GetFileInfo 失败直接报错
- **决策**:`DownloadToTemp` 中 GetFileInfo 失败时直接返回错误,不静默降级用空 modTime。
- **原因/取舍**:原失败时用空 modTime 继续生成 key,失败/成功状态产生不同 key 永远命中不了缓存;确定报错让调用方明确处理。
- **状态**:✅ 已落地(`ossdrv/service.go` DownloadToTemp)
### Last-Modified 解析填入 FileInfo
- **决策**:解析阿里云 HEAD 响应的 Last-Modified 头填入 FileInfo(原 `_ =` 丢弃)。
- **原因/取舍**:丢弃致 `LastModified` 永远零值,缓存 key 用零值格式化后不稳定(失败/成功 key 不同),永远命中不了已有缓存。
- **状态**:✅ 已落地(`oss/aliyun/client.go` GetFileInfo)
---
## 缓存管理
### 预览缓存:前端 LRU Map → 后端 SQLite 24h
- **决策**:预览缓存从前端 LRU Map(上限 50)迁移到后端 SQLite,键 (transport, connID, remotePath, fileSize, modTime),24h TTL。
- **原因/取舍**:前端 JS Map 重启即丢失且无过期;后端 SQLite 重启后仍有效,size+modTime 进键源文件变动自动失效。
- **状态**:✅ 已落地(`storage/download_cache.go`)
### 站点缓存清理时机:启动 + 关闭
- **决策**:站点预览缓存在启动和关闭时各清理一次 24h 过期目录,不引入后台定时器。
- **原因/取舍**:站点缓存用户驱动低频,不需常驻 goroutine 轮询;启停各清一次覆盖典型生命周期。同时清遗留旧随机目录 `udesk-site-*`。
- **状态**:✅ 已落地(`download_cache.go` CleanupSiteCacheDirs + `app.go`)
### 内容类型检测缓存 200 条满清空
- **决策**:`contentDetectCache` 加 200 条上限,满时整体 `clear()`(非逐条 LRU)。
- **原因/取舍**:无上限会内存泄漏;选「满即清空」非真 LRU——文件类型检测重复命中率低、清空成本低、实现简单。
- **状态**:✅ 已落地(`useFilePreview.ts`)
### 面包屑目录缓存 200 条
- **决策**:面包屑悬停子目录列表用 `Map<path, result>` 缓存,限 200 条。
- **原因/取舍**:悬停需频繁查询,缓存避免重复网络请求;只缓存目录(悬停只展示可导航子目录)。
- **状态**:✅ 已落地(`PathBreadcrumb.vue` dirCache)
### CodeEditor 滚动位置缓存
- **决策**:滚动位置 LRU 最多 5 份,每份 3min 过期。
- **原因/取舍**:文件切换保留编辑位置提升体验;5 份对应最近 5 文件,3min 过期避免长期占内存。
- **状态**:✅ 已落地(`CodeEditor.vue`)
---
## 错误处理策略
### GetFileInfo 错误:吞掉 → slog.Warn
- **决策**:Go 后端 6 处 `infoMap, _ := GetFileInfo(...)` 改为 `slog.Warn` 可追踪。
- **原因/取舍**:吞错误致前端收到零值数据(name/path/size 全零)既不可用又不可调试。
- **状态**:✅ 已落地(`sftp`/`ossdrv`)
### 前端 useFileOperations 去 catch 纯代理
- **决策**:`useFileOperations` 删除全部 try-catch 改为纯代理。
- **原因/取舍**:原 catch → onError → throw 三重处理致同一错误弹两次消息;改为边界层统一处理,中间层不吞异常。
- **状态**:✅ 已落地(`useFileOperations.ts`)
### 系统 API 边界层合理兜底(回滚)
- **决策**:`system.ts` 4 个系统信息 API(CPU/内存/磁盘/环境变量)先删空 catch 后又加回轻量 `try { ... } catch { return {} }`。
- **原因/取舍**:先按确定性编程删空 catch,但审查发现系统信息属边界展示层——获取失败不应崩 UI,返回 `{}` 是合理降级。与中间层「不吞异常」原则不同,这是边界层合理兜底。
- **状态**:✅ 已落地(`api/system.ts`)
### 目录加载失败提示
- **决策**:`loadDirectory(dir).catch(() => {})` 静默吞改为 `Message.error` 提示。
- **原因/取舍**:fire-and-forget 静默吞错时加载失败用户不知列表为何没更新,无从排障;用户感知优先。
- **状态**:✅ 已落地(`index.vue`)
### 系统信息部分成功降级
- **决策**:`GetLocalSystemInfo` 三项采集全部失败才返回 error,部分成功仍返回已得数据。
- **原因/取舍**:单项失败不应让整个接口不可用(降级优于全失败);全失败才说明采集机制本身有问题。
- **状态**:✅ 已落地(`app_system.go`)
### OSS 重命名单文件失败不中断
- **决策**:批量重命名中单文件删除失败不中断整批,记录并返回最后一个错误。
- **原因/取舍**:单文件失败中断整批时已删无法回滚、未删无法继续体验更差;ossdrv 未引 slog 故用 `fmt.Errorf` 累积。
- **状态**:✅ 已落地(`ossdrv/service.go`)
---
## 连接管理
### _activeId 提前设置 + 失败回退
- **决策**:`_activeId` 在 `buildAndPool()` 之前设置,失败时回退旧值。
- **原因/取舍**:原在 buildAndPool 之后设,`setState('connected')` 回调读到旧 activeProfile 致 transport 类型判断错误。提前设置引入新问题:失败时卡在失败 ID 上 Sidebar 无法重试 → 保存旧值失败恢复。
- **状态**:✅ 已落地(`connection-manager.ts` connect)
### _lastActiveId 去重提前执行
- **决策**:`_lastActiveId` 去重检查在 `_suppressAutoNav` 之前执行。
- **原因/取舍**:原更新在 suppressAutoNav 检查之后,抑制时回调 return 致 `_lastActiveId` 不更新;抑制后 `updateProfile` 异步 notifyChange 触发回调时 `_lastActiveId` 过期误触发导航到 `/` 覆盖正确路径。
- **状态**:✅ 已落地(`index.vue` onStateChange)
### 连接池复用前 isAlive 探测
- **决策**:`connect()` 复用池中连接前先调 `transport.isAlive()`,失效则清理重建。
- **原因/取舍**:原见池中有就返回「已连接」,僵尸连接点击时直接返回已连接实际已断;复用前探测识别并移除失效连接。
- **状态**:✅ 已落地(`connection-manager.ts`)
### isAlive 做成可选方法
- **决策**:`FsTransport` 接口新增可选 `isAlive?(): Promise<boolean>`,SFTP 用 `SftpGetFileInfo('/')`、OSS 用 `OssListDir('')` 探测。
- **原因/取舍**:用轻量只读操作作心跳而非重连,失败 catch 返回 false;可选方法(`?`)保持对其他实现的向后兼容。
- **状态**:✅ 已落地(`transport.ts` + 各 transport 实现)
### 远程 rootPath 始终返回 /
- **决策**:远程连接的 `rootPath` 始终返回 `/`,忽略 localStorage 残留的 Windows 路径。
- **原因/取舍**:原对远程用 filePath 前 4 段算 rootPath 过滤非目录后为空,悬停子目录列表空;远程根目录就是 `/`。远程模式若沿用本地缓存路径致导航失败。
- **状态**:✅ 已落地(`ConnectionIndicator.vue`)
### 连接失败中止打开文件
- **决策**:`handleOpenFavorite` 连接失败时 `return` 中止,不用错误 transport 继续打开。
- **原因/取舍**:原失败后继续导航用错误 transport(local)加载远程路径,OSS 文件被当本地处理(路径变 `C:\Users\...`)。
- **状态**:✅ 已落地(`index.vue` handleOpenFavorite)
### loadFileContent await updatePreviewUrl
- **决策**:`loadFileContent` 中 `updatePreviewUrl` 改为 `await`。
- **原因/取舍**:OSS/SFTP 预览 URL 依赖异步下载到临时文件,不等待会竞态;本地文本不必要但无害,统一 await 简化逻辑。
- **状态**:✅ 已落地(`index.vue`)
### 字段映射 snakeToCamel
- **决策**:前端 `loadFromDB()` 用 `snakeToCamel()` 转换 Go 返回的 snake_case JSON。
- **原因/取舍**:Go JSON 用 snake_case(`access_key`),前端 TS 期望 camelCase(`accessKey`);直接 `...p` 展开不转换致 `profile.secretKey` 为 undefined,OSS 认证失败——收藏图片打不开的根因。
- **状态**:✅ 已落地(`connection-manager.ts`)
### LoadConnectionProfiles 用 json.Marshal
- **决策**:`LoadConnectionProfiles` 用 `json.Marshal/Unmarshal` 替代手动 `map[string]interface{}` 字段转换。
- **原因/取舍**:手动 map 每字段 `float64()` 断言,新增字段必漏;JSON 自动处理类型转换,Go struct 到前端 camelCase 全链路映射更可靠。
- **状态**:✅ 已落地(`app.go`)
### watchFile 仅本地生效
- **决策**:`watchFile`/`unwatchFile` 仅本地模式生效,远程连接静默跳过。
- **原因/取舍**:远程 FS 无 inotify/ReadDirectoryChangesW 机制,强行监听增加复杂度和网络开销。
- **状态**:✅ 已落地(`app.go` + `index.vue`)
### onStateChange 职责拆分(未实施)
- **决策**:`onStateChange` 回调应拆分为「只管连接状态通知,导航权归 UI 层」。
- **原因/取舍**:当前回调同时承担状态跟踪 + 自动导航,`_suppressAutoNav` 全局标志脆弱,任何异步竞争都绕过它(`updateProfile` 的 notifyChange 异步触发是典型)。长期方案:连接管理器不管导航、导航权在 UI 显式调用、updateProfile 不触发 notifyChange。当前用 `_lastActiveId` 去重 + 提前更新作临时修复。
- **状态**:📐 设计未实施(临时修复已落地)
---
## SFTP
### SSH keepalive 30s
- **决策**:为每个 SSH 连接起独立 goroutine,每 30s 发送 `keepalive@openssh.com`。
- **原因/取舍**:原无 keepalive,长时间空闲后 NAT/防火墙丢连接 SFTP 变「僵尸」;30s 防连接被杀。
- **状态**:✅ 已落地(`sftp/client.go`)
### 临时文件去掉 CreateTemp
- **决策**:去掉 `CreateTemp` + 二次 `Create`,直接拼路径。
- **原因/取舍**:原创建两次临时文件(先 CreateTemp 再 Create),多余 IO 增加失败点;直接拼路径减少一次系统调用与竞态窗口。
- **状态**:✅ 已落地(`sftp/service.go`)
### parseProcMeminfo 统一单位
- **决策**:`parseProcMeminfo` 统一先按 kB 运算、最后转 bytes。
- **原因/取舍**:原 kB/bytes 混用易中间步骤算错;统一单位运算只在出口转换减少混淆。
- **状态**:✅ 已落地(`sftp/service.go`)
---
## 文件预览 / 编辑
### 文件读写大小上限
- **决策**:本地读写上限 10MB,SFTP 对齐 10MB;预览模式放宽 50MB。
- **原因/取舍**:编辑模式整文件加载到内存,10MB 平衡可用性和内存安全;预览可流式/只读,50MB 覆盖绝大多数视频/图片/音频。SFTP 与本地一致避免行为不一致。
- **状态**:✅ 已落地(`filesystem/service.go`、`sftp/service.go`)
### 大文件预检 BIG_FILE
- **决策**:预览文件超 `BIG_FILE`(1MB)阈值时不读取内容,显示提示 return。
- **原因/取舍**:基于目录列表 size 预检,避免读大文件致编辑器/前端卡死;阈值常量化便于统一调整。
- **状态**:✅ 已落地(`FileEditorPanel.vue`、`constants.ts`)
### clearContent 重置 isBinaryFile
- **决策**:`clearContent()` 必须重置 `isBinaryFile = false`(之前只清 fileContent/originalContent)。
- **原因/取舍**:模板 `v-if` 链以 `isBinaryFile` 为第一分支;上一个是二进制、切换到图片/视频时 clearContent 不清该标志会永久命中二进制分支、阻断所有图片/视频预览(cascade 失效根因)。
- **状态**:✅ 已落地(`useFileEdit.ts`)
### 二进制/媒体用 isMediaPreviewable 判定
- **决策**:用 `isMediaPreviewable(filename)` 显式判断,替代 `originalContent.value === undefined`。
- **原因/取舍**:`originalContent === undefined` 在首次加载未完成时也 undefined,靠它区分「非文本」语义模糊不可靠。
- **状态**:✅ 已落地(`index.vue`)
### 非文本文件变更 _t 强制刷新
- **决策**:非文本文件(图片/视频)变更后用 URL 追加 `_t=timestamp` 强制刷新。
- **原因/取舍**:方案简洁不侵入文件服务器 URL 生成;只在真正变化时追加,首次走浏览器缓存、tab 切换从缓存恢复原 URL 不产生额外请求。
- **状态**:✅ 已落地(`index.vue` handleFileChanged)
### _t 用真实 mtime 而非 Date.now
- **决策**:`_t` 参数从 `Date.now()` 改为文件实际 mtime(`os.Stat` ModTime)。
- **原因/取舍**:同文件多次保存同内容时 `Date.now()` 让 mtime 不变但时间戳变;真实 mtime 正确反映修改时刻,且一次 os.Stat 同时完成存在性判断 + 取 mtime。事件 payload 从 string 演进为 `{path, mtime}` map,前端兼容新旧格式。
- **状态**:✅ 已落地(`filewatch/watcher.go` + `index.vue`)
### 草稿恢复脏标记(取舍未决)
- **决策**:加载草稿后不同步 originalContent,致文件打开即显示「脏」标记。
- **原因/取舍**:草稿残留(localStorage 24h)在 loadFile 后覆盖 fileContent,originalContent 已设为原始内容,二者不同触发脏标记。设计意图恢复未保存编辑(预期),但「一打开就脏」让人困惑。取舍:保草稿恢复 vs 视觉干净——暂未改。
- **状态**:❓ 未决
### 单文件 Tab 显示 + 空状态
- **决策**:预览 Tab 栏显示条件从 `length > 1` 改为 `> 0`(单文件也显示可关闭);无预览显示 logo + 「U-Desk / 文件管理器」空状态。
- **原因/取舍**:支持「关闭所有预览回到空状态」;空状态低透明度居中不抢焦点。
- **状态**:✅ 已落地(`FileEditorPanel.vue`)
### AsyncCodeEditor 重试上限 2 次
- **决策**:`defineAsyncComponent` 的 onError 限重试 2 次。
- **原因/取舍**:Vue3 加载/渲染失败后进入永久死亡状态不自动重试;无限 `retry()` 在网络持续故障时无限重试。策略演进:无限重试 → 上限 2 次。
- **状态**:✅ 已落地(`FileEditorPanel.vue`)
### BGM 播放失败跳过不删
- **决策**:播放失败自动跳下一首,不删除曲目。
- **原因/取舍**:网络文件可能临时不可用(SFTP 断连、OSS 签名过期),删曲目会永久丢失播放列表;跳过保留重试机会。
- **状态**:✅ 已落地(`BgmBar.vue`)
---
## 文件监听
### 300ms 防抖
- **决策**:fsnotify 文件变化事件后端 300ms 防抖后再 `EmitEvent("file-changed")`。
- **原因/取舍**:编辑器连续保存(VS Code)短时间多次触发 fsnotify;防抖合并多次事件为一次避免前端反复刷新。
- **状态**:✅ 已落地(`filewatch/watcher.go`)
### 仅监听当前预览单文件 + 脏数据保护
- **决策**:仅监听当前正在预览的单个文件;用户有未保存修改时跳过自动刷新。
- **原因/取舍**:用户选「自动刷新 + 通知」+「仅当前预览文件」(拒绝「同时监听目录」开销大);跳过脏数据刷新防丢失未保存编辑。
- **状态**:✅ 已落地(`index.vue` handleFileChanged)
### 防抖回调捕获局部 emit 变量
- **决策**:防抖回调里把 `w.emitEvent` 先捕获到局部变量 `emit` 再调用。
- **原因/取舍**:`debounceTimer` 回调在独立 goroutine 执行,直接访问 `w.emitEvent` 不持锁,与 `Close()` 并发会 nil pointer panic;捕获局部变量即无需持锁。
- **状态**:✅ 已落地(`watcher.go`)
### 监听目录而非单文件
- **决策**:监听文件所在目录而非文件本身。
- **原因/取舍**:部分系统(某些 Linux)不支持直接监听单文件;监听目录兼容性更好,配合防抖过滤目标文件。
- **状态**:✅ 已落地(`watcher.go`)
---
## 自动更新
### os.Exit → Quit 释放 mutex
- **决策**:`os.Exit(0)` 改为 `application.Get().Quit()` 优雅退出。
- **原因/取舍**:Wails v3 启用 SingleInstance 单实例锁;旧流程 bat 先 start 新进程再 os.Exit,新实例启动时旧进程未完全退出,SingleInstance 检测冲突拒绝。Quit 释放 mutex,bat 轮询等旧 PID 退出后再启新实例。
- **状态**:✅ 已落地(`update.go` restartApplication + `main.go`)
### .new 延迟替换
- **决策**:运行中 exe 用 `.new` 延迟替换(安装阶段只复制到 .new,重启阶段 bat 替换)。
- **原因/取舍**:Windows 不允许覆盖正在运行的 exe;旧 `os.Rename` 失败后静默 `return nil` 实际没替换。改为安装复制 .new、重启 bat 先杀进程再 move、启动 `ReplacePendingFile()` 兜底。
- **状态**:✅ 已落地(`update.go`)
### bat 启动方式
- **决策**:bat 用 `exec.Command("cmd", "/C", batFile)` + `SysProcAttr{HideWindow: true}`,不经 `cmd /C start`。
- **原因/取舍**:`-H windowsgui` 模式下 `cmd /C start` 启动的 bat 可能被 Windows 安全策略拦截;直接 exec.Command + CREATE_NO_WINDOW 避免弹窗且不触发拦截。
- **状态**:✅ 已落地(`update.go`)
### bat 写失败降级重启
- **决策**:bat 写入失败时降级为 `os.Exit(0)` + 直接 `exec.Command("cmd", "/C", "start", "", execPath).Start()`。
- **原因/取舍**:bat 依赖临时目录可写,权限不足/磁盘满则无法完成标准流程;降级牺牲 .new 替换但保证能退出并尝试重启。
- **状态**:✅ 已落地(`update.go` fallbackRestart)
### check_url 空值才用默认
- **决策**:`check_url` 仅在为空时用默认值,不覆盖已存在的旧 URL。
- **原因/取舍**:尊重用户已配置值 vs 自动修正旧失效 URL(如 `img.1216.top`)——可能导致旧用户无法检测更新。迁移逻辑暂未加。
- **状态**:🚧 待实测(当前配置已修,迁移逻辑未加)
### 更新检查 URL 配置化
- **决策**:检查更新 URL 从硬编码改为读 `update_config.json`,空值 fallback 默认。
- **原因/取舍**:硬编码无法按部署环境切换;完全无默认值会致配置缺失时检查失效,故保留 fallback。
- **状态**:✅ 已落地(`app.go`、`update_config.go`)
---
## 代码结构 / DRY
### 三套 FileItem 类型统一
- **决策**:三套 FileItem 定义统一为 `types/file-system.ts` 的一个,删除 `api/types.ts` 的 `File` interface。
- **原因/取舍**:原 transport.ts 用 `is_dir`/`mod_time`(蛇形)、file-system.ts 用 `isDir`/`modified_time`(驼峰)、api/types.ts 用 `isDir`/`modified`(又不同),加两套 transformFile 易错;统一为驼峰,transport 层做一次蛇→驼峰转换。
- **状态**:✅ 已落地(`types/file-system.ts`、`api/transport.ts`、`api/types.ts`)
### CopyFile 失败清理 + 权限保留
- **决策**:`common.CopyFile` 加失败清理(os.Remove dst)+ 保留源文件权限(os.Chmod)。
- **原因/取舍**:从 download_cache/recycle_bin/update 三处提取共享 CopyFile 时,原各处有失败清理和权限保留,合并不能丢;recycle_bin 依赖文件权限不变。
- **状态**:✅ 已落地(`common/utils.go`)
### 全局单例死代码删除
- **决策**:删除 `globalLogger`/`globalRecycleBin`/`LogOperation`/`LogError`/`GetGlobalLogger`/`GetStackTrace` 等全局单例,而非加 sync.Once 保护。
- **原因/取舍**:这些只有声明和 getter 从未初始化或调用,属死代码;直接删比加保护更干净。
- **状态**:✅ 已落地(`logger.go`、`recycle_bin.go`)
### App 拆分粒度:11 文件不再动
- **决策**:11 个 `app_*.go` 按功能域拆分,不再进一步合并或细分。
- **原因/取舍**:命名清晰符合 Go 多文件包惯例;继续拆分或合并都增加认知负担,「保持现状」是当前最佳点。
- **状态**:✅ 已落地(决定不动)
### Windows 专用代码 build tag 隔离
- **决策**:`registry`/`w32` 等 Windows 专用包函数移到 `app_windows.go`,跨平台函数留 `app.go`。
- **原因/取舍**:Windows 专用包直接 import 致跨平台编译失败;build tag 文件拆分而非运行时 GOOS 判断,是 Go 惯例且零运行时开销。
- **状态**:✅ 已落地(`app.go`/`app_windows.go`)
### watchFile 接口统一
- **决策**:`watchFile`/`unwatchFile` 统一走 `FsTransport` 接口。
- **原因/取舍**:原各 transport 各自实现接口不统一;收敛后共用同一套签名和调用模式。
- **状态**:✅ 已落地(`transport.ts` + 4 个 transport 实现)
### 旧版遗留清理
- **决策**:删除 3 个旧版零引用 composable JS 文件(~1000 行)+ Wails v2 遗留 wailsjs 绑定。
- **原因/取舍**:旧版 JS/wailsjs 文件无 import 引用属历史遗留,留着混淆开发。
- **状态**:✅ 已落地(`composables/*.js`、`wailsjs/wailsjs/`)
---
## 窗口外观
### DWMWA_COLOR_NONE 抑制白边
- **决策**:用 `DWMWA_COLOR_NONE`(0xFFFFFFFE)作四主题状态 BorderColour,移除 `DisableFramelessWindowDecorations`。
- **原因/取舍**:白边根因是 `ExtendFrameIntoClientArea` 保留 DWM 框架、暗色模式未告知 DWM 用暗色渲染;试过 `#2D2D2D`、移除 CustomTheme、`DisableFramelessWindowDecorations:true` 均无效;`DWMWA_COLOR_NONE` 是 Win11 官方抑制边框 API。Win10 不支持降级无装饰(取舍:牺牲 Win10 Aero 阴影/圆角换 Win11 干净边缘)。
- **状态**:✅ 已落地(`main.go`)
### 背景色固定 #2D2D2D
- **决策**:窗口 `BackgroundColour` 固定 `#2D2D2D`(暗色安全)。
- **原因/取舍**:此色仅 CSS 未渲染前的加载瞬间可见;暗色模式不露白边,亮色模式被前端 CSS 覆盖;不随主题切换避免 Go 端复杂度。
- **状态**:✅ 已落地(`main.go`)
---
## UI 交互
### 设置面板 Teleport to body
- **决策**:弹出面板从 sidebar 内 `position: absolute` 改为 `<Teleport to="body">` + `position: fixed`。
- **原因/取舍**:旧方案被 `overflow: hidden` 容器裁切,设置按钮/更多菜单弹出被截断;Teleport 彻底解决 z-index 和 overflow:hidden 裁剪。
- **状态**:✅ 已落地(`Sidebar.vue`)
### 服务器区块 max-height: none
- **决策**:服务器区块 `max-height: none` 不限高;收藏夹区块保留 `max-height` 折叠动画。
- **原因/取舍**:`max-height: 500px` 在服务器列表 >15 行时裁剪;服务器区块在 `flex-shrink: 0` 内高度由内容决定即可。折叠动画只适合可变高度区块。
- **状态**:✅ 已落地(`Sidebar.vue`)
### 收藏夹默认折叠
- **决策**:收藏夹侧栏 `favCollapsed` 初始值改 `true`(默认收起)。
- **原因/取舍**:保持界面简洁,用户按需展开。
- **状态**:✅ 已落地
### 折叠动画 max-height:0
- **决策**:折叠动画从 `grid-template-rows: 0fr` 改为 `max-height: 0` + `overflow: hidden`。
- **原因/取舍**:`0fr` 方案折叠后仍有残留高度;帮助区块不再 `margin-top: auto`,依赖收藏区块 `flex-grow` 自然推到底。
- **状态**:✅ 已落地(`Sidebar.vue`)
### toggleSettings 用 closest
- **决策**:`toggleSettings` 用 `closest('.settings-btn')` 替代 `e.currentTarget`。
- **原因/取舍**:Vue 3 内联事件 `e.currentTarget` 有 null 风险(Vue 内部包装),原 `|| e.target` 兜底有类型安全隐患(e.target 可能是子文本节点);closest 向上查找更可靠。
- **状态**:✅ 已落地(`Sidebar.vue`)
### 面包屑下拉过滤 + 200 + 缓存
- **决策**:面包屑下拉只获取目录(跳过文件),`slice(0, 200)` 限制,`Map<path, result>` 跨导航缓存。
- **原因/取舍**:`/tmp` 等大目录返回过多卡顿;用户只能导航到目录,过滤文件减 payload;200 上限防极端,Map 缓存避免悬停同路径重复请求。
- **状态**:✅ 已落地(`PathBreadcrumb.vue`)
### 拖拽 iframe 遮罩
- **决策**:resize 拖拽时 `mousedown` 即激活全屏遮罩,并缓存容器尺寸。
- **原因/取舍**:iframe(PDF/图片预览)捕获鼠标事件致拖拽中断;每帧 `getBoundingClientRect()` 强制回流;遮罩从 rAF 回调改 mousedown 立即触发,避免鼠标未移动时遮罩不出现。
- **状态**:✅ 已落地(`resize.ts`)
### 系统信息 15s 自动刷新
- **决策**:系统信息面板 `setInterval` 每 15s 刷新。
- **原因/取舍**:固定周期足够反映服务器状态变化且不致过大开销。
- **状态**:✅ 已落地(连接管理器 refreshTimer)
### F11 预览全屏:OS 级 → 窗口内全屏 [2026-06-13]
- **决策**:文件预览区 F11 全屏从浏览器 Fullscreen API(OS 级,占整个屏幕)改为 CSS class 驱动的窗口内全屏(`position: fixed; inset: 0`),只盖 u-desk 应用窗口视口。
- **原因/取舍**:OS 全屏独占整个显示器、遮任务栏、退出后窗口尺寸/位置可能错乱,对桌面文件管理器过重;窗口内全屏只占应用视口,保留任务栏与多窗口切换,符合「应用内聚焦预览」预期。代价:失去浏览器原生 ESC 退出 → 自行补 ESC 监听退出 + F11 再按切换。实现:`isFullscreen` ref 驱动 `.is-fullscreen` class,移除 `requestFullscreen`/`exitFullscreen`/`fullscreenchange` 监听器及 `panelRef`,`:fullscreen` 选择器全部改 `.is-fullscreen`。
- **状态**:✅ 已落地(`FileEditorPanel.vue`,wails3 build 通过 2026-06-13)
---
## 快捷键 / 热键
### RegisterGlobalHotkey 单 defer Unlock
- **决策**:合并为单一 `defer mu.Unlock()`,消除锁间隙竞态。
- **原因/取舍**:原 nil 检查和赋值不在同一把锁内(Lock→检查→Unlock→注册→Lock→赋值→Unlock),两 goroutine 可同时通过 nil 检查致重复注册。
- **状态**:✅ 已落地(`app.go` RegisterGlobalHotkey)
### 盘符快捷键先回本地
- **决策**:`Ctrl+Shift+C~H` 盘符快捷键切换前先回本地 transport。
- **原因/取舍**:远程模式下按盘符快捷键会报错(C:~H: 盘符操作不适用远程 transport);先切回本地再执行盘符切换。
- **状态**:✅ 已落地(`index.vue`)
---
## DevTools
### build tag 控制
- **决策**:用 Go build tag(`!production`/`production`)控制 DevTools 开关。
- **原因/取舍**:`wails dev` 和 `wails3 build` 默认都带 devtools tag 致生产包弹 Inspector;曾考虑运行时 `Env.Info().Debug` 判断但 Run() 前不可用;build tag 编译期控制零运行时开销更可靠。
- **状态**:✅ 已落地(`devtools.go`/`devtools_prod.go`)
---
## 桌面应用体验
### UniqueID 反向域名
- **决策**:UniqueID 用 `top.1216.udesk`,不用 `com.1216.top.udesk`。
- **原因/取舍**:用户域名 `1216.top`,按反向域名规范应为 `top.1216.udesk`;`com.` 开头系统层面也能工作但用户明确要求严格反向域名规范。
- **状态**:✅ 已落地(`main.go` SingleInstance)
### 全局禁用浏览器右键菜单
- **决策**:`App.vue` onMounted 添加 `contextmenu` 事件拦截,全局禁用浏览器默认右键菜单。
- **原因/取舍**:桌面应用露浏览器默认右键菜单破坏原生体验;CSS `--default-contextmenu: hide` 在 WebView2 不一定生效,JS `preventDefault` 更可靠。
- **状态**:✅ 已落地(`App.vue`)
---
## 版本 / 依赖
### Wails 暂缓升级
- **决策**:暂不升级(停留 alpha.80,最新 alpha.86)。
- **原因/取舍**:升级主要带来 Windows 托盘 GDI 内存泄漏修复、WebView2 焦点空指针修复,但对本项目影响不大;而改进清单依赖的原生对话框/窗口事件 API 新版仍未提供,升级收益不足以抵消回归风险。
- **状态**:🚧 待实测(暂缓非永久否决)
---
## 文档 / 流程
### CHANGELOG 用户视角
- **决策**:CHANGELOG.md 只写用户能感知的变化,不写内部实现细节。
- **原因/取舍**:CHANGELOG 面向用户非开发者;内部重构(结构化日志、并发安全、依赖升级)只作概括条目,详细技术改动放 CHANGELOG.internal.md;新功能不应归入「修复」。
- **状态**:✅ 已落地
### 调试工具验证后统一清理
- **决策**:调试日志和临时测试工具在功能验证通过后再统一清理,不随修复合并。
- **原因/取舍**:OSS 连接问题未完全验证前用户要求「先不清理」;调试日志帮助定位了 `updateProfile` 异步 notifyChange 误触发导航的 bug。最终验证后统一清理 cmd 调试工具、DebugLog 绑定、前端调试日志。
- **状态**:✅ 已落地
---
## 已知上游依赖问题
### Chromium 149 中文 IME 标点按两次丢失 [2026-06-13]
- **决策**:确认是 Chromium 149 上游 bug,**不在应用层修**;等 Chrome/Edge 151 推送更新 WebView2 Runtime 解,或临时切 IME 英文标点绕过。
- **原因/取舍**:症状——中文输入法下编辑器(CodeMirror)直接打全角标点(`。,;:`)每隔一次丢失,需按两次。排查已排除:u-desk 全局快捷键(`index.vue` handleKeyDown / `FileEditorPanel` onKeyDown 全部需 Ctrl/Meta/Alt/Shift 或功能键,裸标点不命中不拦截)、CodeMirror 配置(6.41.1,无 closeBrigits/无标点 keymap 绑定)、`watch modelValue` 回写(字符串相等不重派发)。CodeMirror 作者 marijn 用裸 `<div contenteditable>` 也在 Chrome 149 stable 复现,提 Chromium [#521205128](https://issues.chromium.org/issues/521205128) → 纯内核 bug,非本项目代码。**应用层无法修**:丢失的那次按键在 Chromium 内部被吞,不触发任何 JS 事件(无 keydown/beforeinput/compositionend),拦不到。**不在 CodeMirror 加 inputHandler/keydown hack**:作者本人只提 issue 未给 workaround,硬 hack 引新问题。当前环境:WebView2 Runtime = Edge = 149.0.4022.62(Chromium 149,2026-06-02 stable),通道覆盖未设(stable 正常)。**根因**(2026-06-12 上游确认):Chromium 的 TSFTextStore 在 IME 活动时仍跑 autocorrect 检测,吞掉标点;修复 CL [7917332](https://chromium-review.googlesource.com/c/chromium/src/+/7917332)(Pranav Modi/MS)标题 "Skip autocorrect detection when IME is active in TSFTextStore"。
- **状态**:🚧 上游已修复待发布(2026-06-12 在 Chrome 151.0.7888.0 / Win11 验证有效,已加 verified 标签;接受现状不应用层修)
- **📋 待办**:修复落 **Chrome 151**(非 150)。等 Edge/WebView2 Runtime 升到 151 stable(Chrome 150 stable ~2026-06-29,151 stable 约 2026-07 下旬;急用可装 Edge Beta 151 提前验)→ 更新后回归验证编辑器中文标点。参考 [CodeMirror 论坛 #9741](https://discuss.codemirror.net/t/chinese-ime-punctuation-input-loses-every-other-keypress-requires-2-presses-per-character/9741)。
---
## 维护说明
本文档为初始整理,覆盖 7 个历史会话 + 当前会话,共 ~90 条去重决策。
**压缩信号**:当前 ~19 功能域、超 300 行。后续触发以下任一条件时压缩:
- 老 Sprint 决策超 3 个 → 归档到 `功能决策记录-归档.md`
- 某功能域 > 10 条 → 单独拆文件
- 已稳定决策合并精简,只留结论 + 关键原因
**更新优先于新增**:决策点已存在就改原条,演进用 `→` 串联,不留重复版本。