跨端: 审计补强 —— 邮件行的「抄送 N」+ 断点差异登记 + 两处"看起来有其实没有"的澄清

继续「全面对齐 WebUI 和鸿蒙」。这一轮做的是**逐页对照审计**(子代理通道被
session daemon 的端口占用堵死,改为自己逐处读源码对照)。

## 一、补上邮件行的「抄送 N」(真缺失)

WebUI `MailList.tsx:350` 行上有「抄送 N」,鸿蒙**完全没有**。而数据一直在:
`cc_list` 服务端确实返回(实测回包字段列表里有),只是鸿蒙的 `MailLike`
接口漏了这个字段 ⇒ 一封抄送给多个人的邮件在列表里看不出任何区别。

修法:接口加 `cc_count`,`MailSummary` 加 `cc_list` + `cc_count`,
收件箱/发件箱两处填充派生值,行上按 WebUI 的位置渲染。
**设备实测**(造了一封带 2 个抄送的真邮件):行上出现「抄送 2」,
位置与 WebUI 一致(主题行下方、灰字)。

★ 接口用**数字**而不是 getter:`MailSummary implements MailLike`,
而 ArkTS 的 interface 里不能声明 getter(编译报 "incorrectly implements interface")。

## 二、有意**不抄**附件标记(发现 WebUI 那段是死代码)

WebUI 行上还有一个 📎 + 数量的标记(`MailList.tsx:351-355`)。但核对服务端
**实测回包**:`GET /me/mail/inbox` 既没有 `attachments` 也没有 `has_attachments`
⇒ `mail.attachments?.length ?? 0` **恒为 0**,那个标记在 WebUI 上**从不出现**。

所以鸿蒙这一轮**有意不抄它** —— 照抄一个不工作的东西,只会多一处
"看起来有、永远不亮"的代码。要做这个功能得先让服务端在列表回包带上附件计数
(一次 JOIN 的事),那是独立的一件事,已登记进 `docs/DEBTS.json`
(`mail-list-attachment-count`)。

★ 这一条与鸿蒙 `MailSummary.has_attachments` 那个字段一起处理掉了:
它还留在那里会误导人(服务端永不返回它 ⇒ 恒 false)。

## 三、两端宽屏断点不同 —— 登记 + 判据(此前**无任何记录**)

审计点名要核实的这条确认成立:

    WebUI: `NARROW_QUERY = '(max-width: 1023px)'`
           含义 = 「三栏(60 导航 + 320 列表 + ≥520 详情 ≈ 900px,再加余量)放不下就退化单栏」
    鸿蒙:  `isWide = width >= 768`
           含义 = 「要不要显示**侧栏**」(鸿蒙内容区是一个窗格,没有并排三栏)

**含义不同,所以数值不同本身不算错** —— 这与手势阈值同一条口径
(语义各自成立时,数值不必强求一致)。但**用户可见的后果**是:768–1023 宽
(常见竖屏平板、窄窗口)下 WebUI 是单栏+底栏、鸿蒙是侧栏+内容,
同一宽度在两端长得不一样。

处理:① 登记进 `docs/DEBTS.json`(`wide-breakpoint-divergence`,
带三个待定选项);② 加判据 —— 它**不**要求两端取值相同,而是要求
「取值可读 + 含义写清 + 差异被登记」三件事同时成立。

★ 写这条判据时又踩了同一坑:用 `code()` 读注释 ⇒ 永远红。
`criteria-hygiene` 判据的头顶就写着"判代码用 code、判理由用 prose"。

## 四、判据

`run-all.mjs` → `checks=505 pass=505 fail=0 skip=0 red=0 broken=0 unreported=0`
(cross-client-theme 15→16)。`hvigorw assembleHap` 成功。

**未验**:附件标记(有意不做);抄送行在**深色**下的对比度未单独验。
This commit is contained in:
2026-09-19 15:13:51 +08:00
parent f811c9887a
commit f655453424
6 changed files with 137 additions and 2 deletions

View File

