Files
MailUI4Agents/client/harmony/entry/src/main/ets/model/Appearance.ts
JianFeeeee f811c9887a 跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据
这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。

## 一、外观保存从来就没成功过(PUT 400)

`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。

症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。

★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。

## 二、改了档位,页面背景一点不变(两层原因)

**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。

**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:

    Appearance: sync: bgKind=preset … hasImg=true    ← 数据是对的
    Wallpaper: kind=none layers=0 active=false       ← 渲染读到的还是旧值

修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。

★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。

## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)

真相有**两层,两层都值得记**:

1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
   —— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
   ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。

根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。

★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。

## 四、设备判据基建(这一轮加的能力)

- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
  `swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
  只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
  只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
  (拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
  `harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
  · `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
  · 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」

## 验证

`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。

设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
  库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。

**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
2026-09-19 15:03:40 +08:00

290 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 外观(主题 + 壁纸)在**服务端**与本地之间的搬运 —— 纯逻辑,无 UI 依赖。
*
* 参考实现:WebUI 的 `src/lib/appearance.ts` + `src/stores/appearanceSync.ts`。
* 那边的由来值得记一句(用户 2026-09-13 的质问):「为什么背景是保存在本地而不是服务器!」
* —— 主题与壁纸原先只写客户端存储:换设备就没了,而且**多账号共用一份**。
* 现在服务端是权威(账号级 `/me/appearance`),本地只是缓存(秒开、离线降级)。
*
* 两条最容易写错的规则(WebUI 侧都踩过,判据盯着它们):
*
* ① **服务端"没有记录"时必须以本地为准**(`saved === false`)。服务端在没有记录时
* 回的是一份**默认值**,拿它覆盖本地等于把用户已有的外观抹掉 ——
* 首次启用这套同步时每个老用户都会中招。正确动作是把本地那份**推上去**。
* ② 降级**必须可见**(`local-only`):服务端不可达时界面照样能用,
* 但得能说出"现在这份只在本地",否则用户以为换设备也能带走。
*
* ⚠️ 本文件必须保持**类型可擦除**(无 enum / namespace / 构造器参数属性),
* 否则 node 的 strip-types 跑不起来,判据就断了。
*/
/**
* 服务端回包(字段名与 `server/internal/handler/appearance.go` 的 JSON 一致)。
*
* ⚠️ **字段默认值不是 0/空串,而是"缺字段时的合理默认"**:ArkTS 的反序列化会把
* 缺失字段留成类里写的默认值,于是"字段不在"与"字段是 0"分不开。
* 若这里写 `bg_dim: number = 0`,一次没带 `bg_dim` 的响应就会把压暗设成 0(无压暗),
* 而 WebUI 那边(可选字段 = `undefined`)会退回 12。两边行为必须一致,
* 所以默认值在这里对齐 WebUI 的 `clamp(..., dflt)` 语义。
* 这条是**判据逼出来的**:`snapshotFromResponse(resp())` 原本返回 bgDim 0。
*
* `saved` 默认 `false` 也是有意为之:缺字段时按"服务端没有记录"处理,
* 即**以本地为准**(见文件头 ①)—— 这是安全的那一侧。
*/
export class AppearanceResponse {
theme: string = 'system';
bg_kind: string = 'none';
bg_preset_id: string = 'aurora';
bg_dim: number = 12;
bg_blur: number = 4;
has_image: boolean = false;
image_bytes: number = 0;
/** 服务端**有没有这份记录** —— 与"值是什么"是两件事,别混(见文件头 ①) */
saved: boolean = false;
}
/** 可直接用来渲染的快照(认不出的值已退回默认) */
export class AppearanceSnapshot {
theme: string = 'system'; // light | dark | system
bgKind: string = 'none'; // none | preset | image
bgPresetId: string = 'aurora';
bgDim: number = 12;
bgBlur: number = 4;
}
/** 同步结果:动作 + 状态(状态是要**显示给人看**的,不是内部细节) */
export class AppearanceSync {
snapshot: AppearanceSnapshot = new AppearanceSnapshot();
/** 'apply-remote' | 'push-local' —— 谁覆盖谁 */
action: string = 'apply-remote';
/** 'synced' | 'pending' | 'local-only' */
status: string = 'synced';
/** 本地那份要不要上传(push-local 时为 true) */
shouldPush: boolean = false;
}
const THEMES: string[] = ['light', 'dark', 'system'];
const KINDS: string[] = ['none', 'preset', 'image'];
function clampNumber(v: number, lo: number, hi: number, dflt: number): number {
const n: number = Number.isFinite(v) ? v : dflt;
const r: number = Math.round(n);
if (r < lo) {
return lo;
}
if (r > hi) {
return hi;
}
return r;
}
/** 服务端回包 → 快照。认不出的值退回默认,不抛错(老数据/新字段/脏值都会走到这里)。 */
export function snapshotFromResponse(resp: AppearanceResponse): AppearanceSnapshot {
const out: AppearanceSnapshot = new AppearanceSnapshot();
out.theme = THEMES.indexOf(resp.theme) >= 0 ? resp.theme : 'system';
out.bgKind = KINDS.indexOf(resp.bg_kind) >= 0 ? resp.bg_kind : 'none';
out.bgPresetId = resp.bg_preset_id.length > 0 ? resp.bg_preset_id : 'aurora';
out.bgDim = clampNumber(resp.bg_dim, 0, 90, 12);
out.bgBlur = clampNumber(resp.bg_blur, 0, 40, 4);
return out;
}
/**
* **PUT 上去**的 JSON 形状 —— 注意它与 `AppearanceResponse`(GET 回来的)**不是**同一个类型。
*
* ★★ 2026-09-19 修(真 bug,设备实测撞出来的):原先这里复用 `AppearanceResponse`,
* 而那个类型带 `has_image` / `image_bytes` / `saved` —— **服务端不认识这三个字段**,
* 且服务端的 `Decode()` 是 `DisallowUnknownFields()`(严格,有意为之)。
* 于是每一次外观保存都 400,症状是"点了预设档、色板出来了、但壁纸没画上去":
* 界面看着切了(本地 @State 变了),服务端其实拒收(`PUT /me/appearance` → 400)。
*
* 为什么能潜伏这么久:**本地状态立刻变**,所以肉眼看着像成功了;
* 只有看 hilog 的 HTTP 状态码才发现 400。而所有静态判据判的都是
* "payload 的字段对不对" —— 它们读的是同一份类型定义,**看不见'多带了字段'**。
* 这正是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
*
* 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。
*/
export class AppearancePayload {
theme: string = 'system';
bg_kind: string = 'none';
bg_preset_id: string = 'aurora';
bg_dim: number = 12;
bg_blur: number = 4;
}
/** 本地快照 → 要 PUT 上去的 JSON 形状(服务端也做同样的归一,两边都要做) */
export function payloadFromLocal(local: AppearanceSnapshot, hasLocalImage: boolean): AppearancePayload {
const out: AppearancePayload = new AppearancePayload();
out.theme = THEMES.indexOf(local.theme) >= 0 ? local.theme : 'system';
let kind: string = KINDS.indexOf(local.bgKind) >= 0 ? local.bgKind : 'none';
// 选了 image 档却没有图 → 退回 none(否则服务端会存一个指向空图的记录)
if (kind === 'image' && !hasLocalImage) {
kind = 'none';
}
out.bg_kind = kind;
out.bg_preset_id = local.bgPresetId.length > 0 ? local.bgPresetId : 'aurora';
out.bg_dim = clampNumber(local.bgDim, 0, 90, 12);
out.bg_blur = clampNumber(local.bgBlur, 0, 40, 4);
return out;
}
/**
* 合并决策:**这是这一期的验收核心**(换账号后外观跟随、服务端无记录时以本地为准)。
*
* @param local 本地缓存的那份(可能来自上一个账号的缓存,也可能是默认值)
* @param resp 服务端回包
* @param serverHasImage 服务端是否有壁纸本体(`has_image`)
*/
export function mergeAppearance(local: AppearanceSnapshot, resp: AppearanceResponse, serverHasImage: boolean): AppearanceSync {
const out: AppearanceSync = new AppearanceSync();
// ① 服务端没有记录:**以本地为准**,并把本地推上去作为这个账号的初始外观
if (!resp.saved) {
out.snapshot = local;
out.action = 'push-local';
out.status = 'pending';
out.shouldPush = true;
return out;
}
// ② 服务端有记录:以服务端为准,但**图片档要小心**
const remote: AppearanceSnapshot = snapshotFromResponse(resp);
const merged: AppearanceSnapshot = new AppearanceSnapshot();
merged.theme = remote.theme;
merged.bgPresetId = remote.bgPresetId;
merged.bgDim = remote.bgDim;
merged.bgBlur = remote.bgBlur;
if (remote.bgKind === 'image' && !serverHasImage) {
// 服务端记着 image 档但**本体不在**(本地还没推上去,或图被清过):
// 不能照着 image 档渲染一块空地,也不能把本地那张图擦掉 —— 先按本地算。
if (local.bgKind === 'image') {
merged.bgKind = 'image';
} else {
merged.bgKind = 'none';
}
} else {
merged.bgKind = remote.bgKind;
}
out.snapshot = merged;
out.action = 'apply-remote';
out.status = 'synced';
out.shouldPush = false;
return out;
}
/** 没登录 / 服务端不可达:本地就是全部,**而且要让用户知道**(状态可见,不是内部细节) */
export function localOnly(local: AppearanceSnapshot): AppearanceSync {
const out: AppearanceSync = new AppearanceSync();
out.snapshot = local;
out.action = 'apply-remote';
out.status = 'local-only';
out.shouldPush = false;
return out;
}
/**
* 壁纸模糊档 → **系统材质档次名**(不是像素半径)。
*
* ── ★ 已删除(2026-09-15,pi 的裁定)──
*
* 它曾是"`bg_blur` → `BlurStyle` 档位"的唯一映射函数。删除的理由是**消费者不存在,而且不该存在**:
* · 页面上**没有任何调用点** —— 它只被自己的判据调用;
* · 唯一的计划消费者是"导航条档位跟随 `bg_blur`",而 pi 2026-09-15 **否决了那个方案**
* (导航条是 chrome,材质固定;见 `MainPage.ets` 的 `NavBar` 注释)。
* 所以它不是"暂时还没有消费者",是**不该有**。
* · 这正是本仓刚立的规矩:**"有测试"不等于"有人用"**。一个只靠自己的判据活着的函数,
* 与 `Theme.navMaterial` 那个"只被不可达 `??` 分支引用"的死令牌是**同一个形状**,
* 只是换了个层级。当时我在 `harmony-appearance.test.mjs` 里诚实地标了它是"孤岛",
* 但**"孤岛"写在注释里等于没有机制** —— 注释拦不住下一个读者。
*
* **想法若真回来**:`git log --all -- <本文件>` 里有实现,而 (a) 那次提交(`426caf9`)
* 本身就是"它为什么不该回来"的记录。**不要为了"万一有用"把它留在代码里。**
*
* 注意与它**不是**一回事的:壁纸层的 `.blur(px)` 用的是**像素半径**
* (服务端 `bg_blur` 原值,0~40,与 WebUI 同一个数)—— 那个是**活的**,没有删。
* "px 半径"与"系统材质档"是两个量,别再合到一起。
*/
/**
* 主题偏好 → 系统色彩模式。
*
* 用系统色彩模式而不是自己切一套深色色值:这正是"用系统方案"要的效果 ——
* 深浅两套颜色由系统给,我们只表达"偏好哪一种"。
* 返回值与 `ConfigurationConstant.ColorMode` 的成员同名(页面那边照着映射)。
*/
export function colorModeFor(theme: string): string {
if (theme === 'light') {
return 'COLOR_MODE_LIGHT';
}
if (theme === 'dark') {
return 'COLOR_MODE_DARK';
}
return 'COLOR_MODE_NOT_SET';
}
/**
* 主题偏好 → `setColorMode` 要的**数字**。
*
* ⚠️ 数值必须与 SDK 的 `ConfigurationConstant.ColorMode` 一致,而这里的顺序**容易记反**:
* `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`、`COLOR_MODE_NOT_SET = -1`
* (`@ohos.app.ability.ConfigurationConstant.d.ts`)。
* 我第一版就是按"0=浅色、1=深色"写的 —— 正好**反了**:选深色会切成浅色。
* 之所以能发现,是因为判据把这三个数字与 SDK 里的枚举逐一比对(不是凭印象写)。
*
* 映射放在纯逻辑里而不是页面里:这样它是**可判据的行为**,
* 而不是"某处有个 setColorMode 调用"。
*/
export function colorModeValue(theme: string): number {
if (theme === 'light') {
return 1; // COLOR_MODE_LIGHT
}
if (theme === 'dark') {
return 0; // COLOR_MODE_DARK
}
return -1; // COLOR_MODE_NOT_SET(跟随系统)
}
/**
* 当前主题**实际**算不算深色 —— 预设色板要不要用深色那套由它决定。
*
* 为什么不直接看 `theme` 就完事:`theme === 'system'` 时我们并不知道系统当时是深是浅,
* 要把系统反馈进来的 `colorMode` 一起看。数值同样与 SDK 的
* `ConfigurationConstant.ColorMode` 对齐(0=深、1=浅、-1=未设置)。
*
* **未设置/读不到时按浅色算**:与 WebUI 的默认(`:root` 那套)一致 ——
* 宁可"系统深浅还没定下来时先用浅色那套",也不要拿深色色板去配浅色系统表面。
*
* ⚠️ 已知边界:用户在**应用运行期间**改系统深浅色,这里不会自动重算
* (要重新进一次页面/重进应用)。系统侧的正确做法是订阅
* `applicationContext.on('environment', ...)` 的配置变化回调 —— 记在文档里,没做。
*/
export function isDarkMode(theme: string, systemColorMode: number): boolean {
if (theme === 'dark') {
return true;
}
if (theme === 'light') {
return false;
}
return systemColorMode === 0; // COLOR_MODE_DARK
}
/** 遮罩浓度:0~90 的"压暗"值 → 0~1(系统遮罩色 + 这个不透明度) */
export function scrimOpacity(bgDim: number): number {
const d: number = clampNumber(bgDim, 0, 90, 12);
return d / 100;
}
/** 状态文案:降级必须看得见(WebUI 侧的原话:「否则用户以为换设备也能带走」) */
export function statusLabel(status: string): string {
if (status === 'local-only') {
return '仅本机';
}
if (status === 'pending') {
return '正在同步到账号';
}
return '已同步';
}