From b3f404838b2c6c8b61cfccf39ec112734c810b31 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 13:51:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(harmony):=20P2a=20=E6=94=B6=E5=B0=BE=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=92=A4=E6=8E=89=E5=B9=B3=E7=BA=A7?= =?UTF-8?q?=E3=80=8C=E4=BC=9A=E8=AF=9D=E3=80=8Dtab=20+=20=E6=9D=83?= =?UTF-8?q?=E9=99=90"=E5=BC=BA=E5=88=B6=E5=8A=9B"=E4=B8=8A=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E5=88=A4=E6=8D=AE=2014=E2=86=9219?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 撤 tab(按 pi 的顺序:先补视图与折叠,再撤入口) 底部只剩 **收件箱 / 联系人**。「会话」不是第三个地方,而是同一批数据的两种看法: 收件箱那栏按会话折叠(组头就是会话),联系人那栏的卡片视图是会话的进度视角。 依据是"信息没丢",并且把它做成了判据:**卡片字段集与 WebUI `WorkCard` 完全相等** (多一个少一个都红)—— 其中 `status`(active/archived) 与 `from_agent` 参考实现也不显示; 哪天 WebUI 补上,这条会红,提醒跟着补,而不是悄悄少一块。 ## 权限"强制力"上界面(WebUI 有、鸿蒙原先没有) 只显示档位会让人以为 plan 档真的管住了对方。WebUI 把说明放在 `title`(悬停提示), **手指没有悬停** —— 所以鸿蒙拆两步:标记形状当场可辨(● 平台强制 / ◉ 覆盖不完整 / ○ 仅提示),点徽标用 toast 说完整那句话。三条纪律落进判据: 1. 档位/强制力标签与 WebUI 的 `MODE_LABEL` / `ENFORCEMENT_LABEL` **逐字一致**; 2. **说明文案从 WebUI 源码抽出字符串逐字比对**(3 档 × 3 强制力全覆盖)—— 两个客户端对同一个任务不能给两种保证; 3. 认不出的强制力归一到 `advisory`(保守方向),空/未知必须说"仅提示"。 收件箱每封邮件里没有 `permission_enforcement`(会话级字段),故那里只写中文档位 —— 凭空画一个强制力标记等于编一个"平台做到了什么"。 ## 判据自己不可信的两个坑(变异测试逼出来的,各修一次) - **断言一律读剥掉注释的源码**:把 `showToast` 注释掉,正则照样匹配 —— 注释里有某个调用证明不了它存在。 - **"在回调里"不能靠正则窗口**:`onClick` 体掏空、或把 toast 挪到相邻的 `onHover`, 窗口式正则都会放过。改成**括号配对**取那个 `onClick` 的 `{...}` 体,只在里面找。 两次变异现在都判红。 `harmony-logic.test.mjs` 19 条(原 14);变异验证:改文案 / 改档位标签 / 页签改回「会话」/ 注释掉 toast / toast 挪出 onClick / toast 写死文案 → 各判红。 ## 文档 §7.9 记本轮;§7.10 记 jianf 追加的「鸿蒙要求用系统方案」:同意该理解,并补上**可离线校验**的 做法 —— SDK 自带系统资源名表 `sdk/default/openharmony/toolchains/id_defined.json`(7826 条), 其中正好有 `ohos_id_color_list_card_bg`(每项一张卡的底色)、`_list_separator`、 `_text_primary/secondary/tertiary`、`_emphasize`、`_warning`、`_alert`、`_mask_*`、 `ohos_id_blur_style_component_*_color`。**没有设备**,`$r('sys.*')` 写错在运行前发现不了, 所以先立一条判据:源码里的每个 `sys.*` 名字都必须在该表里查得到,再逐处替换。 `cross-client-theme` 的三个取值钉改"意图相同"**先与 pi 对齐、两侧一起改**(他已明确要求)。 ## 验证 `hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 9 个判据文件(background 42 / cross-client 8 / harmony-logic 19 / nav-merge 8 / build-stamp 4 / packaging 3 …)+ vitest 258。 视觉与点击仍未验(无设备,模拟器需人在命令行启动)。 --- client/electron/test/harmony-logic.test.mjs | 87 +++++++++++++++------ docs/HARMONY-ALIGN-PLAN.md | 60 +++++++++++++- 2 files changed, 120 insertions(+), 27 deletions(-) diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index f102439..70b8bf5 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -33,6 +33,15 @@ const MODULE_TS = join(HARMONY_ETS, 'model/MailGrouping.ts'); const H = await import(pathToFileURL(MODULE_TS).href); const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8'); +/** + * 断言一律读**剥掉注释的源码**。 + * + * 起因是一次变异测试:我把 `promptAction.showToast(` 注释掉,判据**照样绿** —— + * 因为它在注释里也能被正则匹配到。注释里出现某个调用,恰恰说明不了那个调用存在 + * (而注释里正当地引用旧写法又是常有的事)。这条纪律在 `cross-client-theme` 里 + * 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。 + */ +const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); const webGroups = readFileSync(join(ROOT, 'client/electron/src/lib/mailGroups.ts'), 'utf8'); const webCard = readFileSync(join(ROOT, 'client/electron/src/components/WorkCard.tsx'), 'utf8'); @@ -129,24 +138,24 @@ test('未读数用服务端 total 相加(它是 CountUnread,权威),负 assert.equal(H.sumUnreadTotals([3, 4]), 7); assert.equal(H.sumUnreadTotals([0, -1, 2]), 2); assert.equal(H.sumUnreadTotals([]), 0); - assert.match(page, /sumUnreadTotals\(/, '页面要用服务端未读数,而不是数这一页'); + assert.match(pageCode, /sumUnreadTotals\(/, '页面要用服务端未读数,而不是数这一页'); }); test('只有"取满了这一页"才提示可能还有更多(服务端 total 是未读数,不是总封数)', () => { assert.equal(H.partialLoadNotice(12, 50), '', '没取满就别吓人'); assert.equal(H.partialLoadNotice(50, 50), '已加载 50 封(本页上限 50,可能还有更多)'); assert.equal(H.partialLoadNotice(0, 0), '', 'limit 不合法时不提示'); - assert.match(page, /partialLoadNotice\(/, '页面要用这条提示'); + assert.match(pageCode, /partialLoadNotice\(/, '页面要用这条提示'); }); test('★ 界面不再把"服务端未读数"当成"总封数"显示', () => { // 原先底部写的是「共 N 封」,而那个 N 是 /me/mail/inbox 的 total(= CountUnread), // 于是同一屏上会出现「共 7 封」和「未读 7」这种自相矛盾的两行字。 assert.ok( - !/共 ' \+ this\.total \+ ' 封/.test(page), + !/共 ' \+ this\.total \+ ' 封/.test(pageCode), '页面里还有「共 N 封」—— 服务端 total 是未读数,不是总封数' ); - assert.match(page, /已加载 ' \+ this\.loaded \+ ' 封/, '应如实说"已加载了多少封"'); + assert.match(pageCode, /已加载 ' \+ this\.loaded \+ ' 封/, '应如实说"已加载了多少封"'); }); // ───────────────────────── 预算(与 WebUI BudgetChip 同判据) ───────────────────────── @@ -165,8 +174,8 @@ test('往返预算档位与 WebUI 的 BudgetChip 完全一致', () => { assert.equal(H.budgetState(5, 9), 'spent', '用超了也是用尽,不能算成还剩负数'); assert.equal(H.budgetLabel(5, 4), '1/5'); assert.equal(H.budgetLabel(0, 0), '', '不限时徽标文字是空串(页面据此不渲染)'); - assert.match(page, /budgetLabel\(c\.max_rounds, c\.used_rounds\)/, '卡片上要显示预算'); - assert.match(page, /budgetState\(c\.max_rounds, c\.used_rounds\)/, '卡片上要用同一档位判据'); + assert.match(pageCode, /budgetLabel\(c\.max_rounds, c\.used_rounds\)/, '卡片上要显示预算'); + assert.match(pageCode, /budgetState\(c\.max_rounds, c\.used_rounds\)/, '卡片上要用同一档位判据'); }); // ───────────────────────── 联系人页两种视图(为撤掉平级「会话」tab 做准备) ───────────────────────── @@ -177,14 +186,14 @@ test('联系人页的列表/卡片切换:点一次换一次,标题用 WebUI assert.equal(H.contactViewTitle('card'), '工作列表', '卡片视图的标题应与 WebUI 一致'); assert.equal(H.contactViewTitle('list'), '联系人'); - assert.match(page, /this\.contactView = nextContactView\(this\.contactView\)/, '切换按钮要走这条判据'); - assert.match(page, /contactViewTitle\(this\.contactView\)/, '标题要走这条判据'); + assert.match(pageCode, /this\.contactView = nextContactView\(this\.contactView\)/, '切换按钮要走这条判据'); + assert.match(pageCode, /contactViewTitle\(this\.contactView\)/, '标题要走这条判据'); }); test('卡片上"最新一封是谁发的":人 vs Agent(决定人要不要接手)', () => { assert.equal(H.lastFromIsHuman('pi', 'jianf'), true); assert.equal(H.lastFromIsHuman('pi', 'pi'), false); - assert.match(page, /lastFromIsHuman\(c\.agent_name, c\.last_from\)/, '卡片要用这条判据选图标'); + assert.match(pageCode, /lastFromIsHuman\(c\.agent_name, c\.last_from\)/, '卡片要用这条判据选图标'); // WebUI 侧同一判据仍在 assert.match(webCard, /const fromHuman = c\.last_from !== c\.agent_name;/, 'WebUI 的 fromHuman 口径变了'); }); @@ -192,12 +201,12 @@ test('卡片上"最新一封是谁发的":人 vs Agent(决定人要不要接 // ───────────────────────── 两层的接合:页面确实调了被测逻辑 ───────────────────────── test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用")', () => { - assert.match(page, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑'); - assert.match(page, /this\.groups = groupMailsBySession\(mergedMails\)/, '加载后要折叠'); - assert.match(page, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)'); - assert.match(page, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染'); - assert.match(page, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)'); - assert.match(page, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来'); + assert.match(pageCode, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑'); + assert.match(pageCode, /this\.groups = groupMailsBySession\(mergedMails\)/, '加载后要折叠'); + assert.match(pageCode, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)'); + assert.match(pageCode, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染'); + assert.match(pageCode, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)'); + assert.match(pageCode, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来'); }); // ───────────────────────── 撤掉平级「会话」tab(P2a 收尾) ───────────────────────── @@ -209,10 +218,10 @@ test('底部只剩 收件箱 / 联系人 两个平级页签,「会话」不再 * 顺序也按他说的:**先补视图与折叠,再撤 tab** —— 撤早了, * 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。 */ - const tabLabels = [...page.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]); + const tabLabels = [...pageCode.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]); assert.deepEqual(tabLabels, ['收件箱', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`); - assert.ok(!/struct\s+SessionsTab/.test(page), 'SessionsTab 已经撤了,不该再留在页面里'); - assert.ok(!/sessions\(\)/.test(page), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)'); + assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里'); + assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)'); }); test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息没丢"的依据)', () => { @@ -224,9 +233,9 @@ test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息 const fields = src => new Set([...src.matchAll(/\bc\.([a-z_]+)/g)].map(m => m[1])); const web = fields(webCard); // 鸿蒙卡片 Builder 的正文(从 `WorkCard(c: Contact)` 到下一个 @Builder 之前) - const cardStart = page.indexOf('WorkCard(c: Contact)'); + const cardStart = pageCode.indexOf('WorkCard(c: Contact)'); assert.ok(cardStart > 0, '找不到鸿蒙的卡片 Builder'); - const cardBody = page.slice(cardStart, page.indexOf('@Builder', cardStart)); + const cardBody = pageCode.slice(cardStart, pageCode.indexOf('@Builder', cardStart)); const harmony = fields(cardBody); const missing = [...web].filter(f => !harmony.has(f)); @@ -302,13 +311,39 @@ test('★ 档位说明文案与 WebUI permissionModeHint 逐字一致(两边 }); test('徽标真的挂在界面上,且点它能看到那句说明(触屏没有悬停)', () => { - assert.match(page, /permissionChipText\(c\.permission_mode, c\.permission_enforcement\)/, '卡片要用"档位+强制力"的徽标'); - assert.match(page, /permissionHint\(c\.permission_mode, c\.permission_enforcement\)/, '点徽标要弹出说明'); - assert.match(page, /promptAction\.showToast\(/, '说明走 toast(手指没有 hover)'); - assert.match(page, /enforcementLabel\(c\.permission_enforcement\)/, '说明里要带强制力标签'); - assert.match(page, /permissionLabel\(mail\.permission_mode\)/, '收件箱行要用中文档位'); + assert.match(pageCode, /permissionChipText\(c\.permission_mode, c\.permission_enforcement\)/, '卡片要用"档位+强制力"的徽标'); + assert.match(pageCode, /permissionHint\(c\.permission_mode, c\.permission_enforcement\)/, '点徽标要弹出说明'); + /* + * 说明必须挂在这颗徽标**自己的 onClick 里**。 + * + * 这条原先只查"页面里出现过 showToast",两次变异都躲过去了: + * ① 把 onClick 体掏空(showToast 还在页面别处); + * ② 把 toast 挪到相邻的另一个回调(`onHover`)里。 + * 正则窗口分不清"在回调里"和"在回调后面",所以这里做**括号配对**, + * 只在那个 onClick 的 `{...}` 里面找。这是"只验结构不算数"的又一个小例子。 + */ + const onClickBodyOf = (code, fromIdx) => { + const open = code.indexOf('{', fromIdx); + let depth = 0; + for (let i = open; i < code.length; i++) { + if (code[i] === '{') depth++; + else if (code[i] === '}') { + depth--; + if (depth === 0) return code.slice(open, i + 1); + } + } + return code.slice(open); + }; + const chipIdx = pageCode.indexOf('permissionChipText(c.permission_mode'); + const clickIdx = pageCode.indexOf('.onClick(', chipIdx); + assert.ok(chipIdx > 0 && clickIdx > chipIdx && clickIdx - chipIdx < 600, '徽标上要有自己的 onClick'); + const chipClickBody = onClickBodyOf(pageCode, clickIdx); + assert.match(chipClickBody, /promptAction\.showToast\(/, '徽标的 onClick 里要弹说明(不是页面别处的 toast)'); + assert.match(chipClickBody, /permissionHint\(/, '弹出来的必须是那句说明'); + assert.match(pageCode, /enforcementLabel\(c\.permission_enforcement\)/, '说明里要带强制力标签'); + assert.match(pageCode, /permissionLabel\(mail\.permission_mode\)/, '收件箱行要用中文档位'); // 收件箱列表接口没有 enforcement 字段,那里不许凭空画强制力标记 - const mailItem = page.slice(page.indexOf('MailItem(mail: MailLike)')); + const mailItem = pageCode.slice(pageCode.indexOf('MailItem(mail: MailLike)')); assert.ok( !/permissionChipText\(mail\./.test(mailItem), '收件箱每封邮件里没有 permission_enforcement,画强制力标记等于编一个"平台做到了什么"' diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index 1096f36..201f478 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -284,7 +284,7 @@ AppImage 与 `linux-unpacked` 正常 —— 交付前要确认这个环境问题 `client/harmony/entry/src/main/ets/model/MailGrouping.ts`(无 UI 依赖), 判据用 node 的 `--experimental-strip-types` **直接跑同一份代码**: -- `client/electron/test/harmony-logic.test.mjs`(14 条)—— 断言的是**行为**: +- `client/electron/test/harmony-logic.test.mjs`(**19 条**)—— 断言的是**行为**: 折叠后组头是不是最新一封、单封是不是不成组、同一时刻是否用 `mail_id` 倒序兜底、 时间解析失败会不会让顺序依赖入参、多账号同名会话会不会被错并、预算剩 1 个来回是哪档。 - 页面那一层用源码判据钉"确实调了这些函数"(`groupMailsBySession` / `isFlatGroup` / @@ -405,3 +405,61 @@ deb 也不必从 targets 里摘。已写进 `client/electron/BUILD.md`(含排 顺带(给所有 agent 的提醒):本机 `/tmp` 已 99% 满,`hvigor` 之类的构建会把它进一步挤爆; 大产物构建请把 `TMPDIR` 指到工作区所在盘。 + +### 7.9 P2a 收尾:撤掉平级「会话」tab + 权限档位的"强制力"(鸿蒙侧) + +- **撤 tab**:底部只剩 **收件箱 / 联系人** 两个平级页签。依据"信息没丢": + 会话列表独有的字段现在落在两处 —— 收件箱按会话折叠(组头就是会话), + 联系人页的卡片视图是会话的进度视角;其中 `status`(active/archived) 与 `from_agent` + **参考实现也不显示**,判据里有一条"卡片字段集与 WebUI `WorkCard` 一致"钉住这件事 + (两边字段集**完全相等**,多一个少一个都红)—— 哪天 WebUI 补上了,这条会红,提醒跟着补。 +- **权限档位 + 强制力**:卡片上的徽标改成"**档位 + 强制力标记**",点它弹出说明。 + WebUI 把说明放在 `title`(悬停提示),**手指没有悬停** —— 所以鸿蒙拆两步: + 标记形状当场可辨(`●` 平台强制 / `◉` 覆盖不完整 / `○` 仅提示),点一下用 toast 说完整那句话。 + 三条纪律落进判据: + 1. 档位标签、强制力标签与 WebUI 的 `MODE_LABEL` / `ENFORCEMENT_LABEL` **逐字一致**; + 2. **说明文案从 WebUI 源码里抽出字符串逐字比对**(9 种组合全覆盖)—— + 两个客户端对同一个任务不能给两种保证; + 3. 认不出的强制力归一到 `advisory`(保守方向:绝不当成"平台拦得住"), + 且空/未知必须说"仅提示"。 + 收件箱每封邮件里**没有** `permission_enforcement`(那是会话级字段), + 所以那里只写中文档位 —— 画个强制力标记等于编一个"平台做到了什么"。 + +**两个"判据自己不可信"的坑,本轮各修一次(都是变异测试逼出来的)**: + +- 断言一律读**剥掉注释的源码**:把 `showToast` 注释掉,正则照样匹配 —— 注释里有某个调用, + 证明不了那个调用存在(`cross-client-theme` 里遮罩那段早有同款教训)。 +- "在回调里"不能靠正则窗口:`onClick` 体掏空、或把 toast 挪到相邻的 `onHover`,窗口式正则会**放过**。 + 改成**括号配对**取那个 `onClick` 的 `{...}` 体,只在里面找。两次变异现在都判红。 + +### 7.10 下一段:改用"系统方案"(jianf 追加要求) + +用户原话:「鸿蒙也同步,但是**鸿蒙要求用系统方案**」(pi 转达,见 §二·五 的对应表)。 +我这边**同意这个理解**,并且补两条可执行的做法: + +1. **能给系统的全给系统**:`backgroundBlurStyle(BlurStyle.*)` 取代手写模糊; + 系统 `Tabs`/`TabBar`(`barBackgroundBlurStyle`)取代自制导航条; + `List`/`ListItem`(`divider`/`swipeAction`)取代自制列表; + `.borderRadius()`/`.shadow()` 取代手写卡片;`animateTo`/`curves` 取代自定义动画。 +2. **语义色优先 `$r('sys.color.*')`**,而且**名字是可以离线校验的**: + SDK 里带着系统资源名表 + `sdk/default/openharmony/toolchains/id_defined.json`(本机 API 26 版本,7826 条)。 + 实测该表里正好有这批语义色: + `ohos_id_color_list_card_bg`(**列表每项一张卡**的底色)、`ohos_id_color_list_separator`、 + `ohos_id_color_background` / `_sub_background`、 + `ohos_id_color_text_primary` / `_text_secondary` / `_text_tertiary`、 + `ohos_id_color_emphasize`(强调色)、`ohos_id_color_warning`、`ohos_id_color_alert`、 + `ohos_id_color_mask_light/regular/thick`(遮罩)、 + `ohos_id_blur_style_component_*_color`。 + ⇒ 本轮先做成**判据**:源码里出现的每个 `$r('sys.color.*')` / `$r('sys.float.*')` 名字 + 都要在这张表里查得到 —— 因为**没有设备**,名字写错在运行前根本发现不了, + 这条判据把它变成构建期就红。做替换之前先把这条判据立起来,再逐处替换。 +3. **判据怎么跟着改(已与 pi 对齐口径)**:`cross-client-theme.test.mjs` 现在钉的三个**取值** + (品牌蓝 `#2563EB` / 圆角 14 / 导航玻璃 0.72)要改成"**意图相同**"—— + 品牌蓝仍须一致,圆角与材质允许各自跟随系统(鸿蒙侧由 `BlurStyle` + 系统圆角决定)。 + **改之前先跟 pi 说一声,两侧一起改**(他明确要求,避免各改一半)。 + 本轮新增的那 6 个预算条取值(gray/red/orange 三档)同属这批,一起改。 +4. 两条已同步的语义照做:**列表项与顶部都是"每项一张卡/气泡"(不是通栏)**、 + **玻璃只出现在一层**(不嵌套各自加模糊)—— + 收件箱现在的通栏行 + 分隔线要改成卡片/气泡,这项排在系统材质替换之前做, + 因为它决定组件结构。