跳转至

原子收藏全面升级 · 可行性与实现方案

历史记录 / 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. 需求解读

把原文拆成可施工的条目:

  1. 组件判定标准(原文给出 10 个例子):自身具备完整上下文 + 能独立完成一个原子操作(或一族原子操作)的元素,才算组件。原文还要求「整理出一张表,标注每一个页面元素是否是组件」——见本文第 7 节。
  2. 元素级可收藏:任何页面上的任何组件都能被收藏,收藏后形态不变(同组件同属性)。
  3. 预制组件:文字标签、文字标题、跳转按钮、数据卡片(校园卡余额 / GPA / 学分 / 剩余作业 等)、分隔线、跳转入口;允许补充新想法。
  4. 收藏夹 = 白板:可随意排布、可调某些组件的样式、可加入预制组件;内部记录每个组件的属性与位置。
  5. 三种用途:替换今日页(今日本身就是系统预制的收藏夹)/ 作为自定义界面并可放进导航栏(导航栏可编辑:显隐、图标、名称;「我的」不可缩减)/ 传统收藏夹。
  6. 编辑模式:点阵背景、组件锚点吸附点阵、长按拖拽、其余组件自行让位。

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 三件东西

  1. WidgetSpec:描述「渲染什么」的可序列化声明。
  2. Widget 注册表:spec.kind 到渲染实现的映射,集中在一处,供白板与「添加组件」面板共用。
  3. 可收藏声明:页面元素声明自己对应哪个 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 判定「是否组件」的三条硬规则

  1. 上下文完整:脱离所在页面单独出现时,用户仍知道它是什么、作用于什么。反例:只有「充值」两字的按钮、只有「下载」两字的按钮、缺少上下文的下拉菜单。
  2. 能完成原子操作:点击后发生的事不依赖页面其它部分(跳转、提交、切换某设置);纯装饰不算。
  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 点)
组件 比正常模式略小,四边露出背后的点阵(正常态边框会盖住点阵)
点击组件 不再触发组件自身的点击,而是直接弹出该组件的长按菜单
进入/退出 有过渡动画(点阵渐显,组件尺寸与位置过渡)

正常模式下的长按编排(手感对齐安卓桌面小组件移动):

  1. 长按到阈值 → 先弹该组件的长按菜单(同时给一次触感反馈,走既有触感分级);
  2. 若手指产生位移 → 本次操作升级为移动:长按菜单自动收回,其余组件模糊 + 渐出 + 略微缩小,手拿的组件尺寸不变,背景点阵渐显用于对齐;
  3. 拖拽期实时预览松手后的布局(含让位结果);
  4. 松手 → 组件落到目标格,自动回到正常模式(点阵与模糊反向复原)。

实现要点:长按判定与「位移升级」判据复用 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 霖的四条要求

  1. 取消所有组件的大小自由控制,改为针对特定组件分别设计不同大小下的排版;
  2. 大小控制放进「添加组件」的展开菜单:展开态由单张预览改为横向列表,选中哪一档就加哪一档;
  3. 分割线 / 标题 / 按钮 / 列表保留自由控制,其余改为挡位控制;长按「改变大小」时复用该抽屉挑选档位;
  4. 给出需要逐档设计排版的组件清单。

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 模块级缓存。

  1. state/cache.ts 新增 cacheSeedPreview(key, data):把示例写进缓存,时间戳取远未来(Number.MAX_SAFE_INTEGER)→ Date.now() - at 恒为负 → 永不判过期 → 一次真实请求都不会发;只写内存、还原时把原条目放回,不污染真实缓存与持久化。
  2. state/cache.ts 另加 setPreviewMode / isPreviewMode:多数 hook 的判据是「��存命中且不过期就不拉」(seed 就够),但 useLearnData 是挂载即拉、useNews / useTodayNewsFeed 的键带参数,seed 拦不住,只能显式跳过。开关放在叶子模块里,数据层与预览层都能引用,不产生循环依赖。
  3. components/PreviewData.tsx 的 PreviewDataProvider:先用 useLayoutEffect seed 并把 ready 置真,再渲染 children。这条顺序是关键——子节点的 useState(() => cacheGet(...)) 发生在首次渲染里,而 effect 顺序是「子先父后」;若在父的普通 effect 里 seed,子节点首屏已读到旧缓存、请求也已经发了。
  4. 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、示例同学 等)而不是真实数字;② 抽屉背后白板的真实数字不应变化(验证快照还原无副作用);③ 预览应即时出现,不再等真实请求。