2026-09-27
新增 Version 6.9.27
本项目引入
分享链接支持自定义后缀(share-create 白名单差点把它吞掉)
网页端 09-26 支持了自定义后缀,客户端要跟上。但客户端的所有网盘调用都经 server.mjs 转发 —— 而它是逐字段白名单 。
差点 被静默丢掉
规则 创建留空 = 云端随机
安全 编辑留空 = 保持原后缀
验证 产物校验 92 条含此断言
实现细节 & 踩过的坑5 条
🔴 必须补白名单 :server.mjs 的 share-create 是逐字段转发 (只挑 path / ttlSeconds / maxAccesses / password),新字段不登记就被静默丢掉 —— 前端传了、后端没收到、界面还显示"创建成功"。已在代码里加注释提醒。
两处口径不一致 :share-update 反而是整包转发 。同一份文件里两个相邻路由的转发策略不同,很容易"改了一处以为两处都改了"。
规则与网页端一致 :创建 留空 = 云端随机后缀;编辑 留空 = 保持原后缀(不会因为清空输入框被悄悄改成随机)。
前端三处 :index.html 加输入框、app.js 收集 + 提交、弹窗里做格式提示(1–64 位小写字母/数字/下划线/短横线)。
验证 :校验打包产物.mjs 的 92 条断言里含这一条;打包 6.9.27。
根因溯源:谁埋的这个坑
本项目引入
白名单转发是我们自己设计 的(只把认识的字段转出去 = 更安全、更可控)。代价是「新增字段忘记登记 = 静默失效」——
这是客户端里最难查的一类 :不会报错,界面会说"已保存",下次打开又变回原样。
模式警告
同一个病在项目里有三处:server.mjs 的 share-create 白名单、/api/config 的键白名单 、
main.js 的 DEFAULT_CONFIG。加配置键/加字段时,要一次性想清"这条路一共几道关"。
2026-09-24
修复 Version 6.9.24
本项目引入
设置页「保存同步设置」误报「至少要有一组」
用户实测:装新版后先开设置页 想把同步模式从「单向备份」切走 → 保存失败,模式改不了。
真因 同步组住在另一个视图里
背景 "组全删了"与"没渲染过"在 DOM 上一模一样
修法 dataset.rendered 标记
代价 一条隐藏前提入册
实现细节 & 踩过的坑5 条
根因 :同步组列表住在「同步 」视图(#syncGroups),而「保存同步设置」按钮在设置页 。本次会话若没打开过同步页,那里一个组行都没有 —— 这与「用户把组全删了」在 DOM 上完全一样 ,于是被当成空清单直接抛错。
修法 :renderGroups() 末尾打 $('syncGroups').dataset.rendered = '1';saveGroups() 见没有 这个标记就沿用配置里现有的组 (语义="只改同步行为、不动同步组"),有标记才按 DOM 走(用户真删光时该报错照报)。
假绿陷阱(真踩过) :冒烟脚本第 1 步「八个视图各切一遍」会把同步页渲染掉 → 之后再测「没打开过同步页时能不能保存」永远为真 。所以这条断言必须插在视图扫描之前 ,复现用户的真实操作顺序。
反向对照 :把回退逻辑关掉 → 该断言应变红,且提示与用户截图一致 —— 证明它真能抓,不是摆设。
🔴 由此明确一条隐藏前提 :凡「在 A 视图保存、却从 B 视图 DOM 取值」的写法,都要按这个 dataset.rendered 模式处理。
根因溯源:谁埋的这个坑
本项目引入
saveGroups() 是我们写的,「设置页 / 同步页」这个视图拆分也是我们的结构。原托盘版没有主界面,也就没有"跨视图读 DOM"这件事。
模式警告
隐含假设「DOM 就是当前真相 」在这里不成立 —— 数据的家在另一个视图里。
判据 :只要一个保存动作读取的元素不在它自己的视图内,就要问「这个视图这次渲染过吗」。
2026-09-23
新增 Version 6.9.23
本项目引入
相册两级改造 + 隐藏改成批量勾选
相册原来是「跟着当前目录递归的平铺照片墙」;隐藏功能则在同一天从「三态黑白名单」收敛成「一个按钮 + 批量勾选」。
一级 按文件夹分组的相册卡片
二级 这本相册里的照片
收敛 配置键只剩 albumHidden
解耦 不再跟着"当前目录"跑
实现细节 & 踩过的坑8 条
取数 :一次 /api/drive/list-all?path=(空路径=根目录递归全盘)拿全量 → 前端按 dirName(path) 分组成相册,封面取该组最新一张;albumIndex() 带 60 秒缓存(与搜索索引同一思路)。
两级形态 :一级=相册卡片(封面 / 目录名 / 张数,同名相册再标出相对路径);点一本进去看这本的照片;顶部「全部照片」=平铺全部(改造前的形态,保留为"想看全量"的入口)。
隐藏交互 :右上角「隐藏相册」一个按钮 → 进选择模式,所有相册(含已隐藏的,勾着、灰着)都列出来,卡片右上角出现圆形复选框 ,勾上=隐藏(卡片同步灰掉 + 删除线,所见即所得),点「完成」一次性写盘。
配置键只剩一个 :albumHidden: []。老配置升级时 readAlbumHidden() 把旧 exclude 清单搬进来(include 无法等价表达,丢弃=回到全部显示)。
🔴 别顺手"优化"这一处 :saveAlbumHidden() 不清 albumCache —— 分组与隐藏无关,清了就白多一次全盘扫描。
🔴 语义有两份实现 :albumPathHit() 是 sync-core.mjs 的 isExcludedFolder 的前端复刻 (那份在 Node 端、浏览器拿不到)→ 改语义要两边一起改 。支持精确路径 + */素材、图标* 通配;'/' 是特例,指"根目录下的散图"。
疑似资源目录自动识别 :首次进相册、隐藏清单还是空时,识别目录名含 图标/icon/素材/assets/背景图/壁纸/logo 的目录,用 confirmBox 弹一次「一起隐藏」(每个会话只提一次)。
配置落点三处(缺一处就"保存了不生效") :server.mjs 的 DEFAULT_CONFIG + /api/config POST 白名单、main.js 的 DEFAULT_CONFIG。🔴 改完必须调 notifyConfigChanged(),否则主进程那份过期 configCache 会在它下次保存配置时把新值盖回去。
现行形态 :平时右上角只有一个「隐藏相册」按钮(卡片上没有任何角标);点进去才出现圆形复选框,勾上=隐藏、卡片同步灰掉
现行形态 :二级页=这本相册里的照片,「← 返回相册」回到一级;一级每本只加载 1 张封面
已替换的第一轮形态 :卡片右上角一枚「…」角标(逐卡入口)—— 当日被批量勾选版取代;卡片上的「按[排除清单]隐藏」字样也是那次改名的痕迹
根因溯源:这一域的三个决定
本项目引入
两级相册、隐藏清单、资源目录识别 —— 整套都是本项目造的 (原托盘版只有跟当前目录走的平铺照片墙)。
所以这一域里"坑"很少,多的是一次次设计收敛 。
设计取舍
白名单语义被放弃 :原本有「只显示这几本」(include)与「排除这几本」(exclude)三态。相册目录只增不减 → 黑名单(默认显示、例外隐藏)才是对的默认 ;
隐藏是低频操作 → 逐卡角标与设置页面板都嫌重,收敛成一个按钮 + 批量勾选。
外部环境
缩略图用原生 loading="lazy" 懒加载 —— 性能天然可控(一级每本只加载 1 张封面,点进去才加载这一组)。
代价是首次进相册要全盘扫一次(一两秒),这是"按文件夹分组"这个需求本身带来的。
2026-09-23
修复 同日续
本项目引入
「立即同步」按钮卡在灰着 —— 永久修复 + 诊断退场
用户实测:同步日志已经打了「同步结束」,按钮却一直不可点。定位过程中加过一条临时诊断通道,最后整段移除 。
修复① finally 兜底复位
修复② progressTimer 提前建立
不变式 日志打了"同步结束" ⟺ 按钮一定可点
已删 progress-debug.log 不要再去找
实现细节 & 踩过的坑5 条
定位手段 :在 main.js 里临时加过一条进度诊断通道(独立日志 logs/progress-debug.log + traceProgress(),含 applyProgress 进出各一行、sync:snapshot 的 running 变化行)。
结论 :用户复现确认两次完整同步都是 scan → start → done → end → running=false,兜底复位一次都没触发 → 说明原来的确丢了事件。诊断代码已整段移除(连 lastSnapRunning 与三处 traceProgress 调用一起)。
永久修复① :runSyncOnce 的 finally 加一道兜底复位 —— 收尾时若 transfer.running 仍为 true(说明 end 事件没送达主进程)就补发一个 end,保证不变式「日志打了同步结束 ⟺ 按钮一定恢复可点」。
永久修复② :前端 progressTimer(1.5 秒轮询)从 boot 末尾提前到 loadLocalToken() 之后 建立,且轮询不再判断"同步页是否可见"(原先若首屏 loadDrive 慢/卡,轮询迟迟不存在,兜底就是死的;切走再切回来按钮还会停在旧状态)。
别去找那个诊断文件 :logs/progress-debug.log 与 traceProgress() 都已经不存在了 —— 留在代码里的是上面两条永久修复。把它写进图鉴就是为了防止下一个人照着旧笔记去找。
根因溯源:谁埋的这个坑
本项目引入
runSyncOnce / progressTimer 这套"进度状态机"是我们写的;"界面点同步 / 托盘立即同步 / 定时自动同步三种来源共用一份进度"也是我们的设计(走 IPC 才看得到进度)。
三种来源 + 一条事件通道 = 事件丢一次就卡住。
模式警告
修法选的是加固不变式 (收尾时无条件复位),而不是继续追"事件为什么丢"——
对"状态机会卡住"这类问题,兜底复位 + 不变式断言 比根治单点更值。
2026-09-19
新增 Version 6.9.19
本项目引入
同步行为自定义:删除维度 × 云端更新维度 + 组级「只同步本组」
这两个维度此前是硬编码 的 —— cloudPull / cloudUpdateDir 连 config.json 里都不存在,用户没有任何办法关掉"云端更新"或改它的目录名。
三模式 双向 / 云端留档 / 单向备份
命门 拆开 if (cfg.cloudPull)
穿过 5 处组级字段
验证 反向对照 24/24
实现细节 & 踩过的坑8 条
用户定的最终形态 :勾「删除同步」+ 勾「云端更新」(被锁死灰掉)=双向同步;不勾删除 + 勾更新=云端留档;都不勾=单向备份;第 4 格(删除勾 + 更新不勾)界面上禁止 。
🔴 那个「拆」是唯一逻辑改动,也是命门 :把 if (cfg.cloudPull) {…} 拆开 —— 识别云端新增的 cloudNewPaths 提到门外,cloudPull 只控制"下不下载"。
为什么必须拆 :extras(交给删除维度的"云端多出来的文件")= 云端有 + 本地没有 + 不在 cloudNewPaths 。若 cloudNewPaths 只在 cloudPull 为真时生成 → 关掉"云端更新",云端新出现的文件会掉进 extras 被判成"本地删过"移走 —— "不处理"会变成"清走" 。
🔴 只同步本组不能过滤 pairs :pairStatePath(base, i) 按下标 取账本,过滤会让后面的组接管前面组的账本(幽灵行为)→ 必须 continue 跳过。
🔴 白名单不加=控件是假的 :点了保存、提示"已保存"、下次打开又变回原样(最难查的那种)。DEFAULT_CONFIG + POST 白名单各补两个键。
组级字段要穿过 5 处 (漏一处=勾了保存即丢):normalizeFolders() / saveConfig() / collectGroups() / readFolderRows() / localPathOf()。
改动面(两端都改) :sync-core.mjs(两端共用)+ ⚠️ 两端各自的 server.mjs 不是同一份 (脚本版约 596 行 / 客户端约 1250 行)+ sync.mjs + main.js / preload.js + 两套 UI。
验证 :冒烟 39 ✓/0 ✗、产物校验(源码模式)84 OK / 0 FAIL、反向对照 24/24、死代码扫描五项全清、打包 6.9.19;⚠️ 脚本版尚未端到端实跑 。
根因溯源:谁埋的这个坑
本项目引入
cloudPull / cloudUpdateDir 写成硬编码,是早期为省事 的直接写法 —— 当时只有一种玩法,没有"关掉它"的需求。
需求一来,硬编码就从"省事"变成"必须动手术"。
模式警告
把一段代码挪到 if 门外,不可能不改变语义 —— 它原来在条件为真的世界里。这次改动唯一出错的可能就在这里,
而它恰好是全部三个模式都依赖的那一处。
设计取舍
用"三种模式"绕开了最早设想的「单向 / 双向下拉框」—— 真做方向开关,切向必须换账本命名空间 ,否则会批量误删。
代价:用户看到的是三个勾选组合,而不是一个直白的方向选择。
2026-09-19
修复 同日晚
本项目引入
同步面板四连修:布局 · 复选框 · 勾选落盘 · 清单可查
用户看截图连报四处 —— 从"方框离文字很远"到"自动同步不显示上传了什么"。
真因 后代选择器把方框撑到 290px
落盘 change 事件委托
新账本 transfers.json 存同步记录
未修 resetTransfer 组间重置
实现细节 & 踩过的坑6 条
① 面板重排 :云端更新目录名收窄成半宽(.grid-2)、云端回收目录名 + 保留天数上移到「删除同步」勾正下方、加副标题「勾上=执行这件事;取消勾选=不做这件事」、模式行改灰底模式盒 (只显模式名);id 变更:syncModeHint → syncModeName + cloudPullHint 。
② 方框离文字远 —— 根因不是间距 (gap 一直是 8px):.group .paths input { flex:1 } 这条后代选择器 把同级的 input[type=checkbox] 也拉成了弹性伸展项 → 复选框元素自己被撑到 290px 宽 ,勾选标记画在最左,看着就"孤零零"。修法:收窄为 .group .paths .ln input。真实浏览器实测:旧写法方框 290px → 新写法 13px ,文本输入框 417px 未被误伤 。
③ 勾选没落盘 :勾上「定时同步时跳过本组」后点别处回来勾没了。根因——勾选只是 DOM 状态 ,没落盘;一切走再回同步页,配置里 skipAuto 还是 false,勾被冲掉(文本框同理会被冲掉,但用户习惯改完点「保存」;复选框勾完没人会想到点保存 )。修法:在常驻容器 $('syncGroups') 上加 change 事件委托 (容器常驻,组行重绘不影响),data-k="skipAuto" 变化即保存 + notifyConfigChanged()(定时器随即重排,勾选立即生效)。
④ 同步动过哪些文件(日志侧) :逐文件 ✓ 行一直有,但混在各组中间、多组同步时滚出视野。修法:runSync 返回值新增 uploadedFiles / pulledFiles / movedFiles,runSyncAll 累加后在最终汇总下方集中列清单 (↑上传 ↓云端更新 ⇢移入回收,≤20 条 + 溢出提示);dryRun 返回值同步补三个空数组保持形状一致。
⑤ 传输记录里没有同步记录 :同步明细只存在主进程内存快照 里 —— 重启即失,且 runSyncAll 每组的 scan 事件会 resetTransfer() 清掉上一组的清单 ,多组跑完只剩最后一组。修法:同步结束后把动过的文件持久写进 transfers.json (kind:'sync')—— server.mjs 新增 recordSyncTransfers() 并挂 globalThis.__clouddriveRecordSyncTransfers(与 __clouddriveSyncHook 一对:一个传事件进来、一个给记账入口出去);单次上限 50 条 ,截断时补一条汇总条目。
⑥ 未修的一处(已知) :resetTransfer 的组间重置没改 —— 要动得给事件带组号(rel 会撞键),改动面大。所以同步进行中 传输页的"同步任务"节仍只显示当前组,靠持久账本兜底历史。
根因溯源:谁埋的这个坑
本项目引入
这四条全是我们写的代码:.group .paths input{flex:1} 这条 CSS、skipAuto 这个只改 DOM 的复选框、同步清单的内存快照。
模式警告
一条 CSS 两个病根:后代选择器误伤同类元素 (本是给文本框的 flex:1,命中了复选框);
开关类控件的默认心智是"勾了就走" —— 任何只改 DOM 不落盘的复选框,都是定时炸弹。
2026-09-18
优化 无发版
设计取舍
源码卫生一轮清理 —— 附带一次「反向对照被强杀」事故
用户原话:"我还是追求简洁,有点完美主义,所以像'删不删都无影响'之类的就直接删。"
死代码 五项全清
孤儿 id 3 个已删
事故 改坏的源码留在了磁盘上
加固 两道网 + 信号处理
清理了什么 & 事故怎么发生的5 条
三个孤儿 HTML id (JS/CSS/冒烟/探针全不引用):#logHint 整个元素删掉 (清理结果早已改用 toast() 弹提示,app.js 里没有 setHint);#dlgBox / #shareTitle 只删 id 、元素保留。删前逐条查过引用。
死代码扫描【5】加「有意保留」白名单 :旧版把必须写出旧品牌名的说明文字 (打包日志里的旧 AppId 提示、version.json 的更新说明)也报成"残留"→ 现在不在白名单里的一律照报,白名单里的单列一段并印出保留理由 。
🔴 事故 :跑 scripts/反向对照检查.mjs 时进程被 SIGTERM 杀掉(跑 4~5 分钟的脚本很容易撞外层超时)→ finally 里的还原没执行 → app.js 的属性弹窗渲染行被留成了故意改坏的版本 (<div class="kv-row"> → <div>)。
为什么隐蔽 :脚本第二次跑会报「找不到锚点,跳过」+ 整体 ❌,看着像"断言过期",实际是源码坏了 ;真打包出去则属性弹窗排版全乱,而文件里看不出哪里不对 。处置:对照 dist* 三份旧产物确认原始写法 → 手动还原 → 给脚本加两道网 (tmp 备份 + 启动自检回滚 + 信号处理)。
纪律 :这类"先改坏再验证"的脚本被强杀后,第一件事是重跑它 ,别急着改断言表。
根因溯源:谁埋的这个坑
本项目引入
三个孤儿 id 是我们写界面时留下的;app.js 与 dist 产物不一致的风险,也是"界面随包发布"这个架构自带的。
外部环境
进程被 SIGTERM 杀掉时,JS 的 try/finally 不保证执行 (信号可以绕过 JS 栈)。
凡是"先破坏再还原"的脚本,都不能只依赖 finally。
设计取舍
不用 git,用源码快照 :自用项目、不想推到网上,为省几个命令去学 git 不划算。
scripts/打包客户端.mjs 打包前自动把自有源码快照到 备份/client-<版本>-<日期>/,覆盖了"退得回去"这个真实需求,
而且多文件目录本来就没法像 worker.js 那样"一文件一备份"。
2026-09-17
界面 Version 6.9.17
设计取舍
目录 / 品牌三件改名:client2/ 变成唯一的 client/
软件名带个「II」、旧托盘版目录也叫 client —— 名字混乱到说"client 改动"要解释是哪一个。
① 旧托盘版 → client托盘版(归档冻结)
② 主客户端 client2 → client(唯一主目录)
③ 软件名去「II」
不变 配置目录还是 clouddrive-desktop
改了什么 & 有什么不能忘4 条
三件改名 :① 旧托盘版 client/ → client托盘版/(归档冻结);② 主客户端 client2/ → client/(成为唯一主目录);③ 软件名 阿鹏云盘II → 阿鹏云盘。
AppId 改 com.zpeng.clouddrive ;但配置目录仍是 %APPDATA%\clouddrive-desktop —— 装了新版不用重新登录、不用重配同步组 。
历史 exe 产物名保持原样 (如 阿鹏云盘II-6.9.13-x64.exe)—— 当时文件真的叫那个名字,改了反而对不上历史快照。
⚠️ 两个客户端不能同时运行 :两版共用配置与账本,同一个账本被两边同时写会打架。这条约束一直保留到现在。
根因溯源:为什么当初会共用配置
设计取舍
共用配置目录换来「装了 client 不用重配」—— 实现方式是 main.js 里在任何 app.getPath('userData') 读取之前
app.setPath('userData', <appData>/clouddrive-desktop);只有该目录不存在时才落回自己的 clouddrive-desktop2。
代价就是上面那条"不能同时开"。
2026-09-17
界面 同日 · 两轮
本项目引入
四列等距两轮定稿(第一轮没达成,第二轮才对齐)
用户第一轮验收「间距相等」未达成 (时间 → 历史版本之间空出约 60px 大缝);第二轮又截图报整列错位 。
行距 放宽到 14px
真因 表头 55px / 文件夹行 12px / 时间值 68px 长短不一
定稿 时间 68 · 操作 104 定宽
探针 新增「三处对齐」段
实现细节 & 踩过的坑5 条
为什么"整列错位" :修改时间不能更宽 (旧 132px 会留大缝)也不能随内容收缩 —— 表头「修改时间」55px、文件夹行「—」12px、时间值 68px 长短不一,各自收缩就会错位(表头文字跑到「历史版本/属性」头顶、文件夹行的「—」与文件行的值错开)。
操作列同理定宽 104px (=文件行两按钮内容宽);文件夹行只有「属性」也占同样宽度(右对齐、左侧留空)。.col-act 不再定宽 (曾 120px,为四列等距取消)+ gap:0; margin-left:-7px。
三处对齐 :表头 / 文件夹行 / 文件行的大小右缘、时间左缘、操作列宽逐项 ±2px 比对。
⚠️ 度量方法 :用 Range 量文字矩形 验三段间距 ≈14px(容差 3px —— Range 含字形边距);量 flex 的视觉顺序一律看坐标 (getBoundingClientRect().left),别看 DOM 顺序。
⚠️ 探针的隐藏前提 :量表头前要先把网格段加的 hidden 摘掉,否则量到的是隐藏元素(0)。
根因溯源:谁埋的这个坑
本项目引入
列宽策略是我们自己写的(原托盘版的列表结构完全不同)。"文字净间距相等"这个目标本身就要求列宽定死 ——
只要允许某一列随内容收缩,它在三种行(表头 / 文件夹 / 文件)里就会得到三个宽度。
外部环境
Range 的 getBoundingClientRect() 包含字形边距 → 度量天然有 3px 级容差。
不要把容差当误差去"修" (越修越歪)。
2026-09-17
新增 同日
本项目引入
云端同步目录「改名…」→ 支持「改名 + 移动 」
新增组会自动预填 同步/<本地文件夹名>,但已有组一律不预填 (写死的硬约束)—— 想给老组换个位置,只能走「改名…」这个入口,而它当时不能移动 。于是"想归位"卡住了。
安全 config 与账本键前缀一起搬
入口 改云端名唯一入口
踩坑 「先规范化再校验」把错悄悄改对
踩坑 搬回根目录被误报「已存在」
实现细节 & 踩过的坑7 条
核心模块 :client/src/rename-remote.mjs(脚本版 同步目录改名.mjs 共用同一模块 ,改一处两边都生效);5 步流水线不变:预检 → 云端改名/移动 → 更新 config → 更新账本 → 收尾核对。
目标父目录必须先建出来 :/api/rename 只负责搬,新父目录不存在时目录"搬进去了但网页端看不见" → 带层级的新路径会先调 /api/mkdir(幂等)。
防嵌套校验 :目标路径不能落在别的组的云端目录里(反之亦然),否则两组互相干扰,直接拦下。
🔴 坑 1「先规范化再校验」=把用户的错悄悄改对 :normalizeRemotePath() 会去掉首尾 /、合并 //、把 \ 当 /。原来 validateRemotePath() 内部也先规范化,于是 /同步/x、同步//x、同步/x/ 全被"修好"后通过了校验 —— 用户写错也照样执行,而且执行的是他没打算要的路径 。现在校验只看原始输入 (只保留 \ → / 这唯一一种无歧义替换),且校验排在"新旧名字相同"判断之前 。
🔴 坑 2「搬回根目录」被误报成"已存在" :预检里决定"要不要重新列目标父目录"的判断写成了 if (np.parent && np.parent !== op.parent) —— 目标父目录是根 (空串)时会跳过重查,拿源父目录的列表当目标列表用 ,于是 ZZ目标目录/ZZ移动测试 → ZZ移动测试 报"云端已经存在"(其实那个名字正躺在源父目录里)。改成 np.parent !== op.parent 即修。⚠️ 这条只有"移出子目录"才踩得到 —— 测试不写反向案例就发现不了。
账本只记「文件」,不记目录 ⇒ 收尾核对的"云端 N 个文件 vs 账本 N 条记录"必须文件对文件 ;凭印象往 fixture 里塞一条目录记录,就会永远报 ⚠️ 不一致(假告警)。
验证 :scripts/验证云端改名移动.mjs 走线上真实云端 ,只用 ZZ* 临时目录 + 隔离配置目录 tmp/_testmove/,跑完连回收站条目一起清 —— 24/24 全过 。
根因溯源:谁埋的这个坑
本项目引入
rename-remote.mjs 是本项目写的;"新增组自动预填、老组绝不预填"这条硬约束也是(见总表 09-17 那一行)——
正是它让"改云端名必须走这个入口"成了唯一安全路径。
模式警告
校验针对的必须是"用户真正输入的那个字符串" 。先规范化再校验 = 校验变成摆设 ——
它拦不住错误输入,只是把错误输入伪装成合法输入放行。
模式警告
测试覆盖不全 = 没有测试 :坑 2 只存在于"移出子目录"这一条分支上,正向案例(移入)永远绿。
所以这个脚本把"搬回根目录"单独列成一条用例。
2026-09-17
移除 同日
本项目引入
网格态去掉 ⓘ 属性入口(一删牵动 5 处断言)
用户截图:"网格视图下的属性还是不要了,有点难看"—— 那是个半透明小圆圈,正压在文件夹图标 / 文件缩略图的右上角 。
删掉 .cell-info + ICON_INFO + 4 条 CSS
保留 左上角勾选框 .cell-check
纪律 删元素的断言必须 count === 0
联动 5 处断言 + 2 处文档
这一删牵动了哪 5 处4 条
当前行为 :需要看属性时切回列表视图 (属性在行内按钮里);网格卡片左上角勾选框保留 (批量操作用得到)。
断言 5 处必须一起改 :① 校验打包产物.mjs 改成反向断言 「不许再出现 cell-info / ICON_INFO」;② 外观布局探针.mjs 换成「格子里没有 .cell-info」+「勾选框仍在格内」;③ 反向对照检查.mjs 那条 mutation 要反过来 (改成"把 ⓘ 塞回格子",断言仍须变 ✗);④ 冒烟脚本 ui-smoke-client.mjs 改成数量 === 0 ;⑤ 文档描述同步改。
🔴 通用教训 :「去掉某个元素」的断言必须写成 count === 0 ,不能写成"找不到就不检查" —— 否则将来有人把它加回来,测试还是绿的。
反向对照自己也会失效 :元素被删掉后,原来那条"抽掉 ⓘ"的 mutation 就失去了作用对象 —— mutation 必须跟着改方向 ,否则反向对照变成永远通过的空壳。
根因溯源:谁埋的这个坑
本项目引入
网格视图与那枚 ⓘ 都是我们自己加的(09-14 做列表-网格切换时顺手给的属性入口),09-17 又自己把它删掉。
三天寿命 —— 属于"设计在真机上看一眼就知道不对"的那类。
模式警告
断言是有方向的 :正向断言("应该有 X")在元素删掉后会变成永远失败的噪音,
而反向断言("不许有 X")在元素留着时才有意义。删元素时如果只删元素不翻断言,测试就悄悄失去意义。
2026-09-15
修复 连报两轮
本项目引入
属性弹窗排版错乱:连报两轮才找到真根因(网格逐格填充)
用户截图报「属性弹窗排版错乱」。第一轮按"路径行只占 2 格"去修 —— 没修好 ,第二轮才发现整块错位的机制。
真根因 一个网格 + 每字段只填 2 格
正解 每字段各自一个 .kv-row
产出 scripts/布局探针.mjs
非假绿 反向对照精确复现
实现细节 & 踩过的坑5 条
🔴 真根因 :.kv 原本是一个 64px / 1fr / auto 三列网格,而每个字段只贡献 2 个 格子 → 网格逐格顺序填充 ,下一个字段的标签被塞进上一行第 3 列 —— 整块错位。
第一轮为什么没修好 :误判成"路径行只占 2 格"→ 只修路径行,其它字段照旧错位(这就是"连报两轮"的原因)。
正解 :每个字段各自一个 .kv-row(display:grid; grid-template-columns:64px minmax(0,1fr) auto),.kv 改 flex column —— 加字段只是多加一行,互不影响 ;说明行移出 .kv。
产出 scripts/布局探针.mjs (这一域最有价值的东西):冒烟脚本带 CD_DUMP_DIALOG=<路径> 吐出现场真实生成 的 #dlgBody.innerHTML(不手写 markup,永不与 app.js 漂移 )→ 真实 app.css + 真实弹窗宽度拼最小页 → 无头 Chrome --dump-dom 量每行标签 Y / 输入框宽 / 是否溢出。
反向对照已验证非假绿 :把 .kv 改回旧写法(.kv-row{display:contents})→ 探针精确复现"重复 Y=69,120 / 值与标签错行",形态与用户截图一致。
根因溯源:谁埋的这个坑
本项目引入
.kv 三列网格是我们自己写的 (原托盘版没有这个属性弹窗),"每个字段只写两格"也是我们的用法。
这是"设计时按行思考、实现时按格填充 "的落差。
外部环境
CSS Grid 的自动放置(auto-placement)逐格填充 是规范行为 —— 它不会报错 ,
只会默默把内容塞到你不期望的位置。可视化问题必须用真实布局引擎量 (jsdom 量不到几何)。
2026-09-15
界面 同日 · 两轮
本项目引入
背景图可读性:列表白卡 + 网格贴边(先量后改的样板)
用户截图:"加了背景图后三处字压在图上读不清";随后又报网格格子边缘与白卡边框叠成一条粗线。
定位 只有列表型四处是"行铺在内容区上"
只在有背景图时生效 零样式回退
真因 白卡没给网格留内边距
否决 "把遮罩再加浓"
实现细节 & 踩过的坑5 条
怎么定位 :其它区域(同步 .group、传输 .tr-item、相册 .album-cell、存储 .node、设置 .panel、网格 .cell)本来就是白底卡片 ,只有列表型四处 是"行直接铺在主内容区上"。
❌ 否决"把遮罩再加浓" :内容区已 93%,浅色高对比图仍以纹理透出;继续加浓 = 图彻底看不见,违背用户"主界面能显示一点点"的原意。
✅ 必须加包裹层 .list-card (把 .list-head 一起包进去):.view 是 flex column; gap:12px,表头与列表之间本来就有 12px 缝,分别加圆角会中间断层漏出背景图 。只在有背景图时生效(:root[data-bg="1"])→ 无背景图时零样式 。
🔴 网格贴边的真因 :探针实测"格子到白卡内边缘 左 0px / 上 0px "(卡片 10px 圆角 + overflow:hidden,格子自带边框 + 8px 圆角)→ 第一列/第一行/最后一行边框叠成一条粗线、四角格子圆角被裁。结论:不是格子太大,也不是遮罩不够浓,是白卡没给网格留内边距。 → :root[data-bg="1"] .list-card:has(.drive-grid){padding:12px};列表态必须仍是 padding 0 (断言里专门验了这一点)。
为什么用 :has() 而不切 class :网格与列表共用 #driveList ,切 class 要改 renderDriveList / renderSearchResults 两处,漏一处就回归。
根因溯源:谁埋的这个坑
本项目引入
背景图、列表白卡、网格视图都是我们自己加的 —— 三个功能各自都"能用",叠在一起 才暴露问题。
外部环境
:has() 是较新的选择器(依赖 Chromium 版本)。选它换来的是"一处声明代替两处 JS "——
这正是"界面随包发布、改错要重打包"这个前提下更划算的写法。
设计取舍
内容区 93% 遮罩 = 背景图只淡淡透出(用户原话"主界面能显示一点点")。
代价:浅色高对比的图会以纹理形式透上来 —— 所以必须给文字区域铺白卡,而不是继续加浓遮罩。
2026-09-15
新增 同日
本项目引入
存储空间改造:口径 · 构成 · 月均 · 超标告警
用户发现"首页只显示云盘文件总大小 / 配额,看不出会不会超 R2 免费额度 "—— 桶里还有历史版本、回收站、站点图标/背景图。
唯一 client + worker 同时改的功能域
零额外请求 一次遍历顺带分类
踩坑 "93%"文字药丸把导航挤坏
改名 存储节点 → 存储空间
实现细节 & 踩过的坑5 条
用户拍板 5 点 :口径写清 / 分类明细 / 月均 / 超标要"不点进去也能看到" / 顺带改名字。
服务端(worker.js,不在 client 里) :calculateR2Usage() 一次遍历顺带按 key 前缀分类(零额外 R2 请求 )塞进原有 60s 缓存;recordUsageSnapshot() 每天第一次全量扫描写一条快照(按北京时间,保留 180 天);computeUsageStats() 算月均,样本 <3 条或跨度 <3 天不给增长结论 。
client 侧 :renderUsageNode() / renderUsageExtra() / applyStorageAlert() / refreshStorageAlert();构成 / 月均缺字段时整块不渲染 (老 worker 不会崩);告警开机 3 秒后拉一次、之后每 10 分钟一次。
🔴 踩坑(外观探针抓到的真问题) :角标第一版做成「93%」文字药丸 → 侧边栏固定 156px、导航项内容区实测只有 104~119px ,标题被挤成两行、这一项 46px vs 邻居 31px 。→ 改成没有文字的小圆点 (百分比放 title)。
为什么"不点进去也能看到" :超标是低频但需要时立刻知道 的信息 → 侧边栏那一项整条变色 + 一个小圆点,而不是塞个数字进去挤坏导航。
根因溯源:谁埋的这个坑
本项目引入
用量口径、"构成 / 月均"的渲染、告警角标全是本项目加的;这是唯一一个"client + worker 同时改"的功能域 ——
服务端出数据、客户端只负责渲染与告警(新字段是加法式 的,缺字段就整块不渲染)。
设计取舍
样本不足不给增长结论 (<3 条或 <3 天):宁可不说,也不给一个会被当真的数字。
代价是新装用户短期内看不到"月均"这一行 —— 但看不懂的数字比没有数字更糟。
2026-09-15
新增 Version 6.9.15
设计取舍
发版工程定稿:日期式版本号 · exe 固定名 · 图标单一图源 · 源码快照
用户要求"重打包后桌面快捷方式 / 任务栏固定项不用重做"。这条需求直接改写了发版规则。
版本号 年尾数.月.日(跨天必升)
exe 固定名永远不带版本号
必须 无沙箱 + 前台跑打包
回退 源码快照 + 回退脚本
实现细节 & 踩过的坑5 条
版本号 :日期式 年尾数.月.日 (2026-09-17 → 6.9.17);跨天必升号 ,同一天多次修订沿用同一号。发版要改两处:client/desktop/package.json 的 version + 根 version.json。
exe 固定名 :阿鹏云盘-x64.exe(NSIS 安装版)/ 阿鹏云盘-便携版.exe(portable)—— 版本号只出现在「软件内设置页」与「资源管理器悬停的文件版本」两处。
🔴 图标必须三步走 :① 跑 build-all-icons.py 生成各尺寸托盘图标 + app-icon.ico;② 手动 copy icon-candidates/pan-512-真彩色.png → client/public/logo.png(脚本不管这步,漏了会出现"exe 图标换了、界面 logo 还是旧的" );③ 重打包。
🔴 打包必须"无沙箱 + 前台" :沙箱的批量删除保护按本轮对话累计删除数 判定(超过 50 就拦),清过临时文件之后必然超标,连移动/原地打包都会被拦;而后台任务跑打包会静默失败 (后台无法弹"绕过沙箱"确认)。另:打包前要先把旧 dist 整体移走 (毫秒时间戳改名)——win-unpacked 会残留上一轮文件,只覆盖不清理会把旧内容打进包。
源码快照(回退底) :scripts/打包客户端.mjs 打包前把自有源码快照到 备份/client-<版本>-<日期>[-主题]/(约 1 MB / 33 文件;node_modules、dist*、vendor 大库不进,第三方库只记 sha256 指纹)。回退:scripts/回退客户端源码.mjs --列出 → 选一份恢复。
根因溯源:这些规则是被什么逼出来的
设计取舍
exe 固定名 :代价是"要同时留两版安装包得自己改名",收益是"桌面快捷方式 / 任务栏固定项永不用重做"。
这条规则只对 client 生效 (托盘版停在 阿鹏云盘II-6.9.13-*.exe 的旧命名)。
设计取舍
不用 git,用源码快照 :client 是多文件目录,不能像 worker.js 那样"每次复制一个文件";
而 1.59 GB 的 node_modules 会把备份撑爆。快照只收自有源码(约 1 MB),
代价是"没有 commit 信息"——所以每次带一主题名。
2026-09-15
界面 同日
本项目引入
操作按钮去重(顶部=操作 / 行内=查看)+ 弹窗正文两条通道
用户截图反馈"很多操作是重复显示的"(打包 / 分享 / 重命名 / 删除在顶部和行内各有一份);后来又报"删除确认弹窗里写着 <br>"。
定规 顶部=操作,行内=查看
真因 不是文案写错,是通道传错
选择 显式开关,默认保留转义
产出 弹窗对比图脚本
实现细节 & 踩过的坑5 条
分工 :顶部工具栏只放「操作」类(上传 / 新建文件夹 / 复制 / 剪切 / 粘贴 / 重命名 / 分享 / 下载 / 删除 / 刷新);行内只放「查看」类(文件夹行=属性;文件行=历史版本 + 属性)。顶栏「重命名」只对单选项 启用。
🔴 弹窗真根因 :showDialog 有两条通道 —— bodyHtml(按 HTML 渲染)/ body(按纯文本 渲染,自动 esc())。confirmBox(title, body) 把第二参数透传给 body → 带标签的文案被 esc 成 <br>,浏览器解析该实体后又正好显示成字面 <br> 。
✅ 为什么选"显式开关"而不是把默认通道开成 innerHTML :body 里插的文件名 / 路径 / 节点名全用户可控,默认必须保留 esc 。confirmBox(title, body, {html = false, danger = true});走 html 通道时每一处插值必须自己 esc() 。
产出 scripts/弹窗对比图.mjs :同一份页面只换 dlgBody.innerHTML 是 esc(body) 还是 body 原文 → 真机各截一张拼上下对比。
⚠️ 附带教训 :诊断"有没有弹窗"要按 id 查 (dlgMask / shareMask / pvMask / nodeMask),不要按 class 猜 —— shareMask/pvMask/nodeMask 的 class 不含 modal-mask ,用 .modal-mask 选择器诊断会显示"无弹窗",把排查带偏半小时。
根因溯源:谁埋的这个坑
本项目引入
按钮分工与弹窗组件都是我们自己写的(原托盘版的交互完全不同)。「顶部=操作 / 行内=查看」现在是一条结构约定 ,README 里写明"2026-09-15 定,别改回去"。
模式警告
"同一个函数两种渲染语义 + 默认参数"是最容易出事的组合 :默认安全(esc)的那条通道用起来更顺手,
于是需要 HTML 的地方会"顺手"走它,然后静默显示成字面标签。解法不是改默认值,而是逼调用方显式声明。
2026-09-14
新增 Version 6.9.13(同日不升号)
设计取舍
文档预览落地:四库随包 + PPTX 换引擎 + 音视频靠 Range 透传
用户原话:"office 和 PDF 预览很重要、效果越接近打开文件越好 、不在乎体积。"
随包 四个库约 2.9 MB,不走 CDN
PPTX 换 pptx-wasm 保真引擎
一行透传 同时救了音视频
兜底 失败降级回旧解析器
实现细节 & 踩过的坑6 条
四库 + 自写胶水 :public/vendor/ 放 pdfjs-dist 3.11.174 / mammoth 1.8.0 / SheetJS 0.18.5 / JSZip 3.10.1,加自写 doc-preview.js(window.DocPreview.render/cleanup/canPreview/isLegacy)。约 2.9 MB 全部随包,不走 CDN (断网可用)。
实现来源 :移植自网页版 worker.js 的预览段 —— 两边预览行为一致,改一处要想着另一处。
⚠️ PDF worker 加固 :先用 fetch 读成 Blob 再赋 GlobalWorkerOptions.workerSrc —— 直接给相对路径在打包后 / 代理 / CSP 下会创建失败 ,症状是永久"正在加载" ;另加 30 秒超时提示。
PPTX 演进史 :第一版是自写 a:xfrm 坐标还原器 → 用户实测"显示不完整(背景图/表格不出来、排版错乱)" → 换 pptx-wasm 0.3.0 (Rust→WASM、canvas 输出、按 PowerPoint 继承链布局:形状 → 占位符 → 版式 → 母版 → 主题)→ 失败自动降级回旧 buildPptxLegacy() (旧代码保留)。
⚠️ .wasm 的 MIME 必须是 application/wasm (server.mjs 已加),否则 stream 实例化被拒 —— 这类"格式对了才肯跑"的接口,报错信息通常很含糊。
音视频 :previewFile() 增加 AUDIO_EXT / VIDEO_EXT 分支(<audio>/<video controls autoplay>)+ server.mjs 透传 Range (把 Range 转给云端 /api/download,并把 content-range / accept-ranges 原样带回、按上游状态回 206);不支持的编码(AVI/WMV)用 onerror 显示提示。
根因溯源:这几条约束是谁定的
沿用既有
.wasm 的 MIME 校验、WebAssembly.instantiateStreaming 的严格要求、PDF worker 的脚本路径解析 ——
都是浏览器 / 规范行为 ,不是我们写错的代码,但踩上去的症状(永久加载中、静默失败)非常难猜。
设计取舍
vendor 约 3.4 MB 随包 :换来"断网可用 + 无第三方可用性依赖"(对比:worker 网页端早期走 CDN,09-17 才挪到 R2 同源托管)。
代价是包体积 + 库的版本要自己盯 。
本项目引入
预览库选型、doc-preview.js、PPTX 降级链都是我们自己定的;
"旧代码保留作降级"是这一域的一个有意为之的反例 ——它看起来像死代码,但删了就没有兜底。
2026-09-14
修复 同日
本项目引入
搜索体验重做:输入即搜 + 只按名称匹配
原来的搜索是"弹层 + 回车";另外「必修一」的搜索结果会被整棵子树淹没。
改法 删弹层 → 350 ms 防抖
顺带 结果含文件夹
同一个病 formatDate is not defined
取舍 代价是搜不到"命中文件夹名"的文件
实现细节 & 踩过的坑4 条
交互重做 :删掉弹层 → 输入即搜 (350 ms 防抖);结果里包含文件夹 ;网页端同时补了搜索面板的滚动条(两端同修)。
修了两个隐藏 bug :formatDate is not defined、以及匹配数翻倍 (同一个文件被算两次)。
刻意取舍 :只按名称匹配 → 结果更精准,代价是命中文件夹名的文件搜不到 (用户认可这个交换)。
⚠️ 假绿提醒 :搜索列表与目录列表共用同一套 DOM 与断言 —— 搜索态下如果还去数 #driveList .row,会数到搜索结果的形状而不是目录行;写断言时要区分"当前是搜索态还是浏览态"。
根因溯源:谁埋的这个坑
本项目引入
搜索逻辑与那个 formatDate 调用都是我们自己写的。
模式警告
formatDate is not defined 与 09-27 网页端那个 jsAttr is not defined 是同一个病 :
浏览器端代码引用了只存在于另一侧的同名函数 。本项目里 escapeHtml / formatDate / formatSize
各有两份 (服务端 + 浏览器端),删掉任何一份都会出事。
设计取舍
只按名称匹配 = 放弃"按路径匹配"。理由:教学资料的目录名常常就是年份/班号(如 2026高一教学),
按路径匹配会让"必修一"命中整棵子树。代价是搜文件夹名搜不到里面的文件 。
2026-09-14
加固 同日 20:00 起
本项目引入
本地服务访问令牌 + 七项完善 ①~⑧
代码审查发现 🔴 本地服务无鉴权 :server.mjs 只听 127.0.0.1、默认端口固定、写 .console-port,全文件无 Origin / token / Host 校验 → 本机任意程序、甚至浏览器里打开的网页都能 POST /api/drive/delete。
风险 浏览器里的网页就能调它删文件
修法 guardRequest() 三重校验
兼容 token 同机可读 → 不锁死脚本版
一次落地 七项功能 + 视图切换
实现细节 & 踩过的坑5 条
guardRequest() = 三件事 :Host 本机 + Origin 本机 + 非安全方法要 X-Local-Token(或 Sec-Fetch-Site: same-origin|none)。
token 的链路 :由 main.js 生成写盘 .console-token + 注入 env → 经 IPC app:localToken → preload.js 暴露 localToken() → app.js 的 authHeaders() 给所有 请求带上(含两处裸 fetch)。
⚠️ 一个有意留下的口子 :token 设计成同机可读 —— 脚本版的控制台是用浏览器打开的,拿不到 token,但 Sec-Fetch-Site: same-origin 会放行 → 不锁死脚本版 。
七项完善 ①~⑦(+⑧) :本地服务鉴权 / 关闭行为 / 排序 / 快捷键 / 文件夹打包下载 / 分享可编辑 / 我的分享总览,晚间追加 ⑧ 列表-网格切换与「分享」→「我的分享」改名。
「分享可编辑」是零后端改动的补齐 :server.mjs 早就有 share-update 路由,只是前端从未调用 —— 典型的"能力已在、入口没接" 。
根因溯源:为什么会有这个洞
本项目引入
本地服务是我们写的,"只听 127.0.0.1 就安全了"这个假设也是我们的。
实际不成立 :浏览器里任何一个网页都能向 http://127.0.0.1:<固定端口> 发请求(CORS 只挡读响应,不挡"请求已经发生")。
设计取舍
token 只到"同机 "这一层:能挡住"浏览器网页跨源调用"这一大 类,但同机恶意程序仍可调用。
取舍的理由是两个产品(桌面客户端 + 脚本版浏览器控制台)共用一套本地服务 ,不能为了安全把脚本版锁死。
2026-09-13
基线 client 诞生 · 6.9.13
沿用既有
client 诞生:架构反转(内核一个字不改,界面全部新写)
原作者的开源客户端 R2-Cloud-Drive-Client/ 界面好看(无边框 + 左侧导航 + Material 配色),但它的界面代码 282 KB、绑定自己的 70 个 IPC 方法 ,手写原生 JS、字段名对不上就静默失效 ;更要命的是它的界面里没有 回收站 / 历史版本 / 目录改名 / 排除规则的入口。
反着做 内核照用,只重写界面
零共用 只借鉴视觉语言
一次拿到 好看的主界面 + 我们自己的内核
代价 改界面=必须重打包
怎么搭起来的 & 顺手带进的坑6 条
三条线 :src/ 直接从 client托盘版/src/ 复制(同步内核、api、net、auth、secret、rename-remote 全部照用,只把 server.mjs 扩出「网盘操作代理」);desktop/ 新写主进程(无边框主窗口 + 托盘辅助)+ preload.js(contextBridge 白名单桥);public/ 全部新写 。
两路数据源 :HTTP → src/server.mjs(配置 / 登录 / 网盘操作 / 回收站 / 版本 / 日志);IPC → preload.js 的 window.desktop(窗口控制 + 同步启停与实时进度)。
同步为什么走 IPC 而不是 HTTP :因为"界面点同步 / 托盘立即同步 / 定时自动同步"三种来源的进度都在 Electron 主进程里,走 IPC 订阅才能统一看到进度与网速。
密码永不出服务端 :一切云盘请求都由本地服务转发;顺带解决一个真问题 —— Node 侧发出的请求天然带 X-R2Drive-CSRF 头,不会被 worker 的同源校验拦下。原作者客户端就是栽在这里:它用 node:http 直连、不带 Origin/Referer,写操作一律 403 ,配置里显示"281 个文件失败"。
三方同源纪律 :client/src/ 的内核文件与 脚本定时同步版/src/ 保持字节一致 (改完要复制过去 + node --check)→ 这也是托盘版"至今仍能用"的原因。
⚠️ 同一天一起带进来的坑 :托盘的「正在同步…」永久卡死 —— job.running 引用了不存在的变量导致 ReferenceError 静默中断。这类前端错误由 jsdom 冒烟脚本专门监听 jsdomError 抓 ,从这天起成了固定手段。
根因溯源:三个起点决定
沿用既有
整套内核(sync-core / api / net / auth / secret / rename-remote)是照搬托盘版 的,
连同托盘那套状态机一起 —— 所以托盘上出现过的坑(如上面那个 job.running),client 里也会原样存在。
设计取舍
界面随包发布 :换来"断网也能开设置 / 日志 + 不用操心浏览器兼容 + 无 CDN 依赖",
代价是改任何前端都要重打包 (托盘版主窗口是网页端,改完刷新即可)。这条取舍影响了后面所有迭代的节奏。
外部环境
contextIsolation: true 下页面(http://127.0.0.1:端口)默认拿不到任何 Electron 能力
→ 一切桌面能力必须 靠 preload.js + contextBridge 显式暴露。
顺带:本地服务端口会漂移 → origin 会变 → 设置项不能存 localStorage 。