原子收藏全面升级 · 可行性与实现方案¶
历史记录 / Historical record:本文保留当时的计划、研究或验收证据,不代表 0.11 当前能力全集。现行说明见 文档入口;英文入口见 English overview。
面向对象:下一轮执行 agent。本文只做可行性判定与实现方案,不改代码。 需求原文见仓库根目录
下一轮需求书.md的「新需求」章(原子收藏全面升级 / 组件 / 收藏夹)。 延后项(本轮不做、留后续批次的)统一登记在需求书的「留到后续批次」表;本文各章只在自己的位置标注原因与触发条件。 现状基线:分支next/ui-ux-overhaul,HEAD 见git log -1;本文所有「现状」判断都以该提交的代码为准。
0. 结论摘要¶
可行,但要按三层拆开做,不能一次到底。 三层是:元素级可收藏(机制)、预制组件(内容)、白板收藏夹(容器与编辑器)。
| 需求 | 可行性判定 | 主要依据 |
|---|---|---|
| 收藏夹作为「可自由排布的白板」 | 地基已存在,改造量中等 | 今日页已是可配置卡片板:HomeCard + col: main/rail + off 持久化 + 上下/跨栏移动(state/homeOrder.ts、pages/Today.tsx) |
| 预制组件(文字/标题/按钮/数据卡/分隔线/入口) | 可行,工作量在数据接线 | 数据 hook 已在(useCard、useCampusData、课表/作业/通知各自 hook),组件外壳可复用 HomeCard |
| 点阵网格 + 长按拖拽 + 其余项自行让位 | 可行,是本需求的主要工程量 | 长按拖拽机制已有可复用件(components/useNavDrag.ts);让位需要 FLIP 式位移动画(仓库已有先例) |
| 元素级「所见即所得」收藏 | 可行,但必须走「声明式 widget」而不是抓 DOM/传送节点 | 现状是可收藏面按页面登记(各页把 collect.item 塞进菜单);元素级需要新建登记机制 |
| 编辑导航栏(显隐/调序/自定义入口) | 地基已存在,改造量中等 | 栏目管理弹窗已具备「勾选显隐 + ↑↓ 调序 + 恢复默认」(components/TabManageModal.tsx);PC 侧栏偏好已有存取(loadSidebarPrefs / resolveSidebar / saveSidebarPrefs) |
| 用收藏夹替换今日页 | 可行,且与现状同构 | 今日页本身就是「按角色排序的卡片板」,把它降级为「系统预制的 board」即可 |
最需要先定的一件事:元素级收藏的语义。需求原文的「看到什么收藏了之后还是什么(按钮收藏了之后还是这个按钮原封不动)」有两种实现路线,必须选一条:
| 路线 | 做法 | 判定 |
|---|---|---|
| A 声明式 widget(推荐) | 被收藏元素由开发者声明一份 widget 规格(类型 + 属性),收藏夹用同一份组件实现重新渲染 | 真·所见即所得(同一组件同一属性),可多实例、可跨页、可离线;代价是每个可收藏元素要写一份声明 |
| B 抓取 DOM 快照 | 收藏时序列化元素的外形 | 外形像但不可交互,数据是死的,字体/主题一改就崩;与「原子操作」自相矛盾 |
| C 传送真实节点 | 把页面上的那个节点 portal 到收藏夹 | 同一节点不能同时存在于两处;源页面卸载后失效;多实例不可能 |
本文余下部分按路线 A 展开。
1. 需求解读¶
把原文拆成可施工的条目:
- 组件判定标准(原文给出 10 个例子):自身具备完整上下文 + 能独立完成一个原子操作(或一族原子操作)的元素,才算组件。原文还要求「整理出一张表,标注每一个页面元素是否是组件」——见本文第 7 节。
- 元素级可收藏:任何页面上的任何组件都能被收藏,收藏后形态不变(同组件同属性)。
- 预制组件:文字标签、文字标题、跳转按钮、数据卡片(校园卡余额 / GPA / 学分 / 剩余作业 等)、分隔线、跳转入口;允许补充新想法。
- 收藏夹 = 白板:可随意排布、可调某些组件的样式、可加入预制组件;内部记录每个组件的属性与位置。
- 三种用途:替换今日页(今日本身就是系统预制的收藏夹)/ 作为自定义界面并可放进导航栏(导航栏可编辑:显隐、图标、名称;「我的」不可缩减)/ 传统收藏夹。
- 编辑模式:点阵背景、组件锚点吸附点阵、长按拖拽、其余组件自行让位。
2. 现状盘点:可直接复用的地基¶
| 已有能力 | 位置 | 对本需求的作用 |
|---|---|---|
| 今日页卡片板:卡片外壳 + 双栏 + 角色排序 + 折叠 + 显隐 | pages/Today.tsx(HomeCard)、state/homeOrder.ts(HomeOrderRole、homePriorityGroups) |
白板的渲染与排序骨架;「收藏夹替换今日页」天然同构 |
| 卡片位次持久化(含 off 态) | 同上 + 本机存储 | 白板文档持久化的现成范式 |
| 长按拖拽机制(长按判定、跟手、1:1、拖拽态类名) | components/useNavDrag.ts(useNavDrag)、styles/global.css 的 .is-dragging 一族 |
白板编辑模式的拖拽可直接复用 |
| 栏目管理弹窗:勾选显隐 + ↑↓ 调序 + 恢复默认 | components/TabManageModal.tsx(信息页/生活页的 tabLayout) |
「编辑导航栏」交互的现成范式 |
| 侧栏偏好:读取/解析/保存 | state/(loadSidebarPrefs、resolveSidebar、saveSidebarPrefs、SidebarPrefs) |
导航项显隐与调序的存储范式 |
| 导航注册表与归属规则 | state/navigation.ts(NAV_REGISTRY、NavEntry)、state/navOwner.ts(BOTTOM_NAV_PAGES、navOwner) |
导航项的唯一事实源;自定义入口应挂进这张表 |
| 现有收藏(原子)模型 | state/atoms.tsx(AtomRef/AtomView/enc/dec/resolveAtom/searchAtoms)、state/favs.tsx、state/favorites.ts、pages/FavsHomePage.tsx |
传统收藏夹语义(引用式书签);新白板是其超集,需明确两者关系 |
| 可收藏面登记(现为页面级) | 各页把 collect.item 塞进 PageHead 菜单或 ctx 菜单(如 pages/learn/AssignmentsPage.tsx、pages/learn/FilesPage.tsx 等) |
元素级登记要在此之上扩展,不是推倒 |
| 数据源 hook | state/data.ts(useCampusData、useCard 等)、课表/作业/通知各自 hook |
预制数据卡片的取数与新鲜度 |
| 弹层与编辑态范式(Esc、退场、遮罩、点外关闭) | components/*Modal.tsx、state/navStack.ts(useOverlayBack 登记浮层帧) |
白板编辑页/添加组件面板可直接套用 |
结论:需要从零新建的只有两件——元素级 widget 声明与注册表,以及基于点阵的白板布局与编辑模型;余下都是既有能力的扩展或数据化。¶
3. 数据模型(白板文档 v1)¶
原则:一个 board 一份文档,存本机,版本号先行。与仓库既有本机存储范式一致(hwRemind 式 JSON、tabLayout、SidebarPrefs),不引入新依赖、不引入服务端同步。
实现期细化(2026-10-09,P0 开工时实测):既有今日页的布局是按窗口朝向分别持久化的 (
onethu.home.layout.portrait/…landscape),且 PC 窗口宽窄切换会读不同的一份。为不改动现状观感,Board的单元格集合改为按朝向各存一份(cells: Partial<Record<HomeOrientation, BoardItem[]>>); 用户自建板只写portrait,宽窗下按 §6.1 的比例映射。下文模型已按此更新。
interface BoardDoc {
v: 1; // 版本号,迁移用
boards: Board[]; // 至少一个(系统预制的「今日」)
activeBoardId: string; // 当前主页用哪块板
}
interface Board {
id: string; // 稳定 id,导航入口引用它
name: string; // 用户可改
icon: string; // 图标名(走既有图标集)
kind: "system" | "user"; // 系统板(今日)不可删
items: BoardItem[];
}
interface BoardItem {
id: string; // 实例 id(同一 widget 可多实例)
spec: WidgetSpec; // 渲染什么(见第 4 节)
cell: { x: number; y: number; w: number; h: number }; // 点阵坐标,单位=格
props?: Record<string, unknown>; // 用户可调(文字内容、显示项等)
pinned?: boolean; // 「固定」:正常模式下长按不进入快捷编辑(编辑模式仍可长按)
}
要点:
- 单位是格不是像素:
cell存基准单位(手机 4 格为基准;PC 8 列 = 每单位 2 列,见 6.1 的换算表)。像素只出现在渲染期换算,避免设备密度与字号变化导致布局漂移。 - 位置与属性分离:
cell由编辑器写,props由「组件设置」写;渲染层只读。 - 迁移:
v不匹配时按版本逐级升级;v1只做一件事——把今日页现有的位次/显隐表转成一块kind: "system"的 board。 - 容量:估算单条 item 约 200–400 字节,50 个组件约 20KB,远低于本机存储上限;仍要写一条「保存失败」的可重试提示(参考既有存储错误处理)。
- 与现有收藏的关系:
state/atoms.tsx的引用式书签保留,作为kind: "link"的一种 WidgetSpec 接入白板——传统收藏夹因此是白板的一个子集,不需要两套 UI。
4. 元素级收藏:声明式 widget¶
4.1 三件东西¶
- WidgetSpec:描述「渲染什么」的可序列化声明。
- Widget 注册表:
spec.kind到渲染实现的映射,集中在一处,供白板与「添加组件」面板共用。 - 可收藏声明:页面元素声明自己对应哪个 spec,以及当前实例的属性。
type WidgetSpec =
| { kind: "atom-link"; ref: string } // 既有原子书签(页/实体)
| { kind: "nav"; page: Page; params?: LearnNav } // 跳转按钮/入口
| { kind: "card"; card: WidgetCardId } // 预制数据卡(余额/GPA/作业…)
| { kind: "text"; text: string; variant: "label" | "title" }
| { kind: "divider" }
| { kind: "custom"; id: string; props: Record<string, unknown> }; // 页面自带的复杂组件
4.2 页面元素怎么声明可收藏¶
推荐形态:一个包装组件,不改变原渲染。
// 复杂组件(页面自带的卡片/条目)
<Collectable spec={{ kind: "custom", id: "card.balance", props: { days } }}>
<BalanceCard days={days} />
</Collectable>
// 简单入口(按钮/行)
<Collectable spec={{ kind: "nav", page: "card" }}><button>校园卡</button></Collectable>
Collectable 的职责(都很薄):
- 在容器上挂
data-collectable与一份运行时可达的 spec(局部状态即可,不必进全局 store); - 把「收藏此组件」注入该元素的菜单:桌面右键、移动端长按(复用既有 ctx 菜单宿主与
useNavDrag的长按判定); - 被收藏时只做一件事:把 spec(必要时合并用户在设置里改过的 props)追加进白板。
这样「看到什么收藏了之后还是什么」由同一份组件实现 + 同一份属性保证,而不是靠外形快照。
4.3 判定「是否组件」的三条硬规则¶
- 上下文完整:脱离所在页面单独出现时,用户仍知道它是什么、作用于什么。反例:只有「充值」两字的按钮、只有「下载」两字的按钮、缺少上下文的下拉菜单。
- 能完成原子操作:点击后发生的事不依赖页面其它部分(跳转、提交、切换某设置);纯装饰不算。
- 可独立实例化:能用一份属性把它从零渲染出来。反例:依赖兄弟节点的状态、依赖所在列表的滚动位置。
显示型卡片(GPA 卡、余额卡、学生卡)按规则 2 视为组件:显示本身就是它完成的原子操作,与需求原文对其「显示 GPA 的卡片」的判定一致。
4.4 注册策略:两档成本与七项定义¶
覆盖范围(霖 2026-10-09 定):本轮把第 7 节表中判定为组件的元素全部注册(约 40–50 个)。
昂贵档的本轮口径(霖 2026-10-09 定):先复用现有外壳——今日页的 HomeCard 本身就是独立卡壳,这些卡片先以「原页面的卡壳」形态进白板;把卡片本体抽成更干净的独立组件留到后续批次。
| 档 | 适用 | 做法 | 成本 |
|---|---|---|---|
| 便宜档 | 带完整上下文的入口(服务行 / 导航按钮 / 列表项) | 一行 Collectable 包装,spec 为 nav 或 atom-link;白板用通用入口卡渲染,不需要新组件 |
低 |
| 昂贵档 | 复杂卡片(课表卡 / 余额卡 / GPA 卡 / 学生卡 / 通知列表 …) | 需在脱离原页面的情况下独立渲染;本轮先复用现有卡壳 | 中 |
每个注册要齐的七项定义:
| 字段 | 用途 | 谁消费 |
|---|---|---|
kind / id |
注册表主键与 spec 引用 |
渲染层、白板文档 |
title |
「添加组件」面板中的显示名 | 添加面板 |
description |
长按菜单中的详情 | 组件菜单 |
render(props) |
独立渲染(昂贵档的核心) | 白板 |
defaultSize + sizeRange |
初始占格与允许尺寸(如课表卡不允许缩到 1x1) | 编辑器 |
editableProps + 表单声明 |
长按菜单中的编辑(抽屉式;无可编辑项则不显示该项) | 编辑器 |
| 可序列化上下文 | 收藏时写入 spec.props 的引用 |
Collectable |
两条落地纪律:
- 覆盖面按第 7 节全表推进,但按页面切成多个执行单元(每个单元:代码 + 覆盖断言一个提交,文档一个提交),避免一个巨型提交无法复核。
- 注册组件时同一提交内补该页面的覆盖断言(第 11 节的
collectable-coverage),否则后续删掉声明无人察觉。
描述文案要求:写成用户能读懂的简短说明,并讲清它需要什么上下文(例:「校园卡余额与最近流水,需要登录」)。这段文字出现在用户的长按菜单里,不是给开发者看的注释。
5. 预制组件¶
5.1 类型清单¶
| spec.kind | 形态 | 用户可调属性 | 备注 |
|---|---|---|---|
text(variant=label) |
文字标签 | 文字、字号档、颜色令牌档 | 需求原文明确要求 |
text(variant=title) |
文字标题 | 文字、字号档 | 与标签分开,避免用户用字号替代层级 |
nav |
跳转按钮 | 目标页/参数、图标、文字 | 覆盖需求原文的「通往某一个页面的按钮」「跳转入口」 |
card |
数据卡片 | 显示项、时间窗(如校园卡流水天数) | 见 5.2 数据源表 |
divider |
分隔线 | 粗细档、是否带标题 | 需求原文明确要求 |
atom-link |
既有原子书签 | 展示形态(行/卡) | 与现有收藏互通,见第 3 节 |
custom |
页面自带的复杂组件(余额卡、GPA 卡、学生卡…) | 由该组件自行声明 | 元素级收藏的主要出口 |
5.2 数据源接线(预制数据卡片)¶
| 卡片 | 数据来源(现状) | 新鲜度策略 |
|---|---|---|
| 校园卡余额 / 最近流水 | useCard(days)(state/data.ts) |
沿用既有 SWR:进入可见即重验证,失败保留旧值 |
| 门户聚合数据(含成绩入口) | useCampusData() |
同上 |
| 课表(今日/本周) | 今日页已有的课表数据链(homeOrder 的时段判定可复用) |
同上 |
| 剩余作业 / 作业提醒 | 作业页与提醒链(hwRemind、作业 hook) |
同上 |
| 通知 | 通知页数据链 | 同上 |
| GPA / 学分 | 信息页成绩栏目数据链 | 同上;若取数成本高,先只读缓存,进入可见再刷新 |
约束:霖已裁定为白板允许新增 state 层取数 hook,但只读既有接口——即复用信息页已有的数据链并做缓存,不新造外部请求(沿用「我的」页的既有纪律)。若某张卡片所需的既有数据链确实不存在且无法在 state 层组装,则降级为「跳转入口」形态。
5.3 补充建议(需求原文允许提新想法)¶
- 快捷动作卡:把「打卡/签到」这类高频动作用
custom暴露成一个可收藏组件,比跳转少一步。 - 空态组件:用户删到只剩一两个组件时,白板要有「添加组件」的空态引导,否则白板观感异常。
- 模板起步:提供 2–3 套预制排版(课表+作业+通知 / 余额+日程 / 极简),用户可一键套用后微调——比自己从零摆更容易留住人。
- 导入导出:白板文档序列化后可作为文本分享(与插件市场的仓库机制同款,无需新基建)。
6. 白板与编辑模式¶
6.1 布局模型:格而非自由坐标¶
需求要求「背景点阵 + 锚点吸附」,并已给出尺寸档位(1x1 / 1x2 / 2x2 / 2x1 / 1x3 / 2x3 …)与宽度上限 4(顶满页面,含页边距)。据此定案:手机 4 列 / PC 8 列(霖 2026-10-09 补充修正),行数不设上限。
坐标基准(关键约定):文档中的 cell 一律以手机 4 格为基准单位记录,需求原文的「宽度最大 4」也按此基准理解。渲染时按设备换算:
| 设备 | 物理列数 | 1 个基准单位 = | 最大宽度 |
|---|---|---|---|
| 手机 | 4 列 | 1 列 | 4 单位(顶满,含页边距) |
| PC | 8 列 | 2 列 | 4 单位(= 8 列,顶满) |
这样同一块板在两端按比例一致(手机上的 1x1 在 PC 上是 2x2 个物理列),不需要为两端各存一份布局;PC 的吸附点阵即「2 个物理列为一格」,与手机端一一对应。若将来要放开 PC 的半格精度,再引入第二套基准并写迁移。
| 方案 | 判定 |
|---|---|
| 格布局(已定案) | 让位规则可确定、吸附天然成立、跨设备一致性好(手机 4 列 / PC 8 列,按基准单位换算);代价是「随意排布」被约束到格点 |
| 自由坐标 | 更自由,但让位/防重叠/跨设备缩放都要自己解,且容易出现「看起来没对齐」的观感问题 |
6.2 渲染与让位¶
- 渲染顺序 = 文档顺序;每项按
cell绝对定位(transform: translate(...)而非top/left,避免布局抖动)。 - 拖拽中:被拖项跟手(
transform逐帧写),其余项按目标格即时重排;重排用 FLIP(先记录旧位置 → 改格 → 读取新位置 → 反向位移 → 过渡回零),与仓库既有跨栏让位实现同源。 - 拖拽结束:把
cell写回文档(一次写入,过程中只改内存)。 - 落位规则(按二次补充的需求更正):组件落在松手时所在的格,不做「向上补齐」——需求明确允许中间空任意多格(坐标可以是 (1,1000))。
- 让位规则:只处理重叠——落点与既有组件重叠时,被压到的组件向下推(霖 2026-10-09 定案,对齐安卓桌面行为),拖拽期即实时预览让位结果,松手定稿。
6.3 编辑模式交互¶
| 操作 | 入口 | 复用 |
|---|---|---|
| 进入/退出编辑 | 收藏夹页面右上「···」菜单中的「编辑」 | 既有页面级菜单机制 |
| 长按 → 拖拽 | 长按先出组件菜单;产生位移才升级为拖拽(编排见 6.5) | components/useNavDrag.ts 的长按与位移判据 |
| 调大小 | 选中后四边拖拽点(需求定稿);尺寸档位 1x1 / 1x2 / 2x2 / 2x1 / 1x3 / 2x3 …,宽度上限 4 单位 | 新增;约束到格,松手写回 w×h |
| 删除 | 选中后删除按钮,或拖到删除区 | 新增 |
| 组件设置 | 选中后「设置」→ 弹层改 props |
既有弹层范式(含 Esc 与返回键,两处都要接,见本轮 §28.14 教训) |
| 添加组件 | 编辑态底栏「+」→ 面板(预制组件 + 可收藏项) | 新建面板,数据取第 5 节 |
6.4 移动端与老引擎注意事项(WebView 96)¶
- 拖拽期只动
transform与opacity;will-change: transform只在拖拽态声明(本仓库已定此口径)。 - 点阵背景用一层 CSS 渐变平铺(
background-image: radial-gradient(...)+background-size),不要用大量 DOM 点。 touch-action: none只加在可拖拽项上,且拖拽结束即移除,避免拦截页面滚动。- 长按与滚动互斥:手指移动超过阈值即取消长按(与导航拖拽同一判据)。
- 减弱动效时:让位改为即时位移(无 FLIP 过渡),拖拽仍跟手。
7. 组件判定表(需求原文要求交付的表格)¶
判定依据 = 第 4.3 节三条硬规则。形态 列给出接入白板时的 spec 建议。本表按页面组织,覆盖主流程页面;详情页与二级页在实现期按同一标准补齐。
7.1 今日(pages/Today.tsx)¶
| 元素 | 是否组件 | 依据 | 形态 |
|---|---|---|---|
| 打招呼标题(上午/中午/晚上好) | 是 | 完成打招呼,无额外上下文(原文举例) | text(title) 或 custom |
| 课表卡 | 是 | 完整上下文 + 显示当日课程 | custom(card.schedule) |
| 校园卡余额条 | 是 | 完整上下文 + 显示余额 | custom(card.balance) |
| 作业卡 | 是 | 完整上下文 + 显示待交作业 | custom(card.homework) |
| 通知卡 | 是 | 完整上下文 + 显示最近通知 | custom(card.notices) |
| 最近使用 / 猜你喜欢 行 | 是 | 每行携带该原子的标题与说明 | atom-link |
| 卡片标题行的折叠箭头 | 否 | 无独立语义,依附于卡片 | — |
| 卡片编辑工具(上移/跨栏/隐藏) | 否 | 编辑态控件,不参与收藏 | — |
7.2 待办(pages/TasksPage.tsx)¶
| 元素 | 是否组件 | 依据 | 形态 |
|---|---|---|---|
| 统计数字卡(各自一项) | 是 | 显示某类计数即其原子操作 | custom(card.task-stat) |
| 未完成作业项 | 是 | 携带作业标题/类型等上下文(原文举例) | custom 或 atom-link |
| 筛选/分段控件 | 否 | 需所在列表上下文 | — |
| 搜索框 | 否 | 无上下文 | — |
7.3 服务(pages/services,svc-row 一族)¶
| 元素 | 是否组件 | 依据 | 形态 |
|---|---|---|---|
| 单个服务入口行(含图标 + 名称 + 说明) | 是 | 完成跳转且上下文完整(原文举例:云盘资料库入口) | nav |
| 分类标题 | 否 | 无操作 | — |
| 页面级搜索 | 否 | 无上下文 | — |
7.4 我的(pages/Mine.tsx)¶
| 元素 | 是否组件 | 依据 | 形态 |
|---|---|---|---|
| 学生卡(姓名/学号/院系/邮箱) | 是 | 显示型原子操作,上下文完整 | custom(card.profile) |
| 三张数字卡(各自一项) | 是 | 各自显示一项统计 | custom(card.mine-stat) |
| 五项服务入口(设置/邮箱/云盘/成绩/收藏) | 是 | 跳转且上下文完整 | nav |
| 页头大标题 | 否 | 只是标题 | — |
| 头像 | 否 | 无独立操作 | — |
7.5 网络学堂(pages/learn)¶
| 元素 | 是否组件 | 依据 | 形态 |
|---|---|---|---|
| 课程列表项 | 是 | 携带课程名/教师/学期上下文 | atom-link |
| 课程详情页各卡片(作业/文件/通知/讨论) | 是 | 各自完整上下文 | custom |
| 作业项(列表与详情) | 是 | 携带作业标题/类型/截止 | atom-link 或 custom |
| 文件行 | 是 | 携带文件名与课程上下文 | atom-link |
| 通知项 | 是 | 携带标题与来源 | atom-link |
| 学期切换控件 | 否 | 切换视图的控制,脱离页面无意义 | — |
| 搜索框 / 筛选 | 否 | 无上下文 | — |
7.6 信息(pages/info)¶
| 元素 | 是否组件 | 依据 | 形态 |
|---|---|---|---|
| 各栏目数据卡(成绩/体测/考试/教学评估/校历/新闻/个人信息/课程信息) | 是 | 各自显示一类数据,上下文完整 | custom(card.info-*) |
| 校园卡页的余额卡与流水段 | 是 | 上下文完整(消费相关一族) | custom(card.balance, card.tx) |
| 校园卡页的「充值」按钮 | 否 | 仅有「充值」两字,需「校园卡」上下文(原文举例) | — |
| 宿舍电费卡 | 是 | 完整上下文 + 显示与充值入口 | custom(card.dorm) |
栏目标签栏(seg-track) |
否 | 页面级导航控制 | — |
| 新闻列表项 | 是 | 携带标题与时间 | atom-link |
7.7 日程 / 云盘 / 设置¶
| 页面 | 元素 | 是否组件 | 依据 | 形态 |
|---|---|---|---|---|
| 日程 | 课程/事件条目 | 是 | 携带时间与名称 | atom-link |
| 日程 | 时段刻度与网格 | 否 | 装饰与容器 | — |
| 云盘 | 资料库入口 | 是 | 原文举例 | nav |
| 云盘 | 文件/文件夹行 | 是 | 携带名称与路径 | atom-link |
| 云盘 | 配额条 | 是 | 显示型原子操作 | custom(card.cloud-quota) |
| 设置 | 单个设置项(开关/选择/跳转) | 是 | 原文举例:操作描述清楚、上下文完整 | custom(setting.*) |
| 设置 | 分组标题与页头 | 否 | 无操作 | — |
7.8 详情页通用规律¶
| 元素 | 是否组件 | 依据 |
|---|---|---|
| 详情页的实体头部(标题 + 元信息) | 是 | 上下文完整,可作为入口收藏 |
| 详情页的操作按钮组(网页端打开/浏览器打开等) | 是 | 携带该实体的地址上下文(非裸按钮) |
| 正文富文本 | 否 | 无独立操作 |
| 面包屑/返回键 | 否 | 导航控件 |
给实现期的判据速记:裸按钮、裸下拉、纯装饰、纯导航控件都不是组件;带标题/名称/数值/状态的自洽块,以及携带完整目标信息的入口,都是组件。¶
6.5 编辑态视觉与长按编排(按二次补充的需求定稿)¶
进入编辑态(右上「···」→「编辑」)后:
| 元素 | 编辑态表现 |
|---|---|
| 背景 | 固定间隔点阵(CSS 渐变平铺,非 DOM 点) |
| 组件 | 比正常模式略小,四边露出背后的点阵(正常态边框会盖住点阵) |
| 点击组件 | 不再触发组件自身的点击,而是直接弹出该组件的长按菜单 |
| 进入/退出 | 有过渡动画(点阵渐显,组件尺寸与位置过渡) |
正常模式下的长按编排(手感对齐安卓桌面小组件移动):
- 长按到阈值 → 先弹该组件的长按菜单(同时给一次触感反馈,走既有触感分级);
- 若手指产生位移 → 本次操作升级为移动:长按菜单自动收回,其余组件模糊 + 渐出 + 略微缩小,手拿的组件尺寸不变,背景点阵渐显用于对齐;
- 拖拽期实时预览松手后的布局(含让位结果);
- 松手 → 组件落到目标格,自动回到正常模式(点阵与模糊反向复原)。
实现要点:长按判定与「位移升级」判据复用 components/useNavDrag.ts 的既有口径(超过阈值即取消长按转拖拽);模糊与缩小只动 filter/opacity/transform,点阵显隐用 opacity 过渡;减弱动效时点阵直接显示、让位即时。
6.6 组件长按菜单(四项)¶
| 菜单项 | 行为 | 备注 |
|---|---|---|
| 删除(标红) | 从白板移除该组件 | 走既有危险色令牌 |
| 编辑 | 打开该组件的编辑菜单(抽屉式) | 仅当该 widget 声明了可编辑属性时才出现(需求原文:如果有的话) |
| 详情 | 显示该组件的描述 | 因此 widget 注册表每条要带一份描述文本 |
| 固定 | 写入 pinned: true |
固定后:正常模式长按不再触发快捷编辑;编辑模式中仍可长按(可拖动/可删) |
「固定」是每项的属性(需求原文按组件描述),不是整块板的开关。
7.10 实现状态对账(2026-10-11 逐行核对)¶
本表如实记录 §7 各行的落地状态。判「已实现」的标准是:页面上真的有可收藏声明(Collectable),且白板能渲染出内容(有卡体或走跳转/文字档)。
| §7 行 | 形态 | 状态 |
|---|---|---|
| 7.1 打招呼标题 | text |
未实现(便宜档,未包装) |
| 7.1 课表/余额/作业/通知卡 | custom |
等价实现:这四张卡就是系统白板的内容本身,已在板上 |
| 7.1 最近使用 / 猜你喜欢行 | atom-link |
未接入白板(走既有原子收藏,落在书签收藏夹) |
| 7.2 待办统计数字卡 | custom |
未实现 |
| 7.2 未完成作业项 | custom |
未实现 |
| 7.3 单个服务入口行 | nav |
已实现(本轮之前) |
| 7.4 学生卡 | custom |
已实现(本轮) |
| 7.4 三张数字卡 | custom |
已实现(本轮之前) |
| 7.4 五项服务入口 | nav |
已实现(本轮之前) |
| 7.5 课程列表项 / 作业项 / 文件行 / 通知项 | atom-link |
未实现 |
| 7.5 课程详情页各卡片 | custom |
未实现 |
| 7.6 各栏目数据卡(8 个) | custom |
已实现(本轮之前) |
| 7.6 校园卡余额卡与流水段 | custom |
未实现 |
| 7.6 宿舍电费卡 | custom |
未实现 |
| 7.6 新闻列表项 | atom-link |
未实现 |
| 7.7 日程课程/事件条目 | atom-link |
未实现 |
| 7.7 云盘资料库入口 | nav |
已实现(本轮) |
| 7.7 云盘文件/文件夹行 | atom-link |
未实现 |
| 7.7 云盘配额条 | custom |
未实现 |
| 7.7 设置项 | custom |
未实现 |
结论:§7 表共 20 组元素,已实现 6 组,未实现 14 组。未实现的都是同一套机制的铺量(页面加 Collectable 声明 + 必要时发布卡体 + 目录登记),没有新的机制问题;已按需求书要求登记进「留到后续批次」表。
7.9 收藏夹列表页(多收藏夹)¶
需求二次补充明确:收藏界面里存在多个收藏夹,每个都有自己的长按菜单。因此既有 pages/FavsHomePage.tsx 要从「一页书签列表」升级为「收藏夹(board)列表页」:
| 元素 | 说明 |
|---|---|
| 收藏夹卡片/行 | 显示名称、图标、组件数量、是否当前今日页 |
| 新建收藏夹 | 列表页的显式入口(预置几套起步模板可选,见 P3) |
| 长按菜单 | 作为今日页 / 上移 / 下移 / 删除(标红) |
| 点击 | 进入该收藏夹(正常模式浏览) |
与本方案数据模型的对应:列表 = BoardDoc.boards 的顺序;「作为今日页」= 写 activeBoardId;上移/下移 = 交换数组次序;删除 = 从数组移除(系统板「今日」不可删,需要单独的置灰口径)。
因此底栏「收藏」这一格保留,语义从「收藏的原子列表」变为「收藏夹列表」;霖已裁定传统收藏并入白板(作为 atom-link 组件),不再维护第二套列表;传统收藏(state/atoms.tsx 的引用式书签)作为其中一种 widget(atom-link)存在——这块需要霖确认,见第 12 章。
8. 导航集成¶
8.1 现状与改造点¶
底栏目前是硬编码数组(components/Layout.tsx:370 的 BOTTOM_NAV),归属规则在 state/navOwner.ts(BOTTOM_NAV_PAGES、navOwner,且上轮已定「任何页面恰好高亮一项」的 E6 口径)。
改造为数据驱动,与既有偏好存储同构:
interface NavPrefs {
v: 1;
slots: Array<{ id: string; kind: "page" | "board"; page?: Page; boardId?: string; icon: string; label: string; hidden?: boolean }>;
}
要点:
- 默认值等于现状:首次运行解析出的槽位与今天的五项完全一致,用户在编辑前观感零变化(这点决定了改造可以随时回退)。
- 「我的」不可缩减:在解析器中硬性保证该槽位存在且可见(设置入口在其中),并配护栏。
- 自定义板作为槽位:
kind: "board"的槽位渲染该板;图标与名称取 board 的icon/name。霖已裁定:除「我的」外任意槽位(今日/待办/服务/收藏)均可被自定义板替换。 - 归属规则要同步扩展:
navOwner目前按页面归属;引入 board 槽位后,规则要变成「槽位归属」,否则 E6 的「恰好一项高亮」与返回键归属会失真。这是本需求爆炸半径最大的一处,建议单独一个执行单元完成,并复查 E6 相关护栏。
8.2 与今日页的关系¶
今日页当前由 HomeCard + 位次/显隐表驱动。方案是把它降级为一块 kind: "system" 的 board:
- 初次启动:用现有位次/显隐表生成这块板(迁移见第 3 节),渲染仍走
HomeCard,观感与位次不变; - 用户编辑后:写回 board 文档;此后今日页 = 一块普通板;
- 「用收藏夹替换今日页」= 把
activeBoardId指向用户板(今日槽位因此显示用户板,系统板仍在,可切回)。
9. 分期实施¶
| 阶段 | 内容 | 完成判据(DoD) |
|---|---|---|
| P0 地基 | board 文档模型 + 持久化 + 只读渲染;把今日页接成系统板 | 今日页改由 board 数据驱动后,位次/显隐/折叠与现状逐项一致(K30 读数对照 + 护栏);迁移可重复执行且幂等 |
| P1 元素级收藏 + 编辑器 | Collectable 包装 + widget 注册表 + 编辑模式(点阵/吸附/拖拽/让位/增删/属性设置);组件注册覆盖第 7 节全表(约 40–50 个,按页面切成多个执行单元),昂贵档先复用现有外壳 |
每页的组件都能被收藏并在白板中独立渲染;从 3 个不同页面各收藏 1 个组件后,在白板上拖拽、让位、删除、改属性,重启后全部保持;WebView 96 上拖拽跟手无跳变 |
| P2 预制组件 + 导航集成 | 预制组件目录 + 数据卡片接线 + 导航数据化 + 自定义板槽位 + 图标/名称编辑 | 用白板替换今日页;导航栏增删一项自定义板;「我的」无法被移除;默认布局与改造前一致 |
| P2.5(霖已排期) | 起步模板(2–3 套预制排版)与空板引导 | 新用户开箱即用;空板不至于像出错 |
| P3(本轮不做) | 导入导出、快捷动作卡 | 不在本轮范围;若后续排期,按此顺序推进 |
纪律沿用本仓库既有要求:每个单元「代码 + 护栏」一个提交、「文档回填」一个提交;四项门禁全绿(pnpm guard / pnpm test / tsc --noEmit / docs-prose-lint);反例逐条注入验证。
10. 风险与取舍¶
| 风险 | 影响 | 处置 |
|---|---|---|
| widget 属性过期 | 收藏时抓到的数据快照以后失去意义 | 硬性规则:spec 只存引用与稳定标识(页面、实体 id、原子 ref),不存会过期的数据本身;数据由渲染期的 hook 现场取 |
| 数据源缺失 | 某类数据卡没有现成 hook | 该卡片降级为「跳转入口」形态;不自造请求(与「我的」页既有纪律一致) |
| 布局模型选错 | 让位与吸附的复杂度翻倍 | 采用格布局;自由坐标列为不采用方案并写明原因 |
| 导航模型改造 | E6 归属、返回键、高亮规则全线受影响 | 独立执行单元;默认值等于现状;单独复查 E6 相关护栏 |
| WebView 96 性能 | 老引擎上拖拽/让位掉帧 | 拖拽期只动 transform/opacity;点阵用 CSS 渐变而非 DOM 点;will-change 只在拖拽态声明;减弱动效时让位改为即时 |
| 长按语义冲突 | 页面已有的长按(上下文菜单)与编辑态长按(拖拽)相互冲突 | 按模式分流:编辑态长按=拖拽;非编辑态长按=既有菜单(其中含「收藏此组件」),互不覆盖 |
| 存储迁移 | 文档损坏或版本不符 | 解析失败回落安全默认(保留系统板),并提示可重试;迁移幂等 |
| 与现有收藏重复 | 两套收藏 UI 并存 | atoms.tsx 保留为 atom-link 形态,白板是其超集,不新建第二套列表 UI |
11. 实现期必须补的护栏¶
| 护栏 | 断言内容 | 反例 |
|---|---|---|
board-model |
文档 schema/版本/迁移幂等/解析失败回落 | 注入损坏文档 → 必须回落默认且不崩 |
board-grid |
给定拖拽目标格,落位与让位结果确定 | 改动落位规则 → 结果断言必须红 |
board-crossdevice |
同一份文档在 4 列与 8 列两种宽度下,各组件占宽比例一致(按基准单位换算) | 把 PC 的换算系数写成 1 → 比例断言必须红 |
widget-registry |
文档中出现的每个 spec.kind 都有渲染实现 |
文档引用未知 kind → 渲染空态组件而不是抛错 |
collectable-coverage |
第 7 节表中判为组件的元素都有对应声明 | 删掉一个声明 → 该页面的覆盖断言必须红 |
nav-prefs |
默认布局等于现状;「我的」不可隐藏/移除 | 隐藏「我的」→ 必须红 |
board-tokens |
白板与编辑器的样式只用已定义令牌 | 引用未定义变量 → 现有令牌守卫必须红 |
board-size |
交付清单 docs/白板组件尺寸档清单.md §1 与 widgets.ts 的 SIZE_PLANS 逐条一致(组件集合、档位列、推荐档位列);推荐档位在清单内且可吸附;档位不越基准单位 |
增删一个档位却不改清单 → 必须红 |
| 真机读数 | K30(WebView 96)拖拽/让位的逐帧位置与帧间隔;K90 与 PC 各一轮 | 让位出现跳变 → 判定不通过 |
12. 交接要点¶
这不是「给收藏夹加功能」,而是把「今日页已有的卡片板」升级成用户可编辑的组件白板,再把它接进导航;地基(卡片板、偏好存储、栏目管理、长按拖拽)都在仓库里,真正的新建只有两件:元素级 widget 声明与注册表、基于点阵的白板布局与编辑器。
13. 尺寸档位(2026-10-11 增补)¶
13.1 霖的四条要求¶
- 取消所有组件的大小自由控制,改为针对特定组件分别设计不同大小下的排版;
- 大小控制放进「添加组件」的展开菜单:展开态由单张预览改为横向列表,选中哪一档就加哪一档;
- 分割线 / 标题 / 按钮 / 列表保留自由控制,其余改为挡位控制;长按「改变大小」时复用该抽屉挑选档位;
- 给出需要逐档设计排版的组件清单。
13.2 本轮确认的口径¶
- 「按钮」指入口型组件(卡体是一行入口按钮);「标题」同时包含文字标题与文字标签。
- 档位沿用本仓库细网格(1 单位 = 手机 1 列),最小档保留 1×1,不套用小米的 2×2 起。
- 「改变大小」复用同一个抽屉:自动选中当前档位,底部按钮换成「修改大小」,确认后替换该位置组件的尺寸,四周组件按既有重排算法让位。
- 历史文档不做一次性迁移;尺寸在渲染与写入两处按最近档位吸附。
- 挡位组件退出按内容撑高的拟合(档位是唯一尺寸);保留自由控制的列表仍按内容拟合。
13.3 实现¶
- 目录:
WidgetDef增加sizeControl与sizeTiers;纯核心新增sizeControlOf/isTierControlled/sizeTiersOf/snapToTier/tierIndexOf/normalizeSize/sizeKey/tierLabel(提交 74fd2961)。 - 登记:自由控制的四类由
state/widgets.ts的freeSizeWidgetIds()汇总;其余 15 个组件在SIZE_PLANS登记档位与推荐档位(提交 74fd2961)。 - 界面:抽屉展开态改为
components/WidgetSizes.tsx的档位卡片列表;「改变大小」走抽屉的resize模式;白板页槽位新增data-size与data-size-control两个排版钩子,卡体渲染器第二个参数拿到当前档位(提交 575e888c)。 - 档位列表先做成「滚动到中线即选中」,霖当日指出看不出哪一档被选中,随即改为段落式卡片(1px 描边 + 16px 圆角 + surface 底 + 阴影,对齐待办页作业卡片;点击即选中,选中态用强调色描边、强调底色、对勾与「已选 W×H」四重表达)。
13.4 真机读数¶
K90 / 25102RKBEC / Android 16 / applicationId app.onethu.desktop.dev,APK md5 abe46dcb527d07a5db46dba219ebc85e(USB 调试,序列号 3540f5d7)。
- 档位卡片(余额速览,两个档位):
data-size-option依次4x1/4x2;卡片border-radius: 16px;选中卡border-color: rgb(107,155,255)(即 --accent)、background: rgba(107,155,255,0.14)(即 --accent-soft)、aria-checked=true;未选中卡border: 1px rgba(255,255,255,0.1)、background: rgb(21,26,36);头部文字为「选择大小 / 已选 4×1」。 - 点击最后一档(改前会被判成倒数第二档):
activeSize由2x1变为2x2,头部变为「已选 2×2」。 - 添加:选中 2×2 后落库,槽位
data-size="2x2"、data-size-control="tiers"、grid-row: 14 / span 2。 - 改变大小:长按菜单为「改变大小 / 删除 / 详情 / 固定」;点开后
data-add-mode="resize"、按钮文字「修改大小」、预选2x2;改点 1×1 并确认后槽位变为data-size="1x1"、grid-row: 14 / span 1,抽屉关闭。 - 自由控制:文字标题的展开态没有档位列表,只有
.add-detail-free与提示「这个组件保留自由调整大小,添加后可在编辑态拖动四边把手。」 - 同轮修复的输入法 bug:改前打开抽屉时
activeElement落在.add-drawer内的INPUT、预览区可聚焦输入随卡体异步挂载有数十个(实测 44)、dumpsys input_method的mInputShown=true;改后activeElement为BODY、预览区可聚焦输入 0、mInputShown=false。改前的截图下半屏是未截入画面的输入法区域,改后的截图画面占满全高。 - 输入法真因:抽屉预览渲染的是真实卡体,「网络学堂搜索」带
autoFocus输入框,抽屉一挂载就拿到焦点。修法是让预览区的输入控件失去可聚焦性(disabled+tabIndex=-1),用MutationObserver覆盖异步挂载的卡体,并留一道focusin兜底失焦。
13.5 护栏¶
- 新增
tools/board-size-test.mjs:把交付清单docs/白板组件尺寸档清单.md§1 与SIZE_PLANS逐条对账(组件集合相等、档位列与推荐档位列逐字相等、推荐档位在清单内且可吸附、档位不越基准单位),已接进pnpm guard。 board-edit-test:档位归属与吸附、显式档位落库、抽屉段落卡片、data-size钩子,以及「不再用onScroll判定选中」。widget-meta-test:sizeControlOf / sizeTiersOf / snapToTier / normalizeSize / tierIndexOf的行为断言。widget-coverage-test:非自由控制的卡片必须登记档位的静态断言。
13.7 循环卡片流与 OH 胶囊让位(同日二次改版)¶
霖的现场要求:档位选择要模仿「待办-作业列表」的样式与动效——前台只显示一张放大的卡片、左右两侧各显示缩小的相邻卡片、列表循环,底部再加点状指示栏;另外 OH 的「聊点什么吧」胶囊正好完全挡住了确认按钮。
改法:components/WidgetSizes.tsx 的档位列表改为横向循环卡片流,位移模型与动效同 TasksPage 的滚筒(pos 浮点档位 + rAF 逐帧补间 + 拖拽 1:1 跟手 + 惯性投射后吸附;缩放与透明度只由「离前台的距离」决定;取模回绕实现循环;底部点状指示栏可点击直达某一档)。抽屉按 .dock-island 与抽屉底部的实测重叠量写 --dock-clearance,由 padding-bottom 抵消;展开态高度由 50vh 提到 56vh 以容纳卡片流。
真机读数(K30 Pro / lmipro / Android 12 / WebView 96.0.4664.104,APK md5 3c122e1d97ba069546a17ed7e2879b8d;该机是仓库的兼容性下限):
- 卡片流渲染三张卡(
data-size-slot为 -1 / 0 / +1);前台卡transform: matrix(1, 0, 0, 1, …)、opacity: 1、aria-checked=true;左右两张matrix(0.9, 0, 0, 0.9, …)、opacity: 0.68。 - 拖拽 300px 后「已选」由 1×1 变为 2×2,左右两张同步换成 2×1 与 1×1(循环回绕),指示栏当前点随之移动。
- 让位:
--dock-clearance: 73px;确认按钮底边 671px ≤ 胶囊顶边 696px,按钮完全在胶囊之上。 - 预览区可聚焦输入仍为 0(输入法修复在 WebView 96 上同样成立)。
护栏:board-edit-test 增加卡片流容器与指示栏、循环回绕(NEIGHBOURS + 取模)、侧卡按距离缩放淡出、逐帧补间、touch-action: none,以及 --dock-clearance 让位量与展开态高度档的断言。
13.6 交付清单¶
docs/白板组件尺寸档清单.md:15 个需要逐档设计排版的组件(档位、推荐档位、各档建议呈现什么、设计勾选)与 35 个保留自由控制的组件。霖按该清单逐个设计排版。
13.8 霖报的四个现场 bug:定位与修复(第三轮)¶
四个都已在真机复现并修复。读数取自 K90(myron / Android 16 / WebView 143,APK md5 0ef73266991b7c7f18600d9ad7421b53);卡片流另在兼容性下限 K30 Pro(Android 12 / WebView 79)上过了一遍。
① 收起抽屉后顶栏右上角「···」消失。 取证时间线:抽屉打开时页面里有 4 个 .page-head、顶栏 more=true;抽屉一收起 .page-head 变 1、more=false。真因是抽屉用真实卡体当预览,网络学堂搜索页这类卡体自带 PageHead,它们把抽屉内部的空菜单写进全局 pageChrome 并成为 owner;抽屉收起时最后一个卸载把 chrome 清空,而白板页不会因此重渲染,顶栏就一直读到空菜单。修法:PageHead 记录「自己是否在预览里」(.add-preview-box / .widget-size-box / .add-drawer 之内),预览实例在同步与清理两处都跳过;另给 chrome 快照加上菜单内容签名并在提交后补一次同步(被清空就写回来)。修复后抽屉打开与收起都保持 more=true。
② 抽屉只剩窄窄一行、下面全是空白。 真因是底部点状指示栏被全局 button { min-height: 36px } 拉到 36px 高(即 ④),展开态内容总高超出 56vh,抽屉内容被挤成顶部一条。④ 修好后读数恢复:抽屉 451px / 卡片流 180px / 点栏 6px / 确认按钮 38px。
③ 卡片流「从右向左划出现反向」。 真因是卡片节点原先按「离前台第几格」取 React key,pos 跨过半格时每个节点的档位与位移整体跳一格,看起来就是整条卡片流反向闪一下。修法:改为按绝对档位位置 p 取 key,位移 p − pos 连续。读数:跨半格时三张卡各移动 30px(等于指针位移),无跳变;松手后「已选」由 1×1 变 2×1。
④ 圆点被纵向拉伸成椭圆。 真因是手机档全局 button:not(.switch):not(.plg-switch) { min-height: 36px } 的优先级 (0,2,1) 压过 .widget-size-dot 的 (0,1,0),只写 min-height: 0 无效。修法:用 .widget-size-dots[data-size-dots] button.widget-size-dot 把优先级抬到 (0,3,1)。读数:点栏高 6px,圆点 6×6(当前点 scale(1.6) 后 10×10,仍是正圆)。
同轮并入:余额速览补自足卡体(此前只由今日页发布快照,没进过今日页的白板上会显示「内容由原页面提供」),并按霖的定稿给出两档排版(4×1 并排 / 4×2 上下两行、左名称右数值);入口行的说明文字按宽度分档(4 宽完整 / 3 宽取「·」之后的缩句 / 2 宽「进入」 / 1 宽不写),并把此前在 CSS 里根本不存在的 wg-entry-name / hint / chevron 补上样式。
护栏:topbar-chrome 增加「预览里的 PageHead 不参与 chrome」「只清自己 token」「内容签名短路」;board-edit 增加卡片撑满槽位与预览、入口行样式、卡片流容器与指示栏等断言;board-templates 增加余额速览自足与两档、入口行分档的断言。
13.9 第四轮:指示栏被确认按钮压住 + 中等力度一挥反向(待真机复核)¶
⑤ 指示栏被确认按钮挡住。 真因是展开态的竖向空间不足时,卡片流原先有 min-height: 180px 不可压缩,.widget-size-picker 也不裁溢出,于是多出来的部分滑到确认按钮底下。修法:卡片流改为唯一可伸缩的一段(min-height: 0),标题行与指示栏固定高度,picker 自身 overflow: hidden(空间不足时被裁的是卡片流而不是指示栏);同时把展开态高度由 56vh 提到 60vh、预览基准单位 30 → 28、方框 128 → 116、卡片 176 → 156,让常见机型不再需要裁切。
⑥ 中等力度左滑、停在两项之间却突然退一位。 真因是松手速度只取最后两帧:手指收势时会回弹,最后两帧的方向常常与整段手势相反,于是投射方向被翻成反向(轻滑与大力滑都不受影响,只有中等力度会落在"被翻过来的投射刚好够退一格"的区间)。修法:速度取最近 120ms 窗口的平均,并加一道方向约束——投射方向与整段手势净位移相反时丢掉投射,只按净位移吸附。
无真机读数(原因:这两个修复装上 K90 后应用停在登录页,本轮无法在真机上驱动界面复核;此前四项(①②③④)已在 K90 / K30 上取到读数)。手动复核步骤:打开「添加组件」→ 选一个档位组件 → 确认三枚圆点完整露在「添加/修改大小」按钮上方且不被遮挡;再按住卡片流中等力度向左挥、松手前手指轻微回弹一下,期望前进一格(不是退回),向右做同样动作期望后退一格。
护栏:board-edit 增加「卡片流是唯一可伸缩段(min-height: 0 + picker overflow: hidden)」与「松手速度取时间窗平均(samples / <= 120)+ 投射方向与净位移对账(Math.sign(fling) !== Math.sign(net))」的断言。
13.10 第五轮:两项列表的倒演 + 指示栏下沿被压(待真机复核)¶
⑦ 只有两个档位时,左划之后又会「向左回到第二项」。 真因是动画途中改目标:拖动把 pos 从 1 推到 2(前进到另一档)并回报父组件后,父组件把 index 改成 0,而此刻 round(pos) 还在 1 —— 同步 effect 认为不一致,立刻调 gotoIdx(0) 按「最短路径」定目标;n = 2 时正反两条路等长,代码取到 -1,于是把刚做的前进动作倒着演回去(拖得足够久、round(pos) 已到 2 时才不出问题,与霖的观察一致)。修法:加 animRef,动画途中不抢目标(拖动中同样不抢),只在动画收尾时用最新档位对账一次。
⑧ 指示栏下沿被确认按钮遮住一点。 修法:给 .widget-size-dots 自己的 14px 高度(6px 点 + 上下各 4px)与 position/z-index,当前点 scale(1.6) 之后也不再溢出到按钮底下,且即使与按钮盒子有 1px 级重叠也画在它上面。
无真机读数(原因同 §13.9:K90 装上后停在登录页,本轮无法在真机上驱动界面复核)。手动复核步骤:把某个组件的档位设为两项(当前 15 个组件的档位都是 2~3 项,可直接用「余额速览」的 4×1 / 4×2)→ 第一项左划到第二项 → 再左划,期望继续前进到第一项且动画方向一致(不再倒着演);同时确认三枚圆点完整可见、下沿不再被按钮压住。
13.11 第六轮:整体卡顿(已按真因拆掉两处放大器)¶
霖 2026-10-12 现场:界面整体非常卡顿,页面稍稍向上滑动就会抽搐。
真因一:抽屉预览区的 MutationObserver。 它当初是为了兜住「异步挂载的输入框」(首帧 41 个、3 秒后 44 个),观察 childList + subtree,每次回调都全量重扫 .add-preview-box / .widget-size-box 及其子树。而抽屉总览是 51 个真实卡体预览,卡体里的数据 hook 会持续重渲染 → DOM 持续变动 → 全量重扫持续触发,主线程被占满;滚动时父级布局变化进一步放大它。
真因二:PageHead 每次渲染挂一个 setTimeout 自愈同步。 它当初是为了兜住「chrome 被清空后无人写回」,但那个根因(预览里的 PageHead 污染全局 chrome)已在 §13.8 从源头修掉,自愈只剩纯开销(长页面上每个渲染周期一个定时器)。
修法:抽屉预览区改为只做一次批量处理(一个复合选择器扫一遍并置 disabled / tabIndex),晚挂载的输入框交给原本就有的 focusin 兜底失焦(输入法修复仍成立,它在 §13.7 的读数里本来就是靠这条生效的);PageHead 的补同步定时器直接删除。
无真机读数(原因:K90 停在登录页,无法在真机上驱动界面复核)。手动复核步骤:打开「添加组件」,上下滑动总览网格,确认不再卡顿与抽搐;再进任一组件详情滑动卡片流确认顺畅;最后确认输入法仍不会被拉起来。
护栏:board-edit 反向钉住「抽屉里不许再出现 new MutationObserver」;topbar-chrome 反向钉住「不许再出现每渲染一次的补同步定时器」。
13.12 第七轮:确认键「点两次才生效」(霖补的触感线索直接指到真因)¶
霖 2026-10-12 现场:点一次「修改大小」没反应,第一次连触感都没有,第二次才生效。
这条线索就是真因:全局触感委托(lib/haptics.ts 的 installGlobalHaptics)挂在 click 上(capture,见该文件 §L213)——第一次点按没有触感,说明那一次 click 根本没发生,而不是 handler 没生效。此前用 CDP 直接派发 click 验证过「一次就生效」,正好绕开了真机的这条链路,所以一直没暴露。
修法:确认键不再只依赖 click——增加指针路径:pointerup 命中按钮且位移 ≤12px 即生效,与 click 路径共用一个 400ms 一次性闸门;指针路径 preventDefault() 抑制随后的 click(也就不需要再靠全局委托发声),改为自己补一档与 .btn 相同的 haptic("tick");鼠标路径保持原样交给全局委托,不重复振动。另给确认键 position/z-index 画在档位区之上、档位区 overflow: hidden,避免两者 1px 级重叠时抢走点按。
无真机读数(原因:K90 停在登录页,无法在真机上驱动界面复核)。手动复核步骤:长按一个组件 → 「改变大小」→ 抽屉弹出后只点一次「修改大小」,期望这一次就生效(并且这一次就有触感);再确认「添加」路径同样一次生效。
护栏:board-edit 增加「确认键有指针路径 activate(true) + 一次性闸门 firedRef」「指针路径补了 haptic("tick")」「确认键 z-index: 2 画在档位区之上」三条断言。
待确认:卡顿也可能来自「总览一次渲染 51 个真实卡体」这一固有成本。若拆掉上述两处后仍卡,下一步把总览预览改成进入视口才渲染真实卡体(未进入视口用骨架),这需要单独立一个单元。
14. 按档位设计排版(2026-10-12 起)¶
14.1 一套规则同时管白板与预览¶
档位排版只挂在 [data-size] 上:白板槽位(.board-slot)与抽屉预览盒(.widget-size-box)都带 data-size="WxH",因此一条 CSS 规则在「摆到白板上」和「在抽屉里挑档位」两处同时生效,设计一次不会分叉。需要换结构(而不只是显隐)时,卡体渲染器的第二个参数 size 同样两处都有。
14.2 统计类:信息层数随档位递增(本批 7 张)¶
已修课程 / 总学分 / 全部学年加权 / 最新学期加权 / 校园卡余额(数字卡)/ 学分 / GPA 这七张合并到同一块骨架 StatTile(pages/info/reportParts.tsx),三档按「信息层数递增」组织:
| 档位 | 呈现 | 依据 |
|---|---|---|
| 1×1 | 只留主数值(图标 / 名称 / 上下文收起,纵向居中) | 一格宽度放不下第二层信息,先给「一眼看到数」 |
| 2×1 | 主数值 + 名称(图标与上下文收起,纵向居中) | 横向多出一格:够放名称,不够放第三层 |
| 2×2 | 图标 + 主数值 + 名称 + 一行上下文(如「共 12 个学期」「已修 34 门」) | 高度也翻倍,第三层才有地方落脚 |
数值字号走仓库字阶(1×1 用 --text-lg,2×1 / 2×2 用 --text-xl),间距仍走 4pt 网格;名称与上下文的显隐用 display 切换,不重排结构;信息页没有 data-size 祖先,既有观感一字不变。
真机读数(K90 / myron / Android 16 / WebView 143,APK md5 c6fdf76b4a349fdd32237a93a79e39cd):GPA 的三个档位预览在同一屏里读到 —— 1×1 为 flex-direction: row、名称 / 图标 / 上下文三者 display: none;2×1 为 column、名称显示、图标与上下文隐藏;2×2 为 column、图标 + 名称 + 上下文全显示(stat-sub 为 display: block)。截图同时确认:预览盒自带卡面(不再是一堆幽灵文字)、确认键与三枚圆点都完整可见。
14.3 后续¶
14.4 全部 15 个组件的档位排版(2026-10-12 收官)¶
§1 的 15 个组件全部落地,口径统一为「随档位加信息层」:小档位只留一眼要看的那一层,档位变大再逐层补上下文。
| 组件 | 档位 → 呈现 | 自足卡体 |
|---|---|---|
| 统计类 7 张 | 1×1 数值+名称 / 2×1 图标+右侧两栏 / 2×2 图标名称 + 居中数值与口径 + 进度行 | 本来就是自足(useCard / useReport) |
| 学生卡 | 2×1 姓名+学号 / 4×1 再加院系(横向铺开)/ 4×2 三行分层、姓名放大 | 是 |
| 下一节课 | 1×1 倒计时 / 2×1 加课程名 / 4×2 加时间、地点、进度条 | 本轮补(并把时间换算抽到 HomeWidgets) |
| 校园卡余额卡片 | 1×1 金额+名称 / 2×1 图标+两栏 / 2×2 加「生活页 · 流水与充值」 | 本轮补 |
| 今日预约 | 1×1 条数 / 2×1 加最近一条时间与场馆 / 4×2 逐条 | 本轮补 |
| 订阅新闻 | 1×1 条数 / 2×1 加最新标题 / 4×2 整列表 | 本轮补(取数抽成 useSubsNewsFeed) |
| 今日概览 | 4×1 三项计数 / 4×2 加最近一条截止 / 4×4 再铺开今天前几节课 | 自持取数版已有 |
| 日程与提醒 | 4×2 最近两条 / 4×4 铺满 | 自持取数版已有 |
| 余额速览 | 4×1 并排 / 4×2 上下两行(左名称右数值) | 本轮补 |
两处机制复用:① 统计类五张(含校园卡余额卡片)共用 StatTile 的五个槽位与一套 grid-template-areas,所以「今日预约」「订阅新闻」这类要「数量 + 一条明细」的卡也直接复用同一套三档,只在需要 2×1 就带一行明细时加 .stat-cap-2x1 修饰;② 时间换算、订阅取数、自持卡体都收在 components/HomeWidgets.tsx 一处实现,今日页与白板共调用,避免分叉。
护栏:board-templates-test 逐项钉住「自足卡体 + 登记 + 按档位分层」:NextClassCard / CardEntryCard / ResvCard / NewsCard / TodayOverviewWidget({ size }) / AgendaWidget({ size }) 的存在与登记、tierH >= 2 / tierH >= 4 的分层、const limit = (size?.h ?? 4) >= 4 ? 6 : 2 的截断、以及 useSubsNewsFeed 的共用。
15. 预览数据层:抽屉预览不再现爬数据(2026-10-12)¶
15.1 问题与做法¶
抽屉里的组件预览渲染的是真组件,它们各自 useCard / useReport / useCampusData… 取数——于是「挑一个组件」会连带把整套数据爬一遍,还会把真实数字当成示例展示(霖 2026-10-12 要求:不要渲染完全真实、需要现爬数据的组件,给预览传示例数据即可)。
没有逐个改 hook(13 个 hook 各自在 hook 规则内取 context、挡加载、改返回值,改动面大且易漏),而是利用它们的共同点:都读同一份 SWR 模块级缓存。
state/cache.ts新增cacheSeedPreview(key, data):把示例写进缓存,时间戳取远未来(Number.MAX_SAFE_INTEGER)→Date.now() - at恒为负 → 永不判过期 → 一次真实请求都不会发;只写内存、还原时把原条目放回,不污染真实缓存与持久化。state/cache.ts另加setPreviewMode / isPreviewMode:多数 hook 的判据是「��存命中且不过期就不拉」(seed 就够),但useLearnData是挂载即拉、useNews/useTodayNewsFeed的键带参数,seed 拦不住,只能显式跳过。开关放在叶子模块里,数据层与预览层都能引用,不产生循环依赖。components/PreviewData.tsx的PreviewDataProvider:先用useLayoutEffectseed 并把ready置真,再渲染 children。这条顺序是关键——子节点的useState(() => cacheGet(...))发生在首次渲染里,而 effect 顺序是「子先父后」;若在父的普通 effect 里 seed,子节点首屏已读到旧缓存、请求也已经发了。components/BoardAddDrawer.tsx:抽屉整棵子树(总览网格 + 档位预览)包进 provider。
15.2 覆盖清单¶
| 键 / hook | 覆盖的预览 | 手段 |
|---|---|---|
report |
成绩统计四张、成绩明细 | seed(成绩行含两个学期) |
card:1 / card:30 |
校园卡余额数字卡、生活页校园卡栏目 | seed(余额示例取 ¥123.45,便于区分真实值) |
campus |
今日概览、下一节课、今日课程、作业/通知/文件入口 | seed(含按真实星期几生成的今日课表,否则下一节课会空) |
deadlines |
日程与提醒的倒计时 | seed(起止落在「开始前 14 天 ~ 结束」窗口内) |
learn(模块级变量) |
网络学堂三件、搜索/学期/选课 | seed + isPreviewMode() 跳过挂载即拉的加载 |
todayresv |
今日预约 | seed(座位 + 研讨间各一条) |
todaycal |
日程与提醒的校历节点 | seed |
profile |
个人信息栏目、学生卡 | seed(BasicUserInfo) |
exams |
考试安排 | seed(两场不同日期) |
calendar |
信息页校历栏目 | seed(CalendarData,firstDay 取八周前的周一) |
useNews(page,length) |
信息页订阅新闻栏目 | 预览分支直接返回示例并跳过加载(键带分页参数) |
useTodayNewsFeed(subs) |
白板订阅新闻、今日页新闻 | 预览分支同上(键带订阅集合) |
useExternalHomework |
今日概览的外部作业 | 无需处理:模块级快照,整个应用启动只拉一次,不随预览挂载现爬 |
useIgnoredHw |
已忽略作业集合 | 无需处理:本地存储集合,不发请求 |
15.3 护栏¶
board-edit-test 新增 9 条:抽屉必须包 provider、provider 必须先 seed 再挂子节点(useLayoutEffect + if (!ready) return null)、示例必须取远未来时间戳、必须做快照还原、预览开关必须存在且被复位、网络学堂加载必须在预览期跳过、带参数的新闻 hook 必须有预览分支、示例必须覆盖各键。
无真机读数(原因:本轮四次尝试都连不上设备——183.173.230.238:39593 自始至终「积极拒绝」,USB 也不在 adb devices 里;代码与四项门禁均已通过)。上机复核要点:① 打开「添加组件」,确认预览里显示的是示例值(校园卡 ¥123.45、GPA 3.85、示例同学 等)而不是真实数字;② 抽屉背后白板的真实数字不应变化(验证快照还原无副作用);③ 预览应即时出现,不再等真实请求。