feat(harmony): P4 —— 外观(主题 + 壁纸)跟着账号走

服务端 2026-09-13 起就是外观的权威(账号级 `/api/v1/me/appearance`),WebUI 接好了,
**鸿蒙这边此前完全没接**。这一期补上,并把"谁覆盖谁"的规则做成可判据的纯逻辑。

## 改了什么

- `api/AppearanceApi.ets`:`GET/PUT /me/appearance`、`POST /me/appearance/image`、
  `GET /me/appearance/image`(图片带认证取回本体:不用 `?token=`,也不让 Image 直连 http)。
- `api/ApiClient.ets`:新增 `getBytes()`(按 ARRAY_BUFFER 收)—— 复用 `request<T>` 会当场炸,
  因为它假定响应是 JSON(`JSON.parse`)。
- `model/Appearance.ts`(纯逻辑,判据直接执行):归一化 / PUT 报文 / **合并决策** /
  模糊值→系统材质档次 / 主题→系统色彩模式 / 遮罩浓度 / 状态文案。
- `common/AppearanceStore.ets`:落地副作用 —— 主题交给**系统**(`setColorMode`,不自己维护
  深色色值)、壁纸取回 `PixelMap`、缓存**按账号**分键(`appearance.<accountId>`)。
- 入口两处:`MainPage`(进主界面就应用 —— 只在设置页生效的话"一进主界面就变回去",
  WebUI 侧踩过)与 `SettingsPage` 新增「外观」段(三档主题 + 同步状态「已同步 / 仅本机」)。

## 为什么这么写(两条最贵的规则)

1. **服务端"没有记录"时以本地为准**(`saved === false`):服务端这时回的是一份*默认值*,
   拿它覆盖本地 = 把用户已有的主题/壁纸抹掉(WebUI 原话:每个老用户升级后第一次登录
   都会发现被重置)。正确动作是把本地那份推上去。
2. **降级必须可见**(`local-only` → 显示「仅本机」):否则用户以为换设备也能带走。

## 判据(新增 11 条,已接进 run-all;套件 10 → 11 个判据文件)

归一化(脏值/越界/小数/NaN 退回默认);`image` 档无图 → 退回 `none`;PUT 报文蛇形字段名;
★服务端无记录 → 以本地为准且**一个字段都不能被默认值顶掉**;服务端有记录 → 以服务端为准但
**不擦掉**本地那张服务端还没有的图;离线状态可见且三种状态文案互不相同;
★模糊值→系统材质档次(与 SDK 的 `BlurStyle` 成员**逐一比对**);
主题→色彩模式(数值与 SDK 的 `ConfigurationConstant.ColorMode` **逐一比对**);
★路径必须**相对基地址**(WebUI 那条"整套同步从来没生效过而单测全绿"的坑);
缓存键**带账号**;两处入口都真的应用。

变异验证(6 种,均判红):默认值覆盖本地 / 不管"image 档但服务端无图" / 材质档次自造名字 /
路径多写 `/api/v1` / 缓存键不带账号 / 深浅色彩模式数值写反。

## 判据抓到的两个真 bug

- `snapshotFromResponse` 在字段缺失时给 `bgDim = 0`,而 WebUI 语义是退回 12 ——
  ArkTS 反序列化把缺失字段留成**类里写的默认值**,"字段不在"与"字段是 0"分不开。
  已把默认值对齐 WebUI 的 `clamp(..., dflt)` 语义(并让 `saved` 默认 false = 安全的那一侧)。
- 主题落地按"0=浅色、1=深色"写的 `setColorMode` —— **正好反了**
  (SDK:`COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`),选深色会切成浅色。
  靠判据去 SDK 枚举文件读数比对发现;映射已搬进纯逻辑 `colorModeValue`,
  从"某处有个 setColorMode 调用"变成"可判据的行为"。

## 验证 / 未验

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0(11 个判据文件全绿 + vitest 258/258)。
套件自检又抓到一次"判据写好没接进套件"(新文件第一版漏了 run-all),已修。

**未做**:壁纸**上传**入口(选图 → `POST /me/appearance/image`)—— 需要 picker,API 与命名已就位。
**未验**:壁纸在真机上的渲染 —— 需真机或模拟器。
This commit is contained in:
2026-09-14 14:19:26 +08:00
parent 73f886aea5
commit 2a6ad93ec0
9 changed files with 958 additions and 0 deletions

View File

@ -604,3 +604,51 @@ SDK 里写着:`@ohos.promptAction.d.ts` 的全局 `showToast` 标 `@deprecated
**未验**:底部/内部页签在真机上的观感、悬浮加号的位置、徽标与文字的排版 ——
仍然只有真机或模拟器需人在命令行启动能看。已验证构建成功、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 侧都踩过,判据盯着):
1. **服务端"没有记录"时必须以本地为准**`saved === false`)—— 服务端这时回的是一份
*默认值*,拿它覆盖本地 = 把用户已有的主题/壁纸抹掉WebUI 原话:
"每个老用户升级后第一次登录都会发现被重置")。正确动作是把本地那份推上去。
2. **降级必须可见**`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 调用"变成"可判据的行为"。
**未验 / 未做**:壁纸在真机上的渲染效果(需要真机或模拟器);
**壁纸上传(选图 → `POST /me/appearance/image`)还没接** —— 需要文件选择器picker
这一期的 API 与命名都已就位,但入口没做,所以**不要**把它当成"已完成"。