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

@ -0,0 +1,240 @@
/*
* 外观(主题 + 壁纸)在**服务端**与本地之间的搬运 —— 纯逻辑,无 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 形状(服务端也做同样的归一,两边都要做) */
export function payloadFromLocal(local: AppearanceSnapshot, hasLocalImage: boolean): AppearanceResponse {
const out: AppearanceResponse = new AppearanceResponse();
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;
}
/**
* 壁纸模糊档 → **系统材质档次**(不是像素半径)。
*
* 服务端存的是 WebUI 的 `bg_blur`(0~40 的模糊像素),而鸿蒙这边"模糊"由系统材质提供
* (`BlurStyle`)—— 这是"用系统方案"的直接结果:同一个数字在两边含义不同,
* 所以要**显式映射**,而不是把 40 当半径塞进某个 API。映射关系写在这里,
* 判据可以直接跑它(哪个数字落到哪一档,是行为不是注释)。
*/
export function blurStyleFor(bgBlur: number): string {
const b: number = clampNumber(bgBlur, 0, 40, 4);
if (b <= 0) {
return 'NONE';
}
if (b <= 8) {
return 'COMPONENT_THIN';
}
if (b <= 20) {
return 'COMPONENT_REGULAR';
}
return 'COMPONENT_THICK';
}
/**
* 主题偏好 → 系统色彩模式。
*
* 用系统色彩模式而不是自己切一套深色色值:这正是"用系统方案"要的效果 ——
* 深浅两套颜色由系统给,我们只表达"偏好哪一种"。
* 返回值与 `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(跟随系统)
}
/** 遮罩浓度: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 '已同步';
}