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,200 @@
/*
* 外观在本机的一份状态 + 应用动作(主题 → 系统色彩模式;壁纸 → 取回本体再渲染)。
*
* 分工:
* 服务端 = 权威(账号级,`/me/appearance`);
* 本机 = 缓存(秒开、离线降级);
* `model/Appearance.ts` = 合并规则(纯逻辑、判据直接跑它);
* 本文件 = 把结果**落到系统上**:色彩模式交给系统,壁纸取回本体后交给 `Image`。
*
* 「用系统方案」在这里的具体意思:**不自己维护一套深色色值**。
* 主题只表达"偏好哪一种",深浅两套颜色由系统按色彩模式给 ——
* 所以这里唯一的动作是 `setColorMode`,而不是换一套 Theme 常量。
*/
import { common } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { preferences } from '@kit.ArkData';
import { ApiClient } from '../api/ApiClient';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { AppearanceApi, AppearanceApiResponse } from '../api/AppearanceApi';
import {
AppearanceSnapshot,
AppearanceResponse,
AppearanceSync,
mergeAppearance,
snapshotFromResponse,
localOnly,
colorModeValue,
statusLabel
} from '../model/Appearance';
const PREF_STORE: string = 'agentmail_appearance';
/** 本地缓存的键**必须带账号**:WebUI 侧的教训是多账号共用一份(键是全局常量) */
const KEY_PREFIX: string = 'appearance.';
export class AppearanceStore {
private static instance: AppearanceStore | null = null;
/** 当前快照(界面照它渲染) */
snapshot: AppearanceSnapshot = new AppearanceSnapshot();
/** 'synced' | 'pending' | 'local-only' */
status: string = 'local-only';
/** 壁纸本体(服务端有图且取回成功时才有) */
wallpaper: image.PixelMap | null = null;
private accountId: string = '';
static getInstance(): AppearanceStore {
if (AppearanceStore.instance === null) {
AppearanceStore.instance = new AppearanceStore();
}
return AppearanceStore.instance;
}
statusText(): string {
return statusLabel(this.status);
}
private prefKey(accountId: string): string {
return KEY_PREFIX + accountId;
}
/**
* 读本机缓存(**按账号**:键是 `appearance.<accountId>`)。
*
* 键必须带账号 —— WebUI 侧的教训是"多账号共用一份"(存储键是全局常量),
* 同一台机器换账号时背景不跟着走。
*
* 读不到就是默认值,并如实标 `local-only`(服务端那份才是权威,随后会拉回来)。
*/
loadLocal(ctx: common.Context, accountId: string): AppearanceSnapshot {
this.accountId = accountId;
const snap: AppearanceSnapshot = new AppearanceSnapshot();
try {
const store = preferences.getPreferencesSync(ctx, { name: PREF_STORE });
const raw = store.getSync(this.prefKey(accountId), '') as string;
if (raw.length > 0) {
const parsed = JSON.parse(raw) as AppearanceSnapshot;
const loaded: AppearanceSnapshot = snapshotFromResponse(AppearanceResponseOf(parsed));
this.snapshot = loaded;
this.status = 'local-only';
return loaded;
}
} catch (e) {
// 读不出来就当没有缓存
}
this.snapshot = snap;
this.status = 'local-only';
return snap;
}
/** 写本机缓存(推服务端成功后调用:缓存的是"已经上去了的那份") */
saveLocal(ctx: common.Context, snap: AppearanceSnapshot): void {
try {
const store = preferences.getPreferencesSync(ctx, { name: PREF_STORE });
store.putSync(this.prefKey(this.accountId), JSON.stringify(snap));
store.flush();
} catch (e) {
// 缓存写不进去不影响本次使用(下次冷启动会重新从服务端拉)
}
}
/**
* 应用主题:**交给系统**(色彩模式),不自己切一套深色色值。
*
* `COLOR_MODE_NOT_SET` = 跟随系统 —— 这是默认档,也是"用系统方案"的默认行为。
*/
applyTheme(ctx: common.Context, theme: string): void {
try {
/*
* 数值由 `colorModeValue` 给(纯逻辑、判据比对 SDK 枚举)。
* 别在这里自己写 0/1 —— 我第一版就是自己写的,而且**写反了**
* (SDK 里 `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`:选深色会切成浅色)。
*/
const app = ctx.getApplicationContext();
app.setColorMode(colorModeValue(theme));
} catch (e) {
// 改不了色彩模式不该让页面挂掉(旧系统/权限):界面仍按当前模式渲染
}
}
/**
* 拉服务端外观并按合并规则落地。
*
* 合并规则本身在 `model/Appearance.ts`(判据跑那一份);这里只做 IO 与副作用:
* ① 服务端没记录 → **以本地为准**并推上去(不能拿默认值覆盖本地);
* ② 服务端有记录 → 以服务端为准;图片档而服务端没图时,不擦掉本地那张;
* ③ 拉不到(离线/未登录) → 标 `local-only`,**让人看得见**。
*/
async syncFromServer(ctx: common.Context, client: ApiClient): Promise<void> {
const api: AppearanceApi = new AppearanceApi(client);
let resp: AppearanceApiResponse | null = null;
try {
resp = await api.get();
} catch (e) {
// 服务端不可达:本地就是全部,且状态要可见
const only: AppearanceSync = localOnly(this.snapshot);
this.snapshot = only.snapshot;
this.status = only.status;
return;
}
const asResponse: AppearanceResponse = new AppearanceResponse();
asResponse.theme = resp.theme;
asResponse.bg_kind = resp.bg_kind;
asResponse.bg_preset_id = resp.bg_preset_id;
asResponse.bg_dim = resp.bg_dim;
asResponse.bg_blur = resp.bg_blur;
asResponse.has_image = resp.has_image;
asResponse.image_bytes = resp.image_bytes;
asResponse.saved = resp.saved;
const merged: AppearanceSync = mergeAppearance(this.snapshot, asResponse, resp.has_image);
this.snapshot = merged.snapshot;
this.status = merged.status;
if (merged.shouldPush) {
// 服务端还没有这份记录:把本地这份**推上去**作为账号的初始外观
try {
await api.put(merged.snapshot, this.wallpaper !== null);
this.status = 'synced';
this.saveLocal(ctx, merged.snapshot);
} catch (e) {
this.status = 'local-only';
}
}
// 壁纸本体:只在服务端说"有图"时才取(服务端没图而本地有 = 还没推上去)
if (resp.has_image) {
await this.loadWallpaper(api);
}
this.applyTheme(ctx, this.snapshot.theme);
}
/** 取壁纸本体:**带认证**取回来(不用 `Image('http://…')`,也不用 `?token=`) */
async loadWallpaper(api: AppearanceApi): Promise<void> {
try {
const bytes: ArrayBuffer = await api.fetchImageBytes();
const src: image.ImageSource = image.createImageSource(bytes);
this.wallpaper = await src.createPixelMap();
} catch (e) {
// 取不到就按"没有壁纸"渲染:不显示一块空的占位
this.wallpaper = null;
}
}
/** 供界面读的当前值(ArkTS 的 @State 观察不到类内部变化,所以页面自己复制一份) */
current(): AppearanceSnapshot {
return this.snapshot;
}
}
/** 本地快照 → 服务端回包形状:只为复用 `snapshotFromResponse` 的归一(脏值退回默认) */
export function AppearanceResponseOf(snap: AppearanceSnapshot): AppearanceResponse {
const r: AppearanceResponse = new AppearanceResponse();
r.theme = snap.theme;
r.bg_kind = snap.bgKind;
r.bg_preset_id = snap.bgPresetId;
r.bg_dim = snap.bgDim;
r.bg_blur = snap.bgBlur;
r.saved = true;
return r;
}