阿鹏云盘 · 客户端演进图鉴

client(主界面版)—— 从零到 6.9.27,造了什么、修了什么、每个坑是谁埋的。
时间跨度 2026-09-13 → 2026-09-27(15 天)· 版本 6.9.13 → 6.9.27 · 最近更新 2026-09-29

自有代码
8,774 行
托盘版 5,402 · 界面重写 +62%
源文件
44 个
不含 node_modules / dist
界面视图
9 个
文件 / 相册 / 同步 / 传输 / 回收站 / 版本 / 分享 / 存储 / 设置
网盘动作
32 个
前端调用与后端实现一一对应,零缺口零多余
IPC 通道
21 个
窗口 5 · 配置 4 · 同步 7 · 桌面 9(含事件)
打包产物
110.5 MB
安装版 + 便携版,各一份;vendor 3.4 MB 随包
内核一个字没改,界面全部新写。内核(sync-core / api / net / auth / secret / rename-remote)直接从托盘版复制, 与脚本版保持字节一致 —— 所以同一个云盘不会出现"两套同步行为"。代价是界面随包发布:改任何前端都要重打包。
读这份图鉴的诀窍:每个「坑」都有主人 —— 🔴 沿用既有(内核/第三方库自带,不是这轮重写引入的)、 🟠 本项目引入(我们写界面 / 加功能时带出来的)、🟢 设计取舍(有意接受的代价)、🔵 外部环境(Electron / 浏览器 / 系统行为)。 时间线条目点「查看详情」才展开,根因单独一条,想学习时再翻。先看根因分布 →

一、功能总表

