feat(harmony)★★: 背景档与预设改用官方 Chip,删掉重复的同步指示器

承接 2026-09-29 的反馈,本批改动是那一轮工作的收尾。

## 1. 自绘 Text → 官方 `Chip`

背景档与预设两排原来是**自绘 `Text`**,而且与设置页主题那排
**圆角/内边距不一致**(预设那排 `borderRadius(4)`,另两处是
`radiusControl`)。改用 `import { Chip, AccessibilitySelectedType,
LengthMetrics } from '@kit.ArkUI'`。

## 2. 删掉 `@Prop statusText`(一个数两个指示器)

它原来是「外观」卡右上角那个「已同步」的**第二份**:页面把同一个
`statusLabel(this.appearanceStatus)` 既画在卡片级标题行、又透传进来
画在「背景」小标题右边 ⇒ 同一屏两个「已同步」,永远同值。

删掉透传这一份,值只从页面进、只画一处。

## 3. Surface.ets(32 行)/ Theme.ets(51 行)/ ContactsTab.ets(21 行)

配套:Chip 的尺寸档、主题色取用、联系人页的调用点。

## 判据

- `test/harmony-appearance.test.mjs`:外观判据随实现更新
- `test/mutants/baseline.sha`:变异体自证底本同步

### ⚠️ 设备端判据本机跑不了(不是代码问题)

    node --test test/harmony-appearance.test.mjs
      pass 26 / fail 3

失败的 3 条**全部**是设备端断言:

    ★ 设备:打开背景开关后,壁纸上真的出现了
      AssertionError: 要能拉起我们的应用并等到它到前台
    ★ 设备:窗格内容不得超出屏幕
      AssertionError: 要能拉起应用
    ★ 设备:切换主题后界面真的整体换色了
      AssertionError: 设备连续 10 轮拉不起应用(本条已连续 3 轮没真跑)

即**本机没有接 Harmony 设备**,不是断言挂在代码上。
判据文本本身断言「设备连续 10 轮拉不起」——它自己在如实报告
「这条从未在真机上跑过」。**需要真机补验**,这里不假装它通过了。

## docs

API.md / GUI-PLAN-HARMONY.md / PLAN.md 同步上述改动。
This commit is contained in:
2026-10-05 09:26:30 +08:00
parent a8d6811cd7
commit 8dc714cb62
9 changed files with 442 additions and 59 deletions

View File

@ -88,7 +88,7 @@ POST /me/mail/send 发信
GET /me/mail/inbox 收件箱(?status=unread|all&limit=N)
GET /me/mail/sent 发件箱
GET /mail/{id} 单封详情(含附件列表)
GET /mail/{id}/thread 对话树(分块加载,见下)
GET /mail/{id}/thread 对话树(?offset=N&limit=N 自线索根分块加载,见下)
POST /mail/{id}/read 标记已读
POST /me/mail/{id}/forward 转发(引用原文 + 附件随行)
```
@ -119,22 +119,28 @@ POST /me/mail/{id}/forward 转发(引用原文 + 附件随行)
### 对话树
```
GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙
GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载)
GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
GET /mail/{id}/thread?limit=60 首屏:自线索根起的头一块
GET /mail/{id}/thread?offset=60&limit=60 继续往后取(offset 传上次 next_offset)
```
树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。
因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。
**从线索根整树 BFS,而不是「锚点的祖先链 + 锚点的子树」**:后者会让兄弟分支
整条落在盲区里 —— 一封抄送给两个 Agent 的邮件会收到两个回复,二者互为兄弟,
从其中一个看树看不到另一个;挂在原件上的转发分支同理。先上溯到根
(`ThreadRootOf`)再整棵展开,兄弟、抄送产生的平行回复、转发分支都是根的子孙,
一次覆盖。
```json
{
"anchor_mail_id": "...",
"dir": "around",
"root_mail_id": "...",
"anchor_depth": 3,
"nodes": [
{
"mail_id": "...", "parent_mail_id": "...",
"depth": -3,
"depth": 0,
"from_name": "admin", "to_name": "pi",
"subject": "...", "body_preview": "正文前 240 字节…",
"attachment_count": 2,
@ -142,20 +148,25 @@ GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下
}
],
"total": 21, "hidden": 4,
"has_more_up": true, "has_more_down": false,
"next_up": 20, "next_down": 20
"has_more": true, "next_offset": 60
}
```
- `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
分块加载时根可能还没取到,所以不用「距根深度」
- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down`
原样回传即可,不必自己算已加载数量
- `depth` 是**距线索根**的层级:0 = 根,1 = 它的直接回复。**恒 ≥ 0**。
从根展开后根一定在结果里,绝对深度因此总是可知的
- `offset` 是自根起的**节点数**偏移(不是层数)。从根 BFS 后只剩一个方向,
游标就是「已取到的节点数」;把 `next_offset` 原样回传即可,不必自己算
- `root_mail_id` / `anchor_depth` 是服务端算出的根与锚点深度,供客户端定位
- 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取
- `hidden` = 本页因权限被过滤掉的节点数
- `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分
「确实无权查看」(永久)与「尚未加载」(随上滑补齐)
- `limit` 夹到 [1, 200],非法值回落默认 40
「确实无权查看」(永久)与「尚未加载」(往下翻会补上)
- `limit` 夹到 [1, 200],非法值回落默认 **60**
**锚点一定可见**:巨型线索里锚点可能没落在 BFS 首页,此时服务端额外把
「根 → 锚点」这条路径补进第一页,锚点自己单取。但该路径的回填**上限 60 层**
(`anchorPathBudget`)—— 超过 60 层的链条,锚点之上会出现断点。
递归 CTE 本身另有 `descendantDepthCap = 10000` 的兜底,仅防数据损坏成环。
**鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。
拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。