@ -760,3 +760,63 @@ test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RR
`${n} 与 CSS 的 ${m[1] === 'DARK' ? '.dark' : ':root'} 段不一致`);
}
});
test('★ 断点:两端各是多少、含义是什么、差异被登记(不是"两边必须一样")', () => {
/*
* 2026-09-19 由审计发现:**两端的宽屏断点不同,而且此前没有任何地方记录过**。
*
* WebUI: `useIsNarrow.ts` 的 `NARROW_QUERY = '(max-width: 1023px)'`
* 含义 = 「三栏(60 导航 + 320 列表 + ≥520 详情 ≈ 900px,再加余量)
* 放不下就退化单栏」
* 鸿蒙: `MainPage.ets` 的 `isWide = width >= 768`
* 含义 = 「要不要显示**侧栏**」(鸿蒙的内容区是一个窗格,没有并排三栏)
*
* ★ 这条判据**不**要求两端取值相同 —— 那会是错的:它们判的本来就不是同一件事
* (一个是"三栏放不下",一个是"要不要侧栏")。这与手势阈值同一个口径:
* **语义各自成立时,数值不必强求一致**。
*
* 它要求的是三件事:
* ① 两端的取值能被**读出来**(而不是散落在魔法数字里);
* ② 两端的**含义**在注释里说清了(后人不必猜"为什么不一样");
* ③ 「两者不同」这个事实被**登记**(`docs/DEBTS.json`),
* 否则下一个人只会当成漏改 —— 这正是它被发现时的状态。
*/
const narrowHook = prose(join(ROOT, 'client/electron/src/hooks/useIsNarrow.ts'));
/*
* ★ 读**原文**(`prose`)而不是剥注释版:本条要判的正是"理由有没有写在代码旁",
* 而理由天然在注释里。用 `code()` 会把注释剥掉 ⇒ 永远红。
* (这与 `criteria-hygiene` 那条"判代码用 code、判理由用 prose"是同一条纪律,
* 我在本文件里又踩了一次。)
*/
const mainPage = prose(join(HARMONY_ETS, 'pages/MainPage.ets'));
/* ① 两端取值可读 */
const webMq = /NARROW_QUERY\s*=\s*'\(max-width:\s*(\d+)px\)'/.exec(narrowHook);
assert.ok(webMq, 'WebUI 的窄屏断点要能从 `NARROW_QUERY` 读出来');
const webMax = Number(webMq[1]);
const hWide = /this\.isWide\s*=\s*\(newValue\.width as number\)\s*>=\s*(\d+)/.exec(mainPage);
assert.ok(hWide, '鸿蒙的宽屏阈值要能从 `onAreaChange` 里读出来');
const hMin = Number(hWide[1]);
assert.ok(webMax > 0 && hMin > 0, '两端断点都应是正数');
/* ② 含义写清了(这是本条判据的主要价值:把"为什么不同"钉在代码旁) */
assert.match(narrowHook, /三栏|导航.*列表.*详情/,
'★ WebUI 侧要说明这个断点的**依据**(它是按"三栏放不下"定的)');
assert.match(mainPage, /侧栏|宽屏模式/,
'★ 鸿蒙侧要说明这个阈值判的是什么(「要不要显示侧栏」)');
/* ③ 差异被登记 */
const debts = prose(join(ROOT, 'docs/DEBTS.json'));
assert.match(debts, /wide-breakpoint-divergence/,
'★ 「两端断点不同」必须登记在 docs/DEBTS.json —— '
+ '它被发现时**没有任何地方记录**,下一个人只会当成漏改');
/* ④ 反向断言:万一以后有人"统一"了,登记不该变成化石 */
if (webMax - 1 === hMin || hMin === 1024) {
assert.match(debts, /wide-breakpoint-divergence[\s\S]{0,400}?(已统一|统一到)/,
'★ 两端断点看起来已经一致了 —— 那就该在登记里写明"已统一",'
+ '否则这条登记会变成没人在核的化石(登记也该跟着事实走)');
}
});

View File