从零(09-13)→ 6.9.27 · 共 0 项
引入类型功能 / 改动说明
09-13新增client 诞生:架构反转内核照用 + 界面重写原作者客户端界面 282 KB / 绑定 70 个 IPC,且没有回收站·版本·改名入口 → 借壳成本高于自写
09-13新增网盘操作代理/api/drive/*前端不直连云端,一切经本地服务转发 → 密码不出服务端;顺带解决 Node 侧请求不带同源头的 403
09-13新增分片上传 + 拖拽上传大于 8 MB 走 multipart-init/part/complete(7 天可续传),失败自动 abort 不留半截会话
09-13新增云端剪贴板复制 / 剪切 / 粘贴存 worker /api/clipboard(id=desktop,24 小时),关掉客户端再打开仍能粘贴
09-13新增多选批量 / 新建文件夹 / 重命名 / 删除表头全选含 indeterminate;批量删除的确认弹窗走「弹窗 HTML 通道」
09-13新增托盘(降级为辅助)立即同步 / 自动同步 / 开机自启 / 打开云盘 / 退出;悬停显示「同步中:文件名 · 3/12 · 1.2 MB/s」
09-13修复「正在同步…」永久卡死job.running 引用了不存在的变量 → ReferenceError 静默中断(这类错误由 jsdom 冒烟监听 jsdomError 抓)
09-13修复文件列表操作列竖排.row .mini 是 flex-shrink:0 + nowrap → 操作列定窄了不会竖排而是溢出,这类宽度只能用真实浏览器量
09-13界面「关于」→「更多」+ 版本号独立成框同日多次修订不升号(当天定下的规矩)
09-14新增文档预览五件套PDF / DOCX / XLSX / PPTX四个库约 2.9 MB 全部随包不走 CDN(断网可用);实现移植自网页版预览段
09-14修复PPTX 保真:换 pptx-wasm自写 a:xfrm 坐标还原器实测「显示不完整」→ 换 Rust→WASM 引擎,失败自动降级回旧 buildPptxLegacy()
09-14新增音视频预览 + Range 透传代理把 Range 转给云端、把 content-range 原样带回、按上游状态回 206 → 能拖进度条、能边下边播
09-14新增「用本机程序打开」网页预览再怎么做也是近似(SmartArt / 动画 / 真字体)→ 下到临时目录交系统默认程序,顺手清 24 小时前的旧文件
09-14新增文件夹 / 多选打包下载downloadZip() 读云端错误原文(否则用户只看到"失败")+ 进度条;代理把非 2xx 错误文本原样回 502
09-14新增搜索「输入即搜」删弹层 → 350 ms 防抖;结果含文件夹(原名不能搜到文件夹)
09-14新增分享可编辑典型的「后端能力已在、前端没接」—— share-update 路由早就有,只是前端从未调用(零后端改动)
09-14新增我的分享总览不传 path = 全量;只显示有效、标出已过期;视图内首段写明"这是我的全部分享链接"
09-14新增键盘快捷键 9 个输入框内一律让位(isTypingTarget);弹窗打开时也全部让位
09-14加固本地服务访问令牌审查发现本地服务无鉴权(写 .console-port、无 Origin/token 校验)→ guardRequest() 三重校验
09-14界面列表 / 网格视图切换fileView 落 localStorage;网格态不塞行内按钮(放不下 + 中文按钮会撑变形)
09-14界面关闭主窗口时的行为三轮否决后定稿:放「自动同步与启动」面板,随「保存」一起提交,删掉即时保存与提示
09-15新增属性弹窗每字段独立成行两个路径可一键复制、ETag 用等宽字体;未分享不显示空行
09-15新增同步日志增强选日期 / 关键字过滤 / 只看错误 / 每 5 秒自动刷新(能记住)/ 放大查看
09-15新增存储空间用量口径改造桶实际占用 + 占用构成 + 月均快照 + 超标告警(侧栏整条变色 + 小圆点);唯一 client + worker 同时改的功能域
09-15新增背景图 + 主题 + 字号缩放背景存 config.json 不存 localStorage(端口漂移会丢);字号只用 setZoomFactor()
09-15新增列表白卡背景图可读性只在有背景图时生效(:root[data-bg="1"])→ 无背景图时零样式,外观与从前逐像素一致
09-15修复列挤压:列宽策略反转旧写法 shrink 权重 100:100:0.1 让时间/大小先缩、名称反而不缩 → 改成「名称最先牺牲,大小/时间永不缩」
09-15修复传输记录只有同步任务根因是记账位置错了(原来只读主进程快照)→ 改在 server.mjs 记账,五类都埋点,写 transfers.json
09-15修复同步日志自动刷新不记勾选logAuto 从不落盘 → 必须登记进 server.mjs 白名单 + main.js 的 DEFAULT_CONFIG
09-15修复「我的分享」末条卡片缺下边框「末行去底边线」写成 > :last-child 一把抓 → 误伤自带完整边框的 .share-item
09-15修复网格态白卡贴边探针实测「格子到白卡内边缘 0px」→ 不是格子太大、也不是遮罩不够浓,是白卡没给网格留内边距
09-15新增版本号与产物命名规则定稿日期式版本号(年尾数.月.日);exe 固定名永远不带版本号 → 桌面快捷方式不用重做
09-15新增图标单一图源(青色鸟)全部从 client/desktop/pan.png 生成;🔴 手动 copy → public/logo.png 这步不能漏
09-15优化目录缓存「秒开」+ 启动并发dirCache 5 分钟命中先铺缓存;boot 三个请求 Promise.all 同时发(少等两趟 Cloudflare)
09-15界面操作按钮去重顶部=操作类、行内=查看类 —— 这条是结构约定,README 写明「别改回去」
09-15界面弹窗正文两条通道confirmBox(t, body, {html:true}) —— body 里插的文件名/路径全用户可控,默认必须保留转义
09-15移除「复制路径」(⑨)上线当天后删,与顶部「下载」的功能重叠
09-17新增云端目录「改名 + 移动」框里写 同步/2026高一教学 即可搬进子目录;config 与账本键前缀一起搬 → 下次同步不会全量重传
09-17新增新增同步组自动预填云端目录只对新增的组生效;已同步过的组(有账本记录)绝不预填 —— 改云端名只能走「改名…」
09-17界面目录 / 品牌改名旧托盘版 → client托盘版/(归档);主客户端 client2/ → client/;AppId com.zpeng.clouddrive
09-17界面四列等距两轮定稿时间 68px / 操作 104px 定宽不缩;探针用 Range 量文字净间距 = 14px(容差 3px)
09-17移除网格态 ⓘ 属性入口半透明小圆圈正压在缩略图右上角 → 需要就看属性时切回列表视图;牵动 5 处断言一起改
09-18优化源码卫生:3 个孤儿 id + 死代码白名单#logHint 整个元素删、#dlgBox / #shareTitle 只删 id —— 删前逐条查过引用
09-19新增同步行为自定义删除 × 云端更新三模式(双向 / 云端留档 / 单向备份);第 4 格(删除勾 + 更新不勾)界面上禁止
09-19新增组级「只同步本组」+ skipAuto🔴 只同步本组必须 continue 跳过,绝不能过滤 pairs(按下标取账本会串组)
09-19新增同步动过哪些文件可查日志尾部集中列清单(≤20 条)+ 持久写进 transfers.json(kind:'sync',单次上限 50 条)
09-19修复复选框「离文字很远」根因不是间距:.group .paths input{flex:1} 把同级 checkbox 也拉成弹性伸展项 → 方框被撑到 290px;收窄为 .ln input
09-19修复skipAuto 勾选没落盘勾选只改 DOM → 切视图重绘被冲掉 → 容器上加 change 事件委托,变化即保存 + 重排定时器
09-23新增相册两级改造一级=按文件夹分组的卡片(封面/目录名/张数),二级=组内照片;albumIndex() 60 秒缓存
09-23新增相册隐藏改批量勾选配置键收敛为 albumHidden: [];readAlbumHidden() 把旧 exclude 清单自动搬进来
09-23新增疑似资源目录自动识别目录名含 图标 / icon / 素材 / assets / 背景图 / 壁纸 / logo → 弹一次「一起隐藏」(每个会话只提一次)
09-23修复「立即同步」按钮卡在灰着两条永久修复(finally 兜底复位 + progressTimer 提前);定位用的诊断代码已整段移除
09-23移除相册三态黑白名单albumScope / albumPaths + 设置页「相册」panel + 管理弹窗 + 角标 —— 全项目零引用
09-24修复设置页保存同步设置误报saveGroups() 从同步页 DOM 读组 → 用 dataset.rendered 区分「本次没渲染过」与「用户真删光」
09-27新增分享链接自定义后缀可起一个好记的后缀;🔴 server.mjs 的 share-create 是逐字段白名单 → 必须补 suffix,否则被静默丢

二、重要更新(按时间倒序,最新在上)

概述先看 · 详情与根因点开再看
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 优化无发版 设计取舍

源码卫生一轮清理 —— 附带一次「反向对照被强杀」事故

用户原话:"我还是追求简洁,有点完美主义,所以像'删不删都无影响'之类的就直接删。"

死代码五项全清 孤儿 id3 个已删 事故改坏的源码留在了磁盘上 加固两道网 + 信号处理
清理了什么 & 事故怎么发生的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 成 &lt;br&gt;,浏览器解析该实体后又正好显示成字面 <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。

三、根因分布:这些坑都是谁埋的

点卡片 → 跳到上方时间线并只看这一类

客户端是从零写的,所以这里没有"上游原版的坑"这一栏 —— 取而代之的是「沿用既有」:内核照搬托盘版、 或第三方库(pdfjs / pptx-wasm / WASM 规范)自带的行为。剩下三类才是真正值得复盘的部分。 数字由页面自动统计,不会随手改而漂移。

四、「不做」也是决策

项目处理为什么
借用原作者客户端的壳R2-Cloud-Drive-Client放弃 界面代码 282 KB、绑定它自己的 70 个 IPC 方法、字段名对不上就静默失效;而且界面里没有回收站 / 版本 / 改名入口。 "借壳"等于要重写它的后端契约 —— 成本比从零写更高,功能还会缩水。改为「只借鉴视觉语言,零代码共用」。
网格态 ⓘ 属性入口删除 半透明小圆圈正压在缩略图右上角(用户:"有点难看")。需要就看属性时切回列表视图。删它牵动 5 处断言 + 2 处文档,都已同步。
相册三态黑白名单albumScope / albumPaths整体移除 白名单("只显示这几本")语义被放弃 —— 相册目录只增不减,黑名单才是对的默认;隐藏是低频操作,逐卡角标与设置页面板都嫌重。收敛成一个按钮 + 批量勾选,配置键只剩 albumHidden。
「复制路径」(⑨)当天删除 与顶部「下载」的功能重叠;上线当天后删。同类还有「打包下载」被并进自适应「下载」按钮。
字号缩放用 CSS html{zoom}否决 实测它把整块内容按倍数放大、100vh 不跟着缩 → 1000×900 视口下 body 被撑到 920px、出现纵向滚动条。改为只走 setZoomFactor()。
设置项存 localStorage禁止 本地服务端口会漂移 → origin 变 → localStorage 丢。一律存 config.json(记得同步键白名单 + DEFAULT_CONFIG)。
用 git 管理源码换方案 自用项目、不打算推到网上,为省几个命令去学 git 不划算 → 改成源码快照(备份/client-<版本>-<日期>/)。 已覆盖"退得回去"这个真实需求;哪天想要 commit 信息,再 git init 也不冲突。
不要行内下载 / 顶部打包下载合并 用户明确要"顶部下载按钮自适应":单选文件=下这个文件;多选或含文件夹=自动打包 zip。
auto 表格布局(窄屏)换方案 共享的 .file-row-name{max-width:400px} 把名称列 min-content 钉死 → 整页被撑宽、右端被视口裁掉。改为 table-layout:fixed,让内容 intrinsic 宽度彻底退出计算。
分布式上传 / 重复文件检测暂缓 README 自述"锦上添花";当前无存储节点 → 分布式上传暂时不做。

五、这份文档怎么维护

定位:client改动全史.md 是纵向总账(按功能域回答"这个功能经历了什么");这份 HTML 是横向 + 可视化(按时间回答"哪一天改了什么、坑是谁埋的"),两者互补,不互相替代。 姊妹文件:docs/功能演进图鉴.html(同样的结构,记 worker.js)。