diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index e59cf7a..9ec30bf 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -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}?(已统一|统一到)/, + '★ 两端断点看起来已经一致了 —— 那就该在登记里写明"已统一",' + + '否则这条登记会变成没人在核的化石(登记也该跟着事实走)'); + } +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index e2e37c0..0af3b80 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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` 那条债:它写着「鸿蒙侧出现滑动 // 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。 diff --git a/client/harmony/entry/src/main/ets/model/MailGrouping.ts b/client/harmony/entry/src/main/ets/model/MailGrouping.ts index 586775e..d8eed0a 100644 --- a/client/harmony/entry/src/main/ets/model/MailGrouping.ts +++ b/client/harmony/entry/src/main/ets/model/MailGrouping.ts @@ -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; } diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 1a451f1..02d76d0 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -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 = ''; diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 6902dd6..806c248 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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) { diff --git a/docs/DEBTS.json b/docs/DEBTS.json index 3edf245..7dad44e 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -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 的事),然后两端一起接。" } ] } \ No newline at end of file