View File

@ -135,7 +135,7 @@ entry/src/main/ets/
| 主界面(平板/2in1) | 两栏/三栏自适应:左列表 + 右详情;`@ohos.mediaquery` 断点切换 |
| 会话列表 | 列表/卡片视图切换;卡片含主题、最新一封发件人+摘要、预算徽标(剩 1 橙 / 用尽红 / 不限不显示) |
| 邮件详情 | Markdown 正文 + 附件行 + 抄送行 + 权限卡片 + 底部「回复 / 转发」+ 对话树入口 |
| 对话树 | 缩进 + 连接线(不引图形库),分块加载:`dir=around` 首屏 + `up/down` 增量 |
| 对话树 | 缩进 + 连接线(不引图形库),分块加载:自线索根整树 BFS,`offset` 增量 |
| 写信页 | 收件人补全(三段式 suggest)+ 抄送 + 主题 + 正文 + 预算档位 + 附件 + 会话别名(仅 `.new`) |
| 设置 | 网关地址、退出登录、密钥管理(`/me/keys` 创建/吊销,展示 token_hint) |

View File

@ -764,7 +764,7 @@ execute: {
两处不一致时无法判断谁对。直接用递归 CTE 在 mails 上查(`idx_mails_parent` 已有)。
- [x] `repo/thread.go`:`AncestorsRaw`(向上)/ `DescendantsRaw`(向下)两个方向的递归 CTE
- [x] `GET /mail/{id}/thread?dir=around|up|down&offset=N&limit=N`
- [x] `GET /mail/{id}/thread?offset=N&limit=N`(自线索根整树 BFS)
- [x] **树可跨会话**:转发把线索引到新会话,但 parent 仍指向原件。
这正是树视图比会话内平铺更有价值的地方 —— 能看出线索分叉去了哪里
- [x] **按会话逐个鉴权**:A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。
@ -780,15 +780,16 @@ execute: {
原先实现是深度上限 100 直接截断,超长线索看不到全貌。改为按方向分页:
- [x] 首屏 `dir=around` 取锚点附近一块(limit 在两个方向各分一半);
`dir=up|down` + `offset` 增量加载,两端各自带 `has_more_*` 与 `next_*`
- [x] **游标用「相对锚点的层号/节点偏移」而不是 mail_id**:
偏移量每次从锚点重走一遍,无状态、不可伪造;
- [x] **从线索根整树 BFS**(`ThreadRootOf` 上溯到根,再 `DescendantsRaw` 展开):
弃掉原先 `dir=around|up|down` 三方向分页 —— 那种做法下**兄弟分支整条落在盲区里**:
一封抄送给两个 Agent 的邮件会收到两个回复,二者互为兄弟,从其中一个看树看不到另一个;
挂在原件上的转发分支同理。先到根再整棵展开,兄弟、平行回复、转发分支都是根的子孙
- [x] **游标是「自根起的节点数」而不是 mail_id**:偏移量每次从根重走一遍,无状态、不可伪造;
用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去),
那还得单独证明它确实是锚点的祖先,反而更绕。
祖先方向的层号天然稳定 —— 新邮件只会追加成叶子,不会插进已有链条中间
- [x] `depth` 改为**相对锚点**(0 = 锚点,负 = 祖先,正 = 子孙):
分块加载时根可能还没取到,绝对深度无从得知
那还得单独证明它确实是根的祖先,反而更绕。
节点顺序天然稳定 —— 新邮件只会追加成叶子,不会插进已有链条中间
- [x] `depth` 为**距线索根**的绝对层级(0 = 根,恒 ≥ 0):从根展开后根一定在结果里,
绝对深度总是可知(`AncestorsRaw` 内部的负 depth 在 handler 层加回 `anchorDepth` 抵消)
- [x] repo 层**不做可见性过滤**:不可见的中间段必须能穿过(转发把线索引进别人的会话,
再往上却可能仍是自己参与的往来)。过滤放在 handler 层,那里才知道调用者是谁
- [x] 前端 `IntersectionObserver` 双哨兵(rootMargin 200px 提前触发)+
@ -797,8 +798,10 @@ execute: {
(`setState` 异步,读 state 会双双看到 false)
- [x] 实测 251 封长链:7 页取完,depth 连续无缺口,取到真正的根;
311 封(含 60 个转发分支)9 页取完,`depth=1` 恰为 61 个
- [x] `limit` 夹到 [1,200],非法值回落默认 40(分页参数不该因笔误让整个请求失败);
`limit=1` 时两方向各保底 1,否则算出 `downLimit=0` 连锚点自己都不返回
- [x] `limit` 夹到 [1,200],非法值回落默认 60(分页参数不该因笔误让整个请求失败)
- [x] 锚点回填上限 `anchorPathBudget = 60` 层:锚点没落在 BFS 首页时把「根 → 锚点」
那条路径补进第一页,保证点开的那封一定在树里;超过 60 层则锚点之上留断点。
递归 CTE 另有 `descendantDepthCap = 10000` 兜底,仅防数据损坏成环
**工作列表卡片视图(已完成)**: