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

665 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 功能决策记录
> 日常开发中对各功能做的细节取舍(功能粒度,补架构决策之下的实现层)。聚焦「为什么这么定」。
> 创建: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 条 → 单独拆文件
- 已稳定决策合并精简,只留结论 + 关键原因
**更新优先于新增**:决策点已存在就改原条,演进用 `→` 串联,不留重复版本。