接手 pi 的 WebUI 开项途中踩到三个坑,都已修好并配判据(承接 e94313f)。 ## 坑 1:`npm run build 2>&1 | tail -4 && electron-builder …` 吞掉构建失败 pipeline 的退出码是 `tail` 的 → 构建失败、`&&` 照样往下走、electron-builder **拿旧的 dist 打了新包**。而所有判据都是绿的: - vitest 绿(见坑 2);packaging 绿(它比 dist vs 安装包,两边都是旧的,自然一致)。 新增判据:**dist 必须比 src 新**(build-stamp)。变异:`touch` 一个 src 文件 → 红; 重新 `npm run build` → 绿。错误信息写明"注意别把它的退出码丢在管道里"。 已重构建 + 重打包(deb 与 app.asar 同批,15:19)。 ## 坑 2:测试通过 ≠ 能打包 真正的失败:`AGGREGATE_ID` / `isUsableAccount` 被我当成 `accountStore` 的导出 (它们住在 `lib/accounts.ts`)。vitest 263 条全绿,生产构建直接报 `"AGGREGATE_ID" is not exported by "src/stores/accountStore.ts"` —— 测试运行时对缺的具名导出是宽容的(拿到 undefined)。**生产构建是一道独立的门。** ## 坑 3:判据写成"窗口式",被自己的变异测试抓住两次 `appearance-defaults` 的"取键函数必须把账号拼进去": 1. 第一版从 `export function` 切到 `}` → **参数表里的 accountId 满足了正则**, 把实现退回全局键仍然全绿(假判据!); 2. 第二版只取函数体 → `return accountId ? PREFIX : PREFIX;`(提了一下没用)又骗过去; 3. 第三版要求**同一个表达式里既有常量又有账号的插值/拼接**: 两种退化都红,合法的 `PREFIX + accountId` 写法仍然绿。 三种变异都验过(红/红/绿),还原后基线绿。 ## 文档 §7.12 两行改为「已修」并写明依据(默认值=服务端契约;缓存键差异已消除,旧全局键 只作一次性迁移源);新增 §7.20 记这三个坑与推论(产物是 gitignore 的, "源码修好"≠"用户手上那个包修好")。 ## 验证 `npm test` 退出码 0(13 个判据文件全绿 + vitest 263 passed); `hvigorw assembleHap` BUILD SUCCESSFUL;`npm run build` + electron-builder 均 exit 0。
69 KiB
鸿蒙客户端与 WebUI 的对齐计划
用户(2026-09-14):「安排对齐」「鸿蒙 ui 应当交给对这一块更熟悉的 dsh 负责」。 这份文档把差距与分期写清楚,免得每轮都从"感觉还差什么"重新猜。
负责人:dsh(2026-09-14 起由 jianf 指定)。移交信: 线索
harmony-ui-alignment(dsh@/home/program/agentmail), 信里交代了状态、判据纪律与踩过的坑(判据要点"用户真正会点的那一层"、 不许新写死颜色、不要给单个面单独做深色、模糊只由壁纸层负责、 列表项每项一张卡、ArkTS 编译坑、视觉不可验要如实标注)。我(pi)保留 WebUI/Electron 侧:需要两边一起加令牌之类的配合,回信给 pi。
一、现在的差距(有据可查)
| 能力 | WebUI | 鸿蒙 | 差距性质 |
|---|---|---|---|
| 收件箱 | ✅ | ✅ | — |
| 会话 | ✅(通信页签) | ✅(独立 tab) | 交互不同 |
| 联系人 | ✅ | ✅ | — |
| 发件箱 | ✅(通信页签) | ❌ | 缺页面(数据现成) |
| 授权(权限决策) | ✅(通信页签 + 详情内决策) | ❌ | 缺页面(API 现成) |
| 日历 | ✅ | ❌ | 缺页面(最大一块) |
| 管理(用户管理) | ✅(我的页底部,管理员可见) | ❌ | 缺页面 + 权限判定 |
| 写信 | ✅ 共用 Composer | ✅ 独立实现 | 组件未统一 |
| 底部/侧边导航 | ✅ 悬浮玻璃条 | ⚠️ 系统 TabBar | 观感不同 |
| 主题 / 壁纸(账号级) | ✅ 服务端同步 | ❌ | 未接 |
| 设计令牌 | ✅ :root |
✅ Theme.ets |
已对齐(判据钉住) |
二、已经做完的(本轮之前)
- 设计令牌共用:
common/Theme.ets与 WebUI 的:root一一对应 (品牌蓝、圆角 14/8、导航玻璃 0.72、语义色、字号),并由client/electron/test/cross-client-theme.test.mjs钉住取值一致性。 - 旧调色板清除:页面里的
#1A73E8(Google 蓝)/#333333/#F5F7FA等 与 WebUI 不同的写死色值,全部换成令牌(203 处)。 - 修掉只剩第一个 tab 高亮的 bug(
currentIndex === 0写死)。
二·五、做法上的一条硬要求:用系统方案
用户(2026-09-14):「鸿蒙也同步,但是鸿蒙要求用系统方案」。
含义:能交给系统的就交给系统 —— 用 ArkUI 的组件、材质与语义资源, 而不是把 WebUI 那套"手写 rgba + 自定义模糊 + 自制卡片"照搬过来。 系统材质会跟随深色模式、动效曲线与无障碍设置,手写的那套不会。
| WebUI 做法 | 鸿蒙应该用 |
|---|---|
手写 rgba(...) + backdrop-filter |
backgroundBlurStyle(BlurStyle.*)(系统材质) |
| 自制圆角导航条 | 系统 Tabs + TabBar(barBackgroundBlurStyle) |
自制 Scroll + Column |
系统 List / ListItem(divider / swipeAction) |
.glass-card 手写边框阴影 |
.borderRadius() + .backgroundBlurStyle() + .shadow() |
| 自己切 CSS 变量做深色 | 系统语义色 $r('sys.color.*') 自动跟随 |
| 自定义 transition | animateTo / transition / geometryTransition |
⚠ 影响既有判据:cross-client-theme.test.mjs 目前钉的是三个取值
(品牌蓝 / 圆角 14 / 导航玻璃 0.72)。改用系统资源后它应当变红 ——
那是预期的,届时要把它从"取值相同"改成"意图相同"(品牌蓝仍须一致;
材质与圆角允许各自跟随系统)。改判据需 WebUI 侧(pi)一起改。
三、分期(按"能独立验收"切)
P1 设计语言(pi 已完成) —— 令牌 + 颜色替换 + 编译通过。以下 P2–P6 归 dsh。
P2 发件箱:复用现有列表组件,接口 /mail/sent。验收:能看到已发邮件、
点开进详情;空态有说明。为什么先做它:与收件箱同构,风险最低。
P3 授权页:导航加一项「授权」(or 通信页内页签,见 5.2③),待决列表走
GET /permission/pending,决策走 POST /permission/decide
(body {mail_id, decision, note});详情内也支持决策。
(接口名已于 2026-09-14 由 dsh 核对修正,原写的 /permission/{id}/decide 不存在。)
验收:待决列表与 WebUI 同口径(未决 = 无 permission_result);
点同意/拒绝后状态立刻变;拒绝时可填备注(备注必须随决策送达模型 ——
WebUI 侧踩过这个坑,见 gateway/handler/permission.go 的 Note 传递)。
P4 主题/壁纸同步:接 GET/PUT /me/appearance + 图片走带认证的 fetch。
验收:换账号后外观跟随;服务端"无记录"时以本地为准(不要用默认值覆盖 —— 同 WebUI)。
P5 玻璃悬浮导航:自定义底栏(圆角 + 半透明 + backgroundBlurStyle),
取代系统 TabBar。注意:视觉验收需要设备或签名 HAP,
本机 hdc list targets 为空 ⇒ 只能保证编译与结构,观感要人眼确认。
P6 日历:网格 + 事件读写 + 左右滑动翻页(复用 WebUI 的手势阈值: 水平 ≥40px、≥1.5× 垂直、<600ms)。这一块最大,单独排。
四、验收纪律(照 WebUI 那套)
-
每个页面都要有点它的判据(WebUI 侧就是因为只验结构没验点击, 漏掉了"侧栏点了不翻页")。
-
判据不许只看截图:要量几何/对比度/命中区。
-
无法验证的要如实标注(例如"编译通过、视觉未验"),不能写成"已完成")。 ⚠️ 四档口径(pi 2026-09-14 定,这轮每一条都踩过):
- 没做 —— 功能不存在。不许写成"未验"(那会让人以为只剩观感风险,实际那里什么都没有);
- 未验 —— 做过、没看结果(例如"编译通过、视觉未验");
- 已知不一致 —— 做了,但某条件下会露出破绽:必须写清触发条件与现象 (例:"运行期切系统深浅色时,系统语义色/材质立刻跟随,而我们自己算的预设色板不重算 → 那一刻一部分跟随、一部分不跟随")。这一档最容易被误写成"没做", 而两者的风险读法完全不同;
- 机制上确定不同 —— 可判的差异,必须判、不许记成"未验"(深色档预设那次就是)。
可复用规则(会咬到 P5/P6):系统自动跟随的东西(语义色、材质)不会顺带把 "我们自己算出来的值"一起更新 —— 凡是我们计算/缓存且随主题变化的值, 都必须挂在同一个主题变化事件上重算,否则它迟早是那唯一一处不跟随的。
-
判据怎么写:见
client/electron/test/CRITERIA.md—— 那份规范管两个客户端的判据 (判结构与行为、不判字面与邻接;清单与 allow-list 的形状;变异要红在预期位置; "判据自己不会跑"那个家族的六种宿主)。写在 electron 的测试目录下只是因为run-all.mjs在那儿强制它存在,鸿蒙侧改判据前先读它。
五、交接核对(dsh,2026-09-14 收到 pi 的移交信后)
核对方式:跑构建、跑判据、读代码,不靠转述。
5.1 移交信里属实的两条
hvigorw assembleHap --no-daemonBUILD SUCCESSFUL(改前改后各跑一次)。node --test client/electron/test/cross-client-theme.test.mjs6 条全绿。
5.2 三处纠正(照原计划直接做会踩空)
① P3 的接口名写错了。 计划里写「决策走 POST /permission/{id}/decide」,
实际是 POST /api/v1/permission/decide,body {mail_id, decision, note}
(server/internal/handler/permission.go:301)。而且待决列表不要从收件箱里
筛 permission_*:WebUI 用的是 GET /api/v1/permission/pending
(client/electron/src/api/client.ts:623),鸿蒙照这个走才同口径。
理由不只是"更省事":/me/mail/inbox 默认只取 50 封,而一个会话能连着产生
十几封权限邮件,从收件箱筛会把别的信挤出视野(WebUI 的 mailGroups.ts
开头就是为这件事写的)。用 pending 接口,与 WebUI 同源。
②"不许写死颜色"的判据原来只挡得住列出来的那 8 个旧色值。
判据全绿的同时,pages/ 里还留着 14 处另一套写死的色 —— Google/Material
调色板:#E8F0FE、#E8F5E9、#FFF3E0、#D93025、#777777×2、
#555555、#444444、#cccccc、#aaaaaa、遮罩 #80000000×2、
透明 #00000000×2。枚举挡不住漂移,只有"类"能挡,已改成:
pages/下一个裸色值都不许有(#RRGGBB/#AARRGGBB都算),颜色只能来自common/Theme.ets;页面清单也改成扫目录(旧版硬编码 7 个文件名, 接下来要加的页面会自动逃出判据)。- 变异测试确认能判红:往
InboxPage.ets塞一个#E8F0FE→ 判据红;撤回 → 绿。 - 顺带把权限档位徽标的配色收进令牌:
Theme.permBg/permFg,与 WebUI 的PermissionChip.tsx同一映射(plan=蓝 / workspace=绿 / full=琥珀)。 原先是full ? 绿 : 橙(Material 色),与 WebUI 反着来。 - 判据从 6 条加到 7 条(新增"两边权限档位配色一致",直接拿 WebUI
:root的--c-blue-*/green-*/amber-*比鸿蒙令牌,防"看起来差不多")。
③ 导航的差距不止"观感不同",是信息架构不同。 WebUI(2026-09-14 用户 「导航项就只剩通信,日历,联系人」)已是:**通信(收件箱/发件箱/授权,内部页签
- 未读红/待决策橙徽标)/ 日历 / 联系人**,底部另有"我的(含管理)+ 主题 + 退出", 新建是通信页内悬浮圆形加号;鸿蒙还是 收件箱 / 会话 / 联系人 三个平级 tab, 没有发件、授权、日历,而「会话」在 WebUI 里没有对应入口(WebUI 是按会话把 收件箱分组,没有独立会话页)。 ⇒ P2/P3 不是"再加两个页面",得先对齐信息架构,否则加完还是两套导航。
5.3 一条与鸿蒙无关、但该让 pi 知道的
WebUI 侧 npm test 在 HEAD 上就是红的(test/background.test.mjs):
浅色与深色两套令牌都定义了(自动反色)—— 期望--nav-bg有浅/深两套, 但深色那套已在faacd3c(修"导航栏还是黑色")里被有意去掉, 现在index.css只剩--nav-bg: 255 255 255 / 0.72。导航在壁纸模式下仍参与模糊—— 期望html[data-bg='on'] .nav-rail里有backdrop-filter: blur(,而该规则现在是空的("模糊由壁纸层负责,这里不叠", 见index.css:892)。
两条判据编码的都是已被有意回滚的设计,判据没跟着改。红成了常态, 判据也就不再是判据 —— 这条纪律问题比色值本身更值得先修。
5.4 修订后的分期与"点它"判据
| 期 | 内容 | 判据(必须点到用户会点的那一层) |
|---|---|---|
| P1.5 ✅ | 裸色值清零 + 判据按类挡 + 权限档位配色统一 | 判据 7/7 绿 + 变异能判红 + 构建成功 |
| P2a ✅ | 信息架构:「通信」一项,内部页签 收件箱/发件箱/授权(未读红、待决策橙徽标) | 点页签 → 断言落到哪个 pane(不是断言页签个数)—— 页签状态机判据已落地(见 §7.15) |
| P2b ✅ | 发件箱页:GET /me/mail/sent,复用列表项 |
判据:接口路径、行上主角是收件人、空态有说明(主句与 WebUI 逐字一致) |
| P3 ⚠️ 主体完成 | 授权页:GET /permission/pending + POST /permission/decide |
未决口径与 WebUI 一致(无 permission_result)✅;拒绝可填备注且备注送出 ✅;expired 当场说清"这次批准不会恢复原调用" ✅。未验:真机上点同意/拒绝后状态是否"立刻变"(判据只钉到"决策后重新拉列表"这一层) |
| P4 ✅(P4c 上传除外) | 主题/壁纸(/me/appearance) |
换账号外观跟随 ✅(缓存键带账号);服务端无记录时以本地为准 ✅(§7.16);预设 6 档都能画出来 ✅、图片壁纸渲染 ✅(§7.17 —— 这一版补的,第一版只有数据没有画面)。未做:P4c 上传入口。未验:真机观感(配色/对比) |
| P5 ⬜ 未做 | 悬浮玻璃导航(取代系统 TabBar) | 模糊只由壁纸层负责;列表项每项一张卡;命中区 ≥44vp。现状:底栏仍是系统 Tabs,只有自绘的 tabBar builder 带了 backgroundBlurStyle(§7.10) |
| P6 ⬜ 未做 | 日历(/calendar/events,含 ics 导入导出) |
手势阈值与 WebUI 一致(水平 ≥40px、≥1.5× 垂直、<600ms)。有意排序:入口与内容一起上,不留空页签(§7.15) |
视觉/交互怎么验:本机有 harmony-emu(实例 HarmonyPhone,hdc 在
/opt/harmonyos/ohos-sdk/linux/toolchains),能起来就能截图 + 真点,
把"编译通过、视觉未验"变成"点过、有截图"。
实测(2026-09-14):当前文件沙箱(workspace-write)下起不来 ——
harmony-emu start 卡在等一个永远不来的 hdc(脚本要写 /run/harmony-emulator.pid),
直启 Emulator -start HarmonyPhone -noWindow 则报
Error opening logfile /root/.Huawei/Emulator/deployed/HarmonyPhone/Log/qemu.log: Permission denied
(实例目录在 /root 下,沙箱不许写)。要跑真机/模拟器判据,得先放宽文件权限
(或换有设备的机器);在那之前,鸿蒙侧的判据只能到"构建通过 + 结构/令牌一致",
观感与点击必须如实标注未验 —— 不许写成已完成。
状态口径:✅ = 有能判红的判据 + 构建成功;⚠️ = 主体完成但有未验项; ⬜ = 未做。视觉与交互一律未验(无设备/模拟器),见每节末尾的"未验"。
5.5 待定(需要 pi 或用户一句话)
- 「会话」这个平级入口留不留? WebUI 没有它(收件箱按会话分组)。 我倾向跟 WebUI 一致 —— 收件箱按会话折叠、去掉平级「会话」tab; 但这是删入口,改之前要一句话确认。 → 已答(见 5.6):不是删功能,是两个 tab 合成一个页面的两种视图;先补视图与折叠,再删 tab。
- 新增令牌
accentStrong / warnBg / warnFg / overlay取的都是 WebUI 已有的 tailwind 值(blue-700 / amber-50 / amber-700),WebUI 侧不需要动代码;overlay是唯一没有对应物的(WebUI 弹层不压遮罩)。若 pi 认为 WebUI 侧也该 立同名令牌,我再改。 → 已答(见 5.6):WebUI 侧不立同名令牌;鸿蒙侧的overlay拆成overlayColor+overlayAlpha。
六、pi 的回复已落地(dsh,2026-09-14 下午)
6.1 「会话」入口的结论(pi 作为 WebUI 侧负责人)
不是删功能,是两个 tab 合成一个页面的两种视图:联系人页就是会话列表
(ContactPanel.tsx 有列表 / 卡片两个视图 —— ContactRow ≈ 现在的 ContactsTab;
WorkCard「工作列表」≈ 现在的 SessionsTab:主题、最新一封摘要、权限档位徽标、
往返预算 = 现在的 used_rounds/max_rounds),而收件箱里会话是组织方式
(mailGroups.ts 的 groupMailsBySession() 按 session_id 折叠、组头取最新一封,
isFlatGroup() 让单封不成组)。
落地顺序(原样采纳):
- 先给「联系人」页补列表 / 卡片切换,卡片视图承接预算条 /
status/ 对方 Agent; 收件箱加按会话折叠(组头带未读)。折叠前先看total—— 别让"只取了 50 封" 被折叠伪装成"只有这么多会话"。 - 两件都到位后再删平级「会话」tab。在那之前删 = 丢信息:轮次预算、
status、from_agent会无处可看,而"预算跑满"正是需要人介入的信号。
合并后导航正好是 通信 / 日历 / 联系人 三项,P4–P6 都在这个形态上做。 ⇒ 分期顺序调整为:P2a 联系人双视图 + 收件箱按会话折叠 → P2b 通信页签(收件/发件/授权) → P3 授权页 → P4 主题壁纸 → P5 悬浮玻璃导航 → P6 日历 → 最后删「会话」tab。
6.2 遮罩令牌
overlay 拆成 overlayColor + overlayAlpha(WebUI 是 --bg-scrim 色 + --bg-dim
透明度的两段式;色要能随主题换向,焊死成一个 #AARRGGBB 等于把枚举写回代码)。
ArkUI 只认单值,故用 Theme.overlay() 组装。WebUI 侧不立同名令牌(立了没人用、
判据只能验"它存在",那是自证)。判据第 8 条钉住这个形态。
6.3 那两条红判据的 patch:已落地,且验证过能判红
test/background.test.mjs 第 18 组三条按 pi 的 patch 重写:标签改回它真正断言的东西,
两条方向相反的断言取代原两条(.dark 不许单独给导航换色 / 模糊只由壁纸层负责)。
变异验证:
- 往某个
.dark { }里塞一行--nav-bg: 15 23 42 / 0.72;→ 红; - 往
html[data-bg='on'] .nav-rail里塞backdrop-filter: blur(18px);→ 红; - 撤回 → 绿。背景判据 32 → 34 通过。
6.4 顺手发现并修掉的真 bug(构建只在日志里警告的那种)
src/background-takeover.generated.css 的头注释提前闭合:生成器在注释里写了
「src」加「/」加两颗星加「/」加「.tsx」,其中那对「星号 + 斜杠」把 CSS 注释就地结束,
剩下的尾巴变成 CSS 正文、并与第一条规则的选择器连在一起 ⇒ 非法选择器 ⇒
.bg-amber-100 那条接管规则被浏览器整条丢掉(壁纸模式下它不再变半透明)。
构建只给一条 [WARNING] Unexpected "14" [css-syntax-error],不报错、不影响构建。
改在生成器 scripts/gen-background-takeover.mjs(注释里只描述、不写 glob 字面量)并
重新生成:压缩输出现在以 html[data-bg=on] .bg-amber-100{ 起头、14 条规则全在、无告警。
另加两条判据("头注释没提前闭合" + 自检)。
6.5 一条判据从来没被跑过
cross-client-theme.test.mjs 不在 npm test 链里(npm test 只跑
markdown-xss / narrow-layout / theme / background / packaging + vitest,
而 vitest 只收 test/components 与 test/stores)—— 那条"防漂移判据"从没在默认套件
里跑过。已加进 npm test。
6.6 npm test 现在全绿(退出码 0)
窄屏布局全通过、主题 30、背景 34、cross-client 8、packaging 3、vitest 258 用例 / 15 文件全过。
其中 packaging 第 3 条此前是红的,但它不是判据过期,是安装包真的落后于 dist
(release/linux-unpacked/resources/app.asar 里装的还是旧前端)。根因有点讽刺:
npm test 的 && 链一直在 background 那条就中断,packaging 从来没跑到过。
已 npm run build + npx electron-builder --linux -c.electronDownload.isVerifyChecksum=false 重打包。
⚠️ deb 目标在本机打不出来(fpm 的 portable ruby 在 Dir.chdir 处退出),
AppImage 与 linux-unpacked 正常 —— 交付前要确认这个环境问题不影响目标平台。
6.7 WebUI 侧同源残留(pi 指出的第三处,已修)
Sidebar.tsx 底部的主题切换 / 退出登录按钮原为
text-chrome-400 hover:text-white hover:bg-chrome-800、外层分隔线 border-chrome-700
—— 那是"框架本来就该深"的假设。导航改白玻璃后 chrome-400 在白底上约 2.6:1
(图标要 3:1),hover 还会在白导航上闪出一块近黑。已换成 .nav-item + border-gray-200。
未加"Sidebar 里不许有 chrome-*"的判据:Sidebar.tsx 另有一处
bg-chrome-600 text-chrome-100 是实心小色块(正常用法),一刀切会误红。
6.8 仍未验的
鸿蒙侧视觉与点击仍未验(模拟器在本机沙箱下起不来,见 5.4)—— 本轮的鸿蒙改动只有颜色令牌,判据能覆盖;到 P2a 一定要有人眼或设备, 否则"点页签落在哪个 pane"这条判据无法证明。
七、P2a 落地(dsh,2026-09-14 稍晚)
按 pi 给的顺序:先补视图与折叠,再删 tab。本轮做完前一半,tab 保留。
7.1 做法:把"用户真正会点的那一层"的内核抽出来,让判据执行它
没有设备,"点一下"在鸿蒙上暂时无法自动验。应对不是编个能过的新判据,
而是把会点的那一层的内核抽成纯逻辑文件
client/harmony/entry/src/main/ets/model/MailGrouping.ts(无 UI 依赖),
判据用 node 的 --experimental-strip-types 直接跑同一份代码:
client/electron/test/harmony-logic.test.mjs(19 条)—— 断言的是行为: 折叠后组头是不是最新一封、单封是不是不成组、同一时刻是否用mail_id倒序兜底、 时间解析失败会不会让顺序依赖入参、多账号同名会话会不会被错并、预算剩 1 个来回是哪档。- 页面那一层用源码判据钉"确实调了这些函数"(
groupMailsBySession/isFlatGroup/toggleExpanded/budgetLabel/nextContactView/lastFromIsHuman)—— 两层合起来,「逻辑对」与「页面接上了」都有判据。 - 变异验证(4 处,全部判红):去掉组内排序 → 2 条红;预算阈值
<=1改<1→ 1 条红; 分组键去掉账号前缀 → 1 条红;页面不再区分单封组 → 1 条红。
这条判据已接进 npm test(node --experimental-strip-types --no-warnings --test)。
7.2 收件箱:按会话折叠
- 组头取组内最新一封的别名与主题(与 WebUI
mailGroups.ts同口径), 带未读数徽标与「N 封」;点组头展开/收起。 - 单封不成组、平铺(与 WebUI
isFlatGroup同结论):给孤立的一封信套组头 只是多一次点击,而收件箱里大多数人类来信就是孤立的一封。 - 多账号是鸿蒙特有:分组键带账号前缀(同一
session_id出现在两个账号是两件事);session_id缺失时按mail:<id>各自成组。
7.3 顺带修掉一个"看起来是总数、其实是未读数"的显示
/me/mail/inbox 返回的 total 是 CountUnread(未读总数),不是总封数
(server/internal/handler/me.go 的 MeGetInbox)。鸿蒙底部原来写「共 N 封」,
于是同一屏上会出现「共 7 封」和「未读 7」这种自相矛盾的两行字。
改法:未读数改用服务端 total(权威,原来数这一页会少报);
「共 N 封」改成「已加载 N 封」;并且这一页取满(=50)时如实提示
「已加载 50 封(本页上限 50,可能还有更多)」 —— 客户端手上根本没有可信的总封数,
那就不能把 50 封说成全部(这正是 pi 提醒的"别让只取 50 封伪装成只有这么多会话")。
WebUI 侧完全不读这个字段(mailStore 里没有 total),所以这条只影响鸿蒙。
7.4 联系人页:补上卡片视图(为撤 tab 做准备)
- 右上角切换列表 / 卡片,标题随视图变(「联系人」/「工作列表」,与 WebUI 同词);
切换规则在
nextContactView(),判据直接执行它。 - 卡片(对应 WebUI 的
WorkCard):Agent 名 + 工作目录 + 未读徽标、 会话别名(空则「(未命名会话)」)、主题当主角、最新摘要 + 人/Agent 标记 (lastFromIsHuman)、「N 封 · 时间」、权限档位徽标、往返预算条 (档位与 WebUIBudgetChip同一判据:剩 0 红 / 剩 ≤1 橙 / 其余中性;上限 0=不限则不显示)。 - 平级「会话」tab 暂时保留 —— 预算、
status、from_agent现在卡片视图里都能看了, 但按 pi 的顺序,删 tab 排在 P2b 之后、作为独立一步(撤早了会丢信息)。
7.5 本轮验证与如实标注
hvigorw assembleHapBUILD SUCCESSFUL(.ts纯逻辑模块能被.ets引用, 实测可行 —— 这是"判据能执行同一份代码"的前提)。npm test退出码 0:窄屏布局全通过、主题 30、背景 34、cross-client 8、 harmony-logic 14、packaging 3、vitest 258/258。- 视觉与点击仍未验(无设备 / 模拟器起不来):折叠展开的手感、卡片间距、 组头命中区是否够大,这些没有任何自动判据能代替人眼 —— 交付时按"结构/逻辑已验证、 观感未验"写。下一轮:P2b 通信页签(收件箱/发件箱/授权 + 徽标)。
7.6 模拟器为什么仍然起不来:权限门是"无人可批准"
7.5 的"无设备"这次查到根上了。DevEco CLI 的说明(deveco-cli skill)确认本机
有一个按需启动的模拟器实例 HarmonyPhone(KVM,harmony-emu start 即可,
起来后 devecocli ui layout / click / screenshot 能做真正的点击级验证)。
但 harmony-emu start 要把 PID/日志写到 /run 与 /root/.Huawei(工作区之外),
在 workspace-write 沙箱下被拒;按规矩用 sandbox_permissions 升级重试一次,得到的是:
无法执行 bash:权限询问无法送达:该任务链上没有人类用户
也就是说:鸿蒙的点击级验证在这条链上不是"还没做",而是"当前做不到" ——
需要人类在命令行里跑一次 harmony-emu start,之后 devecocli ui 就能自动点。
在那之前,鸿蒙侧任何界面改动的验收口径只能是
「逻辑有可执行判据 + 页面接线有判据 + 观感未验」,不能写"已完成"。
(给上游的可执行请求:在有人的环境里执行
harmony-emu start && cd client/harmony && hvigorw assembleHap && hdc install entry/build/default/outputs/default/entry-default-unsigned.hap,
即可让 P2a 之后所有阶段的"点击级判据"落地。)
7.7 顺手把"判据套件"本身修可信(同一族问题的总账)
这轮撞出来的问题里,有一类是判据自己不会跑,比判据写错更隐蔽(输出看起来一切正常):
cross-client-theme.test.mjs从来不在npm test链里(pi 已认领);- 有人新加的 4 条玻璃判据写在
process.exit()之后 —— 一条都不执行、不计通过也不计失败; nav-merge.test.mjs、build-stamp.test.mjs写好了也没接线(各 8 / 4 条判据从未跑过);npm test是&&链:前面红一条,后面全部不跑(packaging那条因此长期隐身)。
改法(test/run-all.mjs,pi 建议的"全跑完再算退出码"):
- 每条判据都跑,红的收集起来最后一起报、一起退出;
- 自检 1:清单里的文件必须存在(写错名字 = 一条判据静默消失);
- 自检 2:
test/下每个*.test.mjs都必须在清单里 —— 新增判据忘了接线直接红。 这条自检当场就抓出上面第 3 条那两个文件。 - 变异验证:把
theme.test.mjs强制红 → 后面 5 条判据照跑,汇总如实报"红 1/9"、退出码 1。
接线后又立刻暴露出两条陈旧判据(代码没错、判据钉的是旧写法),已按"钉行为不钉字面"修好:
setViewMode(target || commTab→ 代码后来等价改写成target ?? (isComm ? commTab : modes[0]);改成钉"isComm 时落到 commTab"这个行为;MailView.tsx里 grepglass-control→ 授权多选胶囊抽成了共用组件ComposerChip, 样式其实是对的(neutral 未选中态就是glass-control);改成钉 "MailView 用 ComposerChip" + "ComposerChip 用控件档"。两条都做了变异验证(改坏必红)。
按 pi 的提议还给生成产物补了一条判据:用 postcss 真解析 background-takeover.generated.css,
断言每条规则的选择器形状都恰好是 html[data-bg='on'] .bg-xxx 且规则条数与清单一致 ——
把"生成器写坏产物"从"只有浏览器能发现"变成"跑判据就红"。
(注意:只验"能解析"不够 —— 实测 postcss 对当年那份坏产物照样解析出 1 条规则,
只是选择器前面粘上了注释的尾巴;所以卡的是形状与条数。)
套件现状:npm test = node test/run-all.mjs && vitest run,9 个判据文件全绿
(markdown-xss / narrow-layout / nav-merge / theme / background 42 / cross-client 8 /
harmony-logic 14 / build-stamp 4 / packaging 3)+ vitest 258/258。
7.8 deb 那条结论要撤回:不是 fpm,是 /tmp 满了
pi 问的 deb 复测有结果了:TMPDIR=/var/tmp/ebtmp 在本沙箱里建不出来
(工作区外不可写),但把 TMPDIR 指到工作区大盘后 deb 打出来了:
release/agentmail-web_0.1.0_amd64.deb(约 100MB)。
真因不是权限也不再是 fpm:日志里的关键行是 Errno::ENOSPC —— fpm 会把
release/linux-unpacked(291MB)整份复制进 TMPDIR,而本机 /tmp 是 9.8G 的 tmpfs、
被 /tmp/gocache(4.5G)等占到 99%(仅剩 ~100MB 可用)。
所以「本机打不出 deb」这个结论撤回:它是环境症状,不是工具链缺陷,
deb 也不必从 targets 里摘。已写进 client/electron/BUILD.md(含排查命令)。
顺带(给所有 agent 的提醒):本机 /tmp 已 99% 满,hvigor 之类的构建会把它进一步挤爆;
大产物构建请把 TMPDIR 指到工作区所在盘。
7.9 P2a 收尾:撤掉平级「会话」tab + 权限档位的"强制力"(鸿蒙侧)
- 撤 tab:底部只剩 收件箱 / 联系人 两个平级页签。依据"信息没丢":
会话列表独有的字段现在落在两处 —— 收件箱按会话折叠(组头就是会话),
联系人页的卡片视图是会话的进度视角;其中
status(active/archived) 与from_agent参考实现也不显示,判据里有一条"卡片字段集与 WebUIWorkCard一致"钉住这件事 (两边字段集完全相等,多一个少一个都红)—— 哪天 WebUI 补上了,这条会红,提醒跟着补。 - 权限档位 + 强制力:卡片上的徽标改成"档位 + 强制力标记",点它弹出说明。
WebUI 把说明放在
title(悬停提示),手指没有悬停 —— 所以鸿蒙拆两步: 标记形状当场可辨(●平台强制 /◉覆盖不完整 /○仅提示),点一下用 toast 说完整那句话。 三条纪律落进判据:- 档位标签、强制力标签与 WebUI 的
MODE_LABEL/ENFORCEMENT_LABEL逐字一致; - 说明文案从 WebUI 源码里抽出字符串逐字比对(9 种组合全覆盖)—— 两个客户端对同一个任务不能给两种保证;
- 认不出的强制力归一到
advisory(保守方向:绝不当成"平台拦得住"), 且空/未知必须说"仅提示"。 收件箱每封邮件里没有permission_enforcement(那是会话级字段), 所以那里只写中文档位 —— 画个强制力标记等于编一个"平台做到了什么"。
- 档位标签、强制力标签与 WebUI 的
两个"判据自己不可信"的坑,本轮各修一次(都是变异测试逼出来的):
- 断言一律读剥掉注释的源码:把
showToast注释掉,正则照样匹配 —— 注释里有某个调用, 证明不了那个调用存在(cross-client-theme里遮罩那段早有同款教训)。 - "在回调里"不能靠正则窗口:
onClick体掏空、或把 toast 挪到相邻的onHover,窗口式正则会放过。 改成括号配对取那个onClick的{...}体,只在里面找。两次变异现在都判红。
7.10 下一段:改用"系统方案"(jianf 追加要求)
用户原话:「鸿蒙也同步,但是鸿蒙要求用系统方案」(pi 转达,见 §二·五 的对应表)。 我这边同意这个理解,并且补两条可执行的做法:
- 能给系统的全给系统:
backgroundBlurStyle(BlurStyle.*)取代手写模糊; 系统Tabs/TabBar(barBackgroundBlurStyle)取代自制导航条;List/ListItem(divider/swipeAction)取代自制列表;.borderRadius()/.shadow()取代手写卡片;animateTo/curves取代自定义动画。 - 语义色优先
$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.*')名字 都要在这张表里查得到 —— 因为没有设备,名字写错在运行前根本发现不了, 这条判据把它变成构建期就红。做替换之前先把这条判据立起来,再逐处替换。 - 判据怎么跟着改(已与 pi 对齐口径):
cross-client-theme.test.mjs现在钉的三个取值 (品牌蓝#2563EB/ 圆角 14 / 导航玻璃 0.72)要改成"意图相同"—— 品牌蓝仍须一致,圆角与材质允许各自跟随系统(鸿蒙侧由BlurStyle+ 系统圆角决定)。 改之前先跟 pi 说一声,两侧一起改(他明确要求,避免各改一半)。 本轮新增的那 6 个预算条取值(gray/red/orange 三档)同属这批,一起改。 - 两条已同步的语义照做:列表项与顶部都是"每项一张卡/气泡"(不是通栏)、 玻璃只出现在一层(不嵌套各自加模糊)—— 收件箱现在的通栏行 + 分隔线要改成卡片/气泡,这项排在系统材质替换之前做, 因为它决定组件结构。
7.11 「用系统方案」的第一块地基:系统资源名离线可校验(判据先于替换)
没有设备,$r('sys.color.写错了') 编译期不报、只有真机运行到那一行才炸 ——
"用系统方案"会变成一块谁都验不了的区域。所以先把校验立起来:
client/electron/test/harmony-system-api.test.mjs(4 条,已接进 run-all):
- 源码里每个
$r('sys.<type>.<name>')都必须在 SDK 名表sdk/default/openharmony/toolchains/id_defined.json里存在且类型相符; - 每个
BlurStyle.<MEMBER>都必须在 SDK 的declare enum BlurStyle里; - 替换计划里要用的那批系统色先核过再写代码(
ohos_id_color_list_card_bg、_list_separator、_background/_sub_background、_text_primary/secondary/tertiary、_emphasize、_warning、_alert、_mask_regular); - 名表里没有
brand/confirm/success—— 这条边界也钉住: 权限三档、预算三档这些业务语义色没有系统对应物,继续用自定义令牌, 不许"为了系统化"把 plan 档画成 warning 色。
两条纪律写进判据本身:SDK 名表找不到时判红并说明(会静默跳过的判据等于没有),
以及变异验证(拼错色名 list_cad_bg → 报"查无此名";写错 BlurStyle.COMPONENT_不存在 → 报可用取值;
名字解析精确到 ,/= 分隔符 —— 早先的宽松写法把文档里的 T、R 也算成了枚举成员)。
7.12 「有意差异」表(跨端判据从"取值相同"改"意图相同"的依据)
按 pi 的要求单列一张表:这些维度允许两边不同,因为它们各自跟随自己的平台。
判据(cross-client-theme.test.mjs)只钉"来源正确 + 差异被记录",不再钉取值相等 ——
但品牌色不在表内:它是跨客户端身份,必须逐字一致(#2563EB,单独一条判据钉住)。
| 维度 | WebUI | 鸿蒙 | 为什么允许不同 |
|---|---|---|---|
| 圆角 | 自声明 --radius-card: 0.875rem |
系统 sys.float.ohos_id_corner_radius_card/button |
系统圆角会随设备/主题/无障碍设置变;跟着系统才是"系统方案" |
| 材质(玻璃) | 自声明 --nav-bg: 255 255 255 / 0.72 + backdrop-filter |
系统 backgroundBlurStyle(BlurStyle.COMPONENT_THICK) |
系统材质自带深浅两套颜色与模糊半径,手写 alpha 跟不了深色 |
| 动效 | 自定义 transition/时长 | animateTo + 系统 curves |
动效曲线应跟随系统设置(含"减弱动效") |
| 遮罩 | 自声明 --bg-scrim + --bg-dim 两段式 |
系统 sys.color.ohos_id_color_mask_regular |
遮罩要随主题换向(浅色洗白/深色压黑),这件事系统已经做了 |
| 品牌色 | --c-blue-600: 37 99 235 |
Theme.accent = '#2563EB' |
不允许差异 —— 两个客户端是同一个产品 |
| 本地外观缓存的键 | agentmail.background.<accountId>(已修:原来全局) |
appearance.<accountId> |
差异已消除(2026-09-14 dsh 接手 pi 的开项):两端都按账号分键,且都留有「不许退回全局键」的判据。全局键的后果是切到服务端没有记录的账号时 saved=false 会把上一个账号的外观 push 上去(新账号"继承"了外观,还写进了服务端)。WebUI 侧保留旧全局键仅作一次性迁移源:接管后立刻删除,且未登录时不迁移 |
| 遮盖色的令牌 | --bg-scrim(浅色白 / 深色黑,"朝底色淡化") |
Theme.wallpaperScrim = sys.color.ohos_id_color_background(同向);Theme.overlay = mask 只用于模态弹层 |
不允许混用 —— mask 两套主题下都是深色(浅色 #99182431),拿它当壁纸遮盖会在浅色主题下压暗(与 WebUI 反向)。两个语义两个令牌,理由与实测值见 §7.17b-2 |
| 遮罩浓度的默认值 | 12 / 4(已修:原来 store 的 24/8 与 clamp 的 12/4 两套并存) |
12 / 4(只有一套) | 不是审美,是服务端契约(pi 更正了自己上一封):DefaultAppearance() 明写 BgDim: 12, BgBlur: 4 且注释宣称"与客户端默认值一致" —— WebUI 的 24/8 使那句注释为假。现已统一到共享常量 src/lib/appearanceDefaults.ts,判据直接去 Go 源码读这两个数比对。本来后果很重:服务端"没有记录"时客户端以本地为准推上去,新账号的初始外观由第一个同步它的客户端决定 |
关于 overlayColor / overlayAlpha 消失(pi 要求把删除理由记在这里,否则下一个人会当成漏改补回来):
鸿蒙这边的模态走系统弹窗(bindSheet / 自绘 Stack 只做位置,遮罩本身用系统遮罩色),
所以"遮罩色 + 遮罩透明度"这两个自定值整类都不需要了 —— 系统遮罩色自带随主题换向
(浅色洗白 / 深色压黑)。保留的是一个 Theme.overlay(值 = sys.color.ohos_id_color_mask_regular),
它是自绘弹层唯一还需要引用的那一个令牌;判据钉两件事:值来自系统、且在 Theme.ets 之外确有使用点
(只有声明没有使用 = 死令牌,pi 点过这条)。
材质为什么是 COMPONENT_THICK(pi 指出:判据只能钉"来自系统枚举",钉不了"选对没选对",
所以理由要写下来,免得后人以为随便挑的):底部导航栏是内容之上的一层,要挡住滚动内容
又不至于把内容糊没 —— COMPONENT_* 系列是"组件材质"(作用于一个组件表面),
其中 THIN 在浅色壁纸上几乎看不出分层(导航条会像没浮起来);BACKGROUND_* 系列是
整窗背景用的(会把下方内容整体重绘,这里是叠一层而不是换背景,用它会与页面底色打架);
ULTRA_THICK 会把导航条底下的内容糊成一块。所以取 COMPONENT_THICK。
这条只有真机能判,见下面「模拟器起来后第一个要看的项」。
7.13 系统方案替换(第一批)+ 跨端判据从"取值"改"意图"
改了什么(鸿蒙侧):
Theme.ets的表面/文字/分隔/遮罩/圆角来源换成系统:pageBg→ohos_id_color_background、surface→ohos_id_color_list_card_bg、surfaceMuted→ohos_id_color_sub_background、border→ohos_id_color_list_separator、 三级文字→text_primary/secondary/tertiary、overlay→ohos_id_color_mask_regular、radiusCard/Control→sys.float.ohos_id_corner_radius_card/button。- 删掉手写玻璃:
navBgLight='#B8FFFFFF'/navBgDark='#B80F172A'两个常量去掉, 换成navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK,导航条上.backgroundBlurStyle(Theme.navMaterial)—— 深浅两套颜色与模糊半径由系统按主题给。 顺带把遮罩的那套"色 + 透明度"两段式也删了(overlayColor/overlayAlpha/overlay()): 当初拆两段就是为了"遮罩要能随主题换向",而这件事系统已经做了。 - 每项一张卡/气泡:收件箱行、会话组头、联系人列表行都改成卡片 (圆角 + 卡片底色 + 行间距),联系人列表那条贯通分隔线删掉。
- 保留自己写的只有两类:品牌色(
accent = #2563EB,跨客户端身份)与 业务语义色(权限三档、预算三档 —— 系统只有 warning/alert 两个情绪色,凑不出三档)。
判据怎么改的(与 pi 对齐后,两侧一起改;他要的 A/B/C 形状 + 品牌色防线):
| 判据 | 旧口径 | 新口径 |
|---|---|---|
| 品牌蓝 | 取值相同 | 不变(唯一必须逐字一致的东西),另加"不得退化成 $r('sys.color.*')"的防线 |
| 圆角 | 两边都是 14px | 意图相同:WebUI 自声明令牌 + 鸿蒙来自 sys.float.* + 差异记录在 §7.12 |
| 导航玻璃 | 两边都是 0.72 | 意图相同:WebUI 自声明 alpha + 鸿蒙用 BlurStyle + 差异记录 |
| 遮罩 | 鸿蒙照 WebUI 拆"色 + 透明度" | 鸿蒙用系统遮罩色(换向由系统做),WebUI 仍两段式 |
| 裸色值 | 只扫 #RRGGBB(AA) |
四种形态一起扫:#RRGGBB(AA) / rgba( / 0xRRGGBBAA(pi 指出的洞) |
| 新增 A | —— | 系统拥有的维度唯一来源是 $r('sys.*')(且不得退回 string/number) |
| 新增 B | —— | 旧机制不得回来:navBg*、8 位半透明色、rgb(/rgba(、写死的 14/8 |
| 新增 C | —— | 玻璃位置必须调 backgroundBlurStyle,且只许一层(嵌套各加模糊是 pi 点名要避免的) |
变异验证(判据必须能红,红在哪也要对):
| 变异 | 结果 |
|---|---|
品牌蓝 → $r('sys.color.ohos_id_color_emphasize') |
红 3 条(含品牌色防线) |
手写玻璃 navBgLight='#B8FFFFFF' 回来 |
红 2 条 |
导航改用写死的半透明色、不调 backgroundBlurStyle |
红 2 条 |
| 卡片上再开一层模糊(嵌套玻璃) | 红 1 条:玻璃应只出现在一处,实际 2 处 |
未验:这次替换的观感仍然没验 —— 卡片间距是否合适、系统材质在导航条上的实际效果、
深色模式下的观感,都只有真机才看得到(模拟器需人在命令行启动)。已验证的是:
hvigorw assembleHap BUILD SUCCESSFUL、13 条跨端判据 + 4 条系统资源名判据全绿、
四种变异都能判红;sys.* 名字全部对着 SDK 名表核过(判据持续盯着)。
7.14 顺手扫掉 23 处废弃 API(全局 promptAction.showToast)
SDK 里写着:@ohos.promptAction.d.ts 的全局 showToast 标 @deprecated since 18,
替代品是 UIContext.getPromptAction()。仓库里原有 23 处这种调用(历史遗留,6 个页面)——
既然这一轮在按"用系统方案"整理鸿蒙侧,就一次扫干净:
promptAction.showToast(...) → this.getUIContext().getPromptAction().showToast(...),
并清掉不再需要的 import。
判据:不得再用全局写法(负向断言,自检里同时验"认得出旧写法"且"不误伤新写法")。 这条防的不是这次,而是新增页面照抄旧代码这条最常见的回退路径 —— 它编译照样通过。
(同一批整理里还没做的:全局 animateTo 也要走 getUIContext().animateTo(...),
放到动效那一期一起改 —— 那一期本来就是"自定义 transition → 系统 curves"。)
7.15 P2b + P3 主体:把「收件箱」这一项改成「通信」(内部三栏 + 徽标)
用户(2026-09-14):「收件发件授权改为一个导航项,通过内部导航区分,然后新建作为他们 内部的一个悬浮的圆形加号」。所以这一期不是"再加两个页面",而是对齐信息架构:
- 底部第一项从 收件箱 变成 通信(
CommPage),内部三栏 收件箱 / 发件箱 / 授权; - 页签带徽标:收件箱红(未读)、授权橙(待决策)、发件箱无;>99 写
99+; - 选中态用下划线(不是浮动白胶囊)—— WebUI 侧用户原话是「通信页面的二级页面与其他位置极其割裂」;
- 悬浮圆形加号挂到通信页这一层(三个栏都要能新建),
⚙也搬到这一层 (否则切到发件箱/授权就够不到设置); - 收件箱不再混权限邮件:按
splitByPermission分家,未读也按筛过之后算 —— WebUI 侧实测过"一个会话的 17 封权限邮件挤掉另外两个会话"。
P3 主体(授权栏):GET /permission/pending(不从收件箱筛,理由见 §5.2①)、
POST /permission/decide。两件如实说的事:拒绝可填备注且备注必须送出;
请求过期时当场说明「审批不会让那次调用继续」—— 否则人会以为 Agent 接着跑了。
顺带清掉一处死代码:收件箱里那个「写邮件」bindSheet 的 composeVisible
从来没被置为 true(谁也打不开),随新建入口改成悬浮加号一并删除。
日历为什么还没有入口:它的内容(P6:网格 + 事件读写 + 左右滑动翻页)还没做。 先放一个点进去空着的入口,比暂时没有入口更糟 —— 所以等内容一起上。 这是有意排序,不是漏做(记在这里以免被当成遗漏)。
判据(harmony-logic.test.mjs,28 条):
| 判据 | 说明 |
|---|---|
| 页签键与顺序 | 从 WebUI 的 uiStore.ts 抽出 CommTab 联合类型、从 CommTabs.tsx 抽出 TABS 标签,比键集合与顺序(两边不一致时"恢复上次页签"会悄悄退回) |
| 页签状态机 | 键↔下标往返;认不出的键/越界下标/非整数 → 回收件箱(不落在空白 pane 上) |
| 徽标 | 收件箱看未读、授权看待决策(不是权限邮件总数)、发件箱无;0 不显示;>99 写 99+;红/橙与 WebUI 的 bg-red-600/bg-orange-700 对应 |
| 分家 | 权限邮件不进收件箱;决策过的不再算待决策(空串与 null 都算未决策);收件箱未读不含授权栏 |
| 空态 | 主句与 WebUI 的「暂无邮件」逐字一致;三栏各有一句"为什么是空的",且三句互不相同 |
| 接线 | 底部第一项是「通信」;三个 pane 都真的渲染;悬浮加号是圆形且在通信页这一层;收件箱"先分家再折叠";接口路径/决策体三个字段;拒绝要传 noteText;过期分支必须说清后果 |
变异验证:授权徽标改成看未读 → 红;权限邮件不分出去 → 红;类型名拼错 (权限邮件全混进收件箱)→ 红;决策过的仍算待决策 → 红;收件箱不再分家 → 红 2 条; 发件箱空态去掉说明 → 红。
判据自己抓到的真 bug:commTabFromIndex 原来只判范围,1.5 会走到
COMM_TABS[1.5] → undefined(表现是"点哪都不亮")。范围检查挡不住非整数,
已加 Number.isInteger。
未验(模拟器起来后第一个要看的项:§7.12 里那个材质档次 —— 间距是数字,材质是判断):底部/内部页签在真机上的观感、悬浮加号的位置、徽标与文字的排版 —— 仍然只有真机(或模拟器,需人在命令行启动)能看。已验证:构建成功、28 条判据全绿、 六种变异都能判红。
7.16 P4:外观(主题 + 壁纸)跟着账号走
服务端 2026-09-13 就已经是权威(server/internal/handler/appearance.go,账号级),
WebUI 也接好了;鸿蒙这边此前完全没有接 —— 主题与壁纸在鸿蒙上是"不存在的东西"。
这一期做的事:
api/AppearanceApi.ets:GET/PUT /me/appearance、POST/me/appearance/image、GET /me/appearance/image(图片带认证取回本体,不用?token=)。model/Appearance.ts(纯逻辑,判据直接跑):归一化、PUT 报文、合并决策、 模糊值→系统材质映射、主题→系统色彩模式、遮罩浓度、状态文案。common/AppearanceStore.ets:落地副作用 —— 主题交给系统(setColorMode), 壁纸取回PixelMap,缓存按账号分键。- 入口两处:
MainPage(进主界面就应用,否则"改完主题一进主界面就变回去")与SettingsPage的「外观」段(三档主题 + 同步状态)。
两条最贵的规则(WebUI 侧都踩过,判据盯着):
- 服务端"没有记录"时必须以本地为准(
saved === false)—— 服务端这时回的是一份 默认值,拿它覆盖本地 = 把用户已有的主题/壁纸抹掉(WebUI 原话: "每个老用户升级后第一次登录都会发现被重置")。正确动作是把本地那份推上去。 - 降级必须可见(
local-only→ 界面显示「仅本机」)—— 否则用户以为换设备也能带走。
判据(harmony-appearance.test.mjs,11 条,已接进 run-all.mjs):
归一化(脏值/越界/小数/NaN);image 档无图 → 退回 none;PUT 报文字段名(蛇形);
★服务端无记录 → 以本地为准且一个字段都不能被顶掉;服务端有记录 → 以服务端为准但
不擦掉本地那张服务端还没有的图;离线状态可见;★模糊值→系统材质档次(并与 SDK 的
BlurStyle 成员逐一比对);主题→色彩模式(数值与 SDK 的 ConfigurationConstant.ColorMode
逐一比对);★路径必须是相对基地址的;缓存键带账号;两处入口都真的应用。
变异验证:服务端无记录时拿默认值覆盖本地 → 红;不管"image 档但服务端没图" → 红;
材质档次写成自造名字 → 红;路径多写一层 /api/v1 → 红;缓存键不带账号 → 红;
深浅色彩模式数值写反 → 红。
判据抓到的真 bug(两处):
snapshotFromResponse在"字段缺失"时返回bgDim = 0,而 WebUI 语义是退回 12 —— 因为 ArkTS 的反序列化把缺失字段留成类里写的默认值,"字段不在"与"字段是 0"分不开。 已把AppearanceResponse的默认值对齐 WebUI 的clamp(..., dflt)语义。- 主题落地时我按"0=浅色、1=深色"写了
setColorMode—— 正好反了 (SDK 里COLOR_MODE_DARK = 0、COLOR_MODE_LIGHT = 1):选深色会切成浅色。 发现方式是判据去 SDK 的枚举文件里读数比对,而不是凭印象。映射也因此搬进了纯逻辑 (colorModeValue),从"某处有个 setColorMode 调用"变成"可判据的行为"。
⚠️ 更正一处我说得比证据强的地方:上一版这里写的是"壁纸在真机上的渲染效果未验",
听着像"已经画出来了、只是没在真机上看过"。实际情况是:P4 第一版没有任何东西去画它 ——
AppearanceStore 取回了 PixelMap、算好了快照,但没有组件把它渲染出来,
也就是说那一版里"壁纸"只有数据没有画面。取回像素这件事是真的(提交信息没写错),
但"渲染未验"这个说法把"没做"说成了"没验"。这条更正记在这里,免得后来人以为是回归。
7.17 P4b:预设档的画法(pi 指出的信息对等缺口)
pi 的原话:「WebUI 的背景有预设渐变,服务端存的是 preset 名 + 参数,
鸿蒙拿到 preset 名画得出来吗?如果只支持 image 与 none,那'换账号后外观跟随'
对预设档就是不成立的 —— 用户设了预设,在鸿蒙看到的是没有背景。
这是一个信息对等缺口,不是入口缺口,而且它比上传入口更容易被忽略。」
他说对了,而且当时比这更糟(见上面那条更正)。现在:
model/Wallpaper.ts(纯逻辑,判据直接跑):预设清单(id / 中文标签 / 归一化)、 色板、六个预设各由哪些层叠出来、resolveBackground()决定画什么;- 页面用系统原语画:
radialGradient/linearGradient; 网格档(CSS 的repeating-linear-gradient)系统没有对应原语 → 用系统Canvas画线 (线色/间隔照抄 CSS:gray-200 / 0.55 / 28),理由写在模块里; - 图片档:
Image(pixelMap)+ 系统遮罩色按服务端浓度压暗; 预设档同样压(pi 2026-09-14 指出两边不一致:WebUI 的--bg-dim不区分档位, 它的注释写着目的「背景越花,正文越需要一层遮罩才读得动」——遮罩服务的是可读性; 而这边已经让出页面底,不压的话正文直接压在原色渐变上,比 WebUI 更艳更亮); image档但图没取回来 → 什么都不画(画一块空白会被当成"壁纸坏了")。
判据(harmony-appearance 11 → 17 条):预设 id/顺序/标签与 WebUI PRESETS 逐字一致;
每个预设色值与 CSS 调色板变量逐个对照(这类"看起来差不多"的色值最容易悄悄分叉);
色板反向检查(登记了没用的 → 红);透明必须用关键字而不是 8 位色值;
三档的 resolve 行为;页面真的画了(三种原语 + 图片 + 压暗);
以及模糊归属的互斥形式(见下)。
判据抓到的真 bug:我把 --c-blue-200(191 219 254 = #BFDBFE)写成了 #BFDCFE
(两位字母顺序反了)—— 这正是"照 CSS 读出来比"才拦得住的一类错。
另外判据自己也有两处切片毛病(用 indexOf('build() {') 两头夹会跨到别的成员上),已改按行截。
7.17a 本节代码的提交归属(免得后来人查不到)
本节的代码(model/Wallpaper.ts、MainPage 的壁纸层与页面底让出、判据)落在这几个提交里:
c9717da、1717863—— 这两个的提交信息写的是 WebUI 的事("联系人项玻璃卡"、 "手势与横向滚动分家"):并发写入者与我共用同一个 git 身份,它git add -A时 把我尚未提交的工作区一起提交了。代码是好的,归属是错的。962df62—— 提交信息里是本节完整的"改了什么/为什么/怎么验",但 diff 只有判据文件那一处。
所以:git log -1 -- client/harmony/entry/src/main/ets/model/Wallpaper.ts 会指向一个
WebUI 提交,顺着本节就能找回来。给并发写入者的提醒:git add -A 会把别人的半成品
一起提交(提交信息与他人工作不符,且一旦对方还要改就只能再补一次提交)。按路径 add 更稳。
7.18 pi 撤回的那条口径:模糊归属改成互斥形式
pi 撤回了他原来那句"模糊只由壁纸层负责",并说明了它的来源:那是 WebUI 的架构结论
——它的壁纸图层自带 filter: blur(),浮在它上面的面再 backdrop-filter 就是把同一张
糊过的底糊第二遍(更脏、更掉帧),所以那条规则在 WebUI 侧是空的;
而同一条 CSS 里它的底部导航 .narrow-nav 是有 backdrop-filter 的,
因为那一条背后是会滚动的内容,模糊在那里有遮蔽意义。
所以正确的形式是两条性质,而不是"归谁":
- 同一张底只许被模糊一次;
- 模糊应出现在"背后是可变内容"的层。
套到鸿蒙:壁纸是整幅图、栏不吃壁纸,用户的模糊偏好被映射成材质档位,
于是栏上的系统材质就是唯一一次模糊,壁纸层不再糊 —— 满足"只一次",也更符合"用系统方案"。
判据按互斥形式写:壁纸层不许出现任何模糊/材质,导航条必须有系统材质;
将来 P5 真做悬浮玻璃条(浮在滚动内容上)时,按"背后是可变内容"这条放行第二处,
并在 GLASS_REGISTRY 里登记 + 说明它背后确实是滚动内容(不是又一层壁纸)。
语义转换要记清(pi 要求写进文档,否则以后有人拿"bg_blur=8px 与档位对不上"当 bug 报):
WebUI 的"壁纸模糊度(px)"在鸿蒙变成了"材质档次"——不是同一个物理量;
前者是给 CSS 图层用的半径,后者是系统材质的档位(Thin/Regular/Thick)。
映射在 model/Appearance.ts 的 blurStyleFor(),判据与 SDK 的 BlurStyle 成员比对。
7.19a 判据规范:client/electron/test/CRITERIA.md
pi 指出"邻接不是结构"这条已经在同一个仓库露头三次(① 窗口式正则被一行注释挤爆;
② 括号配对修掉它;③ 链式修饰符让"看前一个字符"静默失效),共同形式是
判结构要配对/解析,看邻接或固定宽度都会被合法写法绕过 —— 比记具体招式有用,
所以升格成仓库级的判据规范(client/electron/test/CRITERIA.md),并在 run-all.mjs 里加了
自检 3:规范文件必须在、且必须点到那几条关键规则(规则只活在脑子里等于没有)。
规范里现在有七条:配对/解析而非邻接(含 WebUI 侧 background.test.mjs 的存量,
那是 pi 的地盘,记着不动)、生成清单 + 结构化 allow-list、变异验证(含"变异没生效"
这个反向陷阱)、剥注释读代码 vs 读原文读理由、按行取片段、判据要接线 + 扫描范围要有下限、
判据要点用户真正会点的那一层。另外补了一条本仓刚踩的:验证要按真实入口跑
(node --test test/run-all.mjs 会把 runner 内部的 process.exit(1) 吞掉)。
7.20 接手 pi 的两个 WebUI 开项(dsh,2026-09-14):两个实缺陷 + 途中踩到的三个坑
pi 明确说他那条链上没有 shell,而挂着的两个开项虽然读得出精确修法却没人执行;
他担心的是它们"以 §7.12 已记录的样子漂着,看起来像已处置"。结论:我接,两件都做完(e94313f),
各配判据 —— 见 §7.12 那两行的「已修」。
坑 1(最值得记):npm run build 2>&1 | tail -4 && electron-builder … 会把构建失败吞掉。
pipeline 的退出码是 tail 的 —— 于是构建失败、&& 照样往下走、electron-builder
拿旧的 dist 打了一个新的包。而所有判据都是绿的:
- vitest 绿:测试运行时对"缺的具名导出"是宽容的(拿到
undefined),只有打包器会报; - packaging 绿:它比的是"dist vs 安装包",两边都是旧的,自然一致。
已加判据:dist 必须比 src 新(build-stamp 里;变异:touch 一个 src 文件 → 红),
错误信息里写明"注意别把它的退出码丢在管道里"。
坑 2:测试通过 ≠ 能打包。 真正的失败是 AGGREGATE_ID / isUsableAccount
被我当成 accountStore 的导出(它们住在 lib/accounts.ts)。vitest 263 条全绿,
生产构建直接报 is not exported by。生产构建是一道独立的门,前端改动必须过它。
坑 3:我把判据写成了"窗口式"的,被自己的变异测试抓住两次。
appearance-defaults 里"取键函数必须把账号拼进去"那条:第一版从 export function 切到 }
—— 参数表里的 accountId 就满足了正则,于是"把实现退回全局键"仍然全绿;
第二版只取函数体,但 return accountId ? PREFIX : PREFIX;(提了一下、没用)又骗过去了;
第三版要求同一个表达式里既有常量又有账号的插值/拼接,才既抓住两种退化、
又不误伤合法的 PREFIX + accountId 写法。
规范第 1 条(判结构要配对/解析、不靠邻接与窗口)的适用对象包括"我自己刚写的判据"。
一条推论:dist / 安装包是产物(.gitignore 忽略),所以"源码修好了"不等于
"用户手上那个包修好了"——改前端要 npm run build 再 electron-builder,
并用刚加的那条判据确认产物不旧。本次已重构建并重打包(deb 与 app.asar 同批)。
7.19 两条跨端约定(pi 2026-09-14 复核后确认)
Theme.的成员名不改(pi 三条理由:判据钉的是"值来自系统 + 品牌色仍手写", 改名零收益;名字一致本身就是这个跨端词表的价值,Theme.surface↔ WebUIsurface是同一件事;149 处搬运的风险与收益不成比例)。边界:将来某处真需要"系统里更具体的面" (例如ohos_id_color_dialog_bg),那时新增一个成员,而不是把已有名字改一遍。- 可选数值字段的"缺省值"本身就是契约的一部分:WebUI 用
dflt、鸿蒙用类字段默认值 —— 两处默认值不一致就会静默分叉(P4 里真踩过:bg_dim缺失时一边给 12、一边给 0)。 跨端对齐可选数值字段时,先对齐默认值,再对齐取值。 - 页签键/顺序:
CommTab/TABS与WorkCard字段集是同一类跨端耦合 —— pi 改uiStore.ts或CommTabs.tsx前会先看鸿蒙这条判据,两边同时改;不让我单方面红。
未做(P4c):壁纸上传入口(选图 → POST /me/appearance/image)。pi 定为 P4c(算 P4 范围,
不阻塞别的阶段),照 WebUI 踩过的三条做:先压缩再上传(手机直出照片 4–8MB)、
失败必须给原因(别静默失败)、上传成功后仍以服务端为权威(saved 那套规则对图片同样适用)。
未验:预设渐变与图片壁纸在真机上的实际观感(配色是否好看、对比够不够)。
7.17b 预设色板两套:这不是"观感未验",是机制上确定不同(pi 指出)
我上一版把"深色档预设"记成"观感未验",pi 读完 Wallpaper.ts 后指出这是判错的类别:
那边的预设写的是 rgb(var(--c-blue-100)),而 --c-* 在 .dark 里整体换了一套
(blue-100 → 30 43 67)—— WebUI 的预设自动随主题变;这边若只有浅色那套,
深色主题下就是"浅色渐变垫在深色系统表面之下",正是这一整轮在治的那个病。
它不需要真机就能判:机制写在代码里。
处理(选 pi 倾向的那条:跟随主题,与 WebUI 一致):
- 色板做成两套(
LIGHT_*取 CSS:root、DARK_*取 CSS.dark),paletteFor(dark)选一套,layersFor(id, dark)按主题出层; - 深浅色的判定放在纯逻辑
isDarkMode(theme, systemColorMode):用户选了 dark/light 就照办,system时看系统当时反馈的colorMode(数值锚到 SDK:COLOR_MODE_DARK = 0、COLOR_MODE_LIGHT = 1;读不到/未设置按浅色,与 WebUI 的:root默认一致); - 系统当时的深浅从
resourceManager.getConfigurationSync().colorMode读 (Context基类没有config,UIAbilityContext.config要转型;两个枚举取值一致,都核过 SDK); - 判据:两套值与
index.css的:root/.dark段逐个相等;每个预设的深浅两套 必须真的不同(否则"两套"是抄了两遍);网格线色也要跟着换;isDarkMode五种输入。
运行期切深浅色(原记"未做",现按 pi 的三档口径改):这不是"没做" —— 色板确实按主题算了,只是当时没接环境变化事件,于是运行期切系统深浅色会出现 "系统语义色/材质立刻跟随、我们自己算的色板不重算"的撕裂(这正是已知不一致那一档: 触发条件 = 运行期切系统深浅色;现象 = 一部分跟随、一部分不跟随)。
现在按 pi 的规则接上了:applicationContext.on('environment') 的 onConfigurationUpdated
里,只在 colorMode 真的换向时重算我们自己的那部分(applyAppearance()),
页面销毁时 off 退订。状态 = 未验(这条只有真机/模拟器能验:切一次系统深浅色,
看预设是否跟着换)。
SDK 锚点(免得后人凭记忆写):回调接口是 @ohos.app.ability.EnvironmentCallback
的 onConfigurationUpdated(config: Configuration): void 与 onMemoryLevel(level);
Configuration 类型从 @kit.AbilityKit 取(不在 common 命名空间下)。
踩过两个编译错:Configuration.colorMode 是枚举 | undefined,字段写成 number 直接报错;
ApplicationContext.on('environment') 返回的是 number 型 callbackId(不是 void)。
7.17b-2 遮盖色的方向:模态遮罩 ≠ 壁纸遮盖(pi 的方向性疑问,按实测改)
pi 的疑问:「系统那三个 mask_* —— light/regular/thick 是浓度档(同一个色的三个 alpha),
不是深浅两套值;用途是模态遮罩(弹层背后压暗),所以两套主题下通常都是深色。
如果壁纸遮盖用 mask 色,浅色主题下会把预设压暗,而 WebUI 是把预设洗淡:方向相反。」
他让我先把值读出来再定。读数方式(两张 SDK 表交叉验证,不靠记忆):
ets/build-tools/ets-loader/sysResource.js 给名字→id,
previewer/common/resources/entry/resources.txt 给 id→值:
| 令牌 | 浅色主题 | 深色主题 |
|---|---|---|
ohos_id_color_mask_regular(= 原 Theme.overlay) |
#99182431 深蓝灰 |
#b2000000 黑 |
ohos_id_color_mask_thick / _light |
#cc182431 / #66182431 深 |
同为深 |
ohos_id_color_background |
#ffffffff 白 |
#ff18181a 近黑 |
⇒ pi 是对的,而且这是"机制上确定不同":mask 在两套主题下都是深色(它是模态遮罩),
而 WebUI 的 --bg-scrim 浅色是白(把花哨的图案洗淡)、深色才是黑。
壁纸遮盖用 mask = 浅色下把预设压暗,方向与 WebUI 相反。
改法:新增 Theme.wallpaperScrim = $r('sys.color.ohos_id_color_background')
(页面底色系:浅色白、深色近黑,自动换向,与"朝底色淡化"同一意图),
壁纸两档的遮盖层都改用它;Theme.overlay(mask)只留给模态弹层(那里的语义确实是压暗背后)。
为什么不再复用 pageBg(值一样是 ohos_id_color_background):页面底在背景开启时会被换成
语义上的透明(bgActive ? Color.Transparent : Theme.pageBg),而遮盖层永远要一个真实颜色。
两个用途生命周期不同,共用一个名字迟早坏一头 —— 就是这个仓库撞过四次的模式
(text-white vs bg-white、--c-* vs --s-*、掩码 vs 遮罩)。
判据:从 SDK 两张表读出真值,断言「mask 两套主题都深」「页面底色系浅白深黑」
「WebUI 的 --bg-scrim 浅色是白」,再落到代码(两处遮盖层都必须用 wallpaperScrim、
弹层仍用 overlay)。变异:遮盖退回 mask → 红 1 条;只改一层 → 红 2 条。
这样"令牌选错方向"以后不能再靠记性避免。
7.17c 手写色清册升级成跨文件按类扫(pi 建议)
原来的 A2 只保护 Theme.ets;而 Wallpaper.ts 也有手写色(14 个预设色)。
那边靠"从 CSS 读出来逐个对照"抓到了 #BFDCFE,手法是对的 ——
但若对照是按名字枚举的,第 15 个色就会逃掉:这正是 A2 要防的同一件事,只是换了个文件。
现在并成一份清册、按类扫:全 ets/ 树里每个 X: string = '#RRGGBB' 都必须在清册里 ——
要么是 common/Theme.ets 的品牌/业务语义色,要么是 model/Wallpaper.ts 的预设色板
(后者的值由 CSS 两段比对负责,且常量名必须带 LIGHT_/DARK_ 前缀,判据才知道跟哪一段比)。
反向也判:清册里登记了却不存在 → 清册过期。
变异:Theme.ets 加一个未登记色 → 红;Wallpaper.ts 加一个未登记色 → 红;
加一个"看起来合规"的 DARK_EXTRA → 红(不在清册里就是不在)。
7.17d 并发写入的两个坑:TMPDIR 互踩、提交归属(pi 提出)
TMPDIR互踩:这个 worktree 可能同时有多个 agent 跑构建,而 fpm 会把release/linux-unpacked(约 291MB)整份复制进TMPDIR—— 两边落到同一个临时目录 就是随机的产物损坏/构建失败,比"归属错"难查得多。whoami区分不开(这里大家都是 root), 所以按会话分家:TMPDIR=$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}(已进npm run build:linux与 BUILD.md 的手敲命令)。- 提交归属可判:同时改
client/harmony/与client/electron/的提交必须在 subject 里 自报家门(跨端:)—— 那种提交是合法的("跨端判据要两侧一起改"),只是要标出来; 不标的多半是被git add -A卷进去的。判据在test/commit-hygiene.test.mjs: 基线 = 该判据文件自己的引入提交(历史不改,规则管从今往后), 配分类逻辑的合成自检(否则"解析没跑起来"时会全绿)。