@ -68,7 +68,7 @@ const SUITE = [
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
['test/theme.test.mjs', [], 38],
['test/background.test.mjs', [], 44],
['test/cross-client-theme.test.mjs', [], 15],
['test/cross-client-theme.test.mjs', [], 16],
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
// 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。

View File

@ -41,6 +41,16 @@ export interface MailLike {
/** 决策结果(空串 = 还没人处理过)。后端用 COALESCE 归一成空串,所以空串与 null 同义。 */
permission_result: string;
permission_mode: string;
/**
* 抄送清单 —— WebUI 行上显示「抄送 N」(`MailList.tsx:350`)。
*
* 服务端确实返回它(实测 inbox 回包字段里有 `cc_list`),此前鸿蒙的
* `MailLike` 接口漏了这个字段 ⇒ 即使模型里有也没法在行上读。
*
* 用**数字**而不是数组:`MailLike` 是 interface(ArkTS 接口里不能有 getter),
* 而列表行只需要个数。派生放到 `MailSummary` 的填充处(`cc_count = cc_list.length`)。
*/
cc_count: number;
source_account_id: string;
source_account_name: string;
}

View File

@ -71,6 +71,20 @@ export class MailSummary implements MailLike {
mail_type: string = '';
/** 决策结果:空串 = 还没有人处理(后端 COALESCE 归一,空串与 null 同义)。 */
permission_result: string = '';
/**
* 抄送清单 —— 行上显示「抄送 N」用它的长度(WebUI `MailList.tsx:350`)。
* 服务端 inbox 回包确实带 `cc_list`(实测字段列表里有),此前鸿蒙没接。
*/
cc_list: Address[] = [];
/**
* 行上的「抄送 N」。
*
* ★ 用**字段**而不是 getter:`MailSummary implements MailLike`,而 ArkTS 的
* interface 里不能声明 getter(编译报 "incorrectly implements interface")。
* 字段由反序列化后的一次派生填上(见收件箱/发件箱的过滤处),
* 或在 `cc_list` 赋值处同步 —— 两处都要写,所以放在模型里注释说明。
*/
cc_count: number = 0;
/** 客户端聚合字段:服务端不返回,由收件箱按来源账号填充。 */
source_account_id: string = '';
source_account_name: string = '';

View File

@ -269,6 +269,15 @@ struct InboxTab {
const mail: MailSummary = response.mails[j];
mail.source_account_id = acct.id;
mail.source_account_name = acct.displayName;
/*
* 派生 `cc_count`(行上的「抄送 N」)。
*
* ★ 为什么在这里算而不是在模型里做 getter:`MailSummary implements
* MailLike`,而 ArkTS 的 interface 里不能声明 getter
* (编译报 "incorrectly implements interface")。派生放在**填充处**,
* 收件箱与发件箱各一处 —— 两处都要记得写,所以模型里也有注释指过来。
*/
mail.cc_count = mail.cc_list.length;
mergedMails.push(mail);
}
unreadTotals.push(response.total);
@ -632,6 +641,28 @@ struct InboxTab {
Text(mail.body_preview).fontSize(12).fontColor(Theme.textSubtle)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
/*
* 「抄送 N」(WebUI `MailList.tsx:350`)。
*
* ★ 2026-09-19 审计补上:此前鸿蒙行上完全没有它 —— 一封抄送给好几个人的邮件
* 在列表里看不出任何区别,得点进去才知道。而 `cc_list` 服务端一直有返回
* (实测回包字段列表里有),只是 `MailLike` 接口漏了这个字段。
*
* ★★ **有意不抄附件标记**(WebUI 那个 `attachCount`):核对服务端实测回包,
* 列表接口既没有 `attachments` 也没有 `has_attachments`
* (`MailList.tsx:351` 的 `mail.attachments?.length ?? 0` 在列表里**恒为 0**
* —— 它在 WebUI 上也是个从不出现的死标记)。
* 照抄一个不工作的东西,只会让鸿蒙多一处"看起来有、永远不亮"的代码。
* ⇒ 要显示附件数得先让**服务端**在列表回包里带上它;那是独立的一件事,
* 不是这里顺手能补的。
*
* 有条件才画:没有抄送时不占位,否则每行都多一片空白。
*/
if (mail.cc_count > 0) {
Text('抄送 ' + mail.cc_count)
.fontSize(10).fontColor(Theme.textSubtle)
.margin({ top: 3 })
}
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
@ -721,6 +752,8 @@ struct SentTab {
const mail: MailSummary = resp.mails[j];
mail.source_account_id = acct.id;
mail.source_account_name = acct.displayName;
/* 同收件箱:派生行上的「抄送 N」(两处都要写 —— 理由见收件箱那处) */
mail.cc_count = mail.cc_list.length;
all.push(mail);
}
} catch (e) {

View File

@ -6,7 +6,9 @@
"两端都读这个文件:Go 侧判据断言自己的条目与**实测**一致(不许留一份手写的数字),",
"electron 套件把它打进 RESULT 行(那是常态可见的位置)。",
"已结算(2026-09-14):calendar-today-recompute —— P6 第 1 步(日历页 pages/CalendarPage.ets)落地,today 走 `@Prop @Watch('onVisibleChanged') visible` 在 pane 变可见时重算(另有 aboutToAppear 覆盖重新挂载),判据 test/harmony-calendar.test.mjs 的「★ today 在 pane **变可见时**重算」。结算即从此清单移除,余额里不再计这一笔。",
"已结算(2026-09-19):gesture-semantics —— P6 第 3 步(左右滑动翻页)落地:鸿蒙侧 `CalendarPage.ets` 加了 `PanGesture`、判定逻辑在纯逻辑层 `model/Calendar.ts` 的 `judgeSwipe`。按该条自己的口径(「鸿蒙侧出现滑动手势代码时**立即建**」)同步建了语义契约判据 `test/cross-client-gesture.test.mjs`(8 条)—— 按 (b) 口径钉**语义**不钉数值:左滑=下一段/右滑=上一段/纵向优先/快滑窗口/手势与按钮共用同一翻页函数/无边界回弹/有意差异被记录/方向写反必红。另在 `harmony-logic.test.mjs` 加了行为判据(跑 `judgeSwipe` 的四道门)。结算即从此清单移除,余额里不再计这一笔。"
"已结算(2026-09-19):gesture-semantics —— P6 第 3 步(左右滑动翻页)落地:鸿蒙侧 `CalendarPage.ets` 加了 `PanGesture`、判定逻辑在纯逻辑层 `model/Calendar.ts` 的 `judgeSwipe`。按该条自己的口径(「鸿蒙侧出现滑动手势代码时**立即建**」)同步建了语义契约判据 `test/cross-client-gesture.test.mjs`(8 条)—— 按 (b) 口径钉**语义**不钉数值:左滑=下一段/右滑=上一段/纵向优先/快滑窗口/手势与按钮共用同一翻页函数/无边界回弹/有意差异被记录/方向写反必红。另在 `harmony-logic.test.mjs` 加了行为判据(跑 `judgeSwipe` 的四道门)。结算即从此清单移除,余额里不再计这一笔。",
"2026-09-19 新增 wide-breakpoint-divergence:两端断点不同且含义不同(审计发现,此前无记录)。它不是\"已确认的缺陷\",而是**未决的口径** —— 登记它是为了让「两者不同」这个事实本身可见,而不是让它藏在两处代码里。",
"2026-09-19 新增 mail-list-attachment-count:邮件列表的附件数两端都拿不到(WebUI 那段是死代码)。鸿蒙侧只补了有真数据的「抄送 N」。"
],
"debts": [
{
@ -106,6 +108,22 @@
"due": "把两份 fixture 变成**当场采集**(跑 hdc dump 取现场)而不是人工存文件时,这条就到期",
"where": "client/electron/test/harmony-deviceprobe.test.mjs(两处提到「未验」的断言 + fixtures/aa-dump-l-*.txt)",
"kind": "scope"
},
{
"id": "wide-breakpoint-divergence",
"count": 1,
"due": "决定是否统一:鸿蒙 768vp vs WebUI 1024px(**不是 bug,是未决的口径**)",
"where": "client/harmony/entry/src/main/ets/pages/MainPage.ets(isWide 的 onAreaChange,>=768) 与 client/electron/src/hooks/useIsNarrow.ts(NARROW_QUERY = max-width: 1023px)",
"kind": "scope",
"note": "两端断点不同,且**含义也不同**,所以数值不同本身不算错:WebUI 的 1024 是「三栏(60 导航 + 320 列表 + >=520 详情 ≈ 900px,再加余量)放不下就退化单栏」,鸿蒙的 768 是「要不要显示侧栏」(鸿蒙没有并排的列表+详情三栏,它的内容区是一个窗格,所以 768 就够)。但**用户可见的后果**是:在 768–1023 宽(常见竖屏平板、窄窗口)下,WebUI 是单栏 + 底部导航,鸿蒙是侧栏 + 内容 —— 两台设备上同一宽度长得不一样。2026-09-19 由审计发现(此前**没有任何地方记录**这件事,连「两者不同」这个事实本身都没写下来,所以下一个人只会当成漏改)。待定:① 统一到 1024(鸿蒙跟 WebUI);② 统一到 768(WebUI 跟鸿蒙,但要重新论证三栏是否真能在 768 放下);③ 承认它们是两件事、把语义差异写进文档(那就该给两端各自的名字,而不是都叫 wide)。"
},
{
"id": "mail-list-attachment-count",
"count": 1,
"due": "邮件列表需要显示附件数时(要服务端在列表回包里带上它)",
"where": "server/internal/handler(邮件列表的响应构造) —— 客户端侧见 client/harmony/entry/src/main/ets/pages/MainPage.ets 的 MailItem",
"kind": "scope",
"note": "2026-09-19 审计发现:**两端都没有**附件数可用,而 WebUI 里那段代码看起来像有。实测 `GET /me/mail/inbox` 的回包字段列表里**既没有 `attachments` 也没有 `has_attachments`** ⇒ `MailList.tsx:351` 的 `mail.attachments?.length ?? 0` 在列表里**恒为 0**,那个 📎 标记在 WebUI 上**从不出现**(死代码)。鸿蒙这一轮只补了「抄送 N」(`cc_list` 服务端确实返回,真数据),**有意不抄附件标记** —— 照抄一个不工作的东西只会多一处「看起来有、永远不亮」的代码。要真做这个功能,先让服务端在列表响应里带上附件计数(一次 JOIN 的事),然后两端一起接。"
}
]
}