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

@ -179,6 +179,41 @@ export class ApiClient {
// 不复用销毁:会话级实例保留 Cookie
}
/**
* 取**二进制**(壁纸本体)。
*
* 单独一个方法而不是复用 `request<T>`:`request` 假定响应是 JSON
* (`JSON.parse(response.result as string)`),拿它取图会当场炸。
*
* 壁纸**带认证取回来**(Bearer 或 cookie),不使用 `?token=` ——
* 那会把密钥写进服务端日志与访问历史(服务端注释里明确不做这件事)。
*/
async getBytes(path: string): Promise<ArrayBuffer> {
const url: string = this.apiBase + path;
let httpRequest: http.HttpRequest;
if (this.httpRequest === null) {
httpRequest = http.createHttp();
this.httpRequest = httpRequest;
} else {
httpRequest = this.httpRequest;
}
const header: Record<string, string> = {};
if (this.token.length > 0) {
header['Authorization'] = 'Bearer ' + this.token;
}
const response = await httpRequest.request(url, {
method: http.RequestMethod.GET,
header: header,
expectDataType: http.HttpDataType.ARRAY_BUFFER,
connectTimeout: 15000,
readTimeout: 30000
});
if (response.responseCode < 200 || response.responseCode >= 300) {
throw new ApiError(response.responseCode, 'HTTP ' + response.responseCode);
}
return response.result as ArrayBuffer;
}
/** GET 便捷 */
async get<T>(path: string, query?: string): Promise<T> {
const opts = new RequestOptions();

View File

@ -0,0 +1,65 @@
/*
* 外观(主题 + 壁纸):`/me/appearance` 系列端点。
*
* 服务端是权威(账号级):换设备跟着走、多账号各自一份。
* 合并规则(谁覆盖谁)**不在这里** —— 在 `model/Appearance.ts` 的纯逻辑里,
* 判据直接跑那一份;这里只负责搬字节。
*/
import { ApiClient } from './ApiClient';
import { AppearanceResponse, AppearanceSnapshot, payloadFromLocal } from '../model/Appearance';
/** `GET /me/appearance` 的响应(形状与 handler 的 JSON 一致) */
export class AppearanceApiResponse {
theme: string = '';
bg_kind: string = '';
bg_preset_id: string = '';
bg_dim: number = 0;
bg_blur: number = 0;
has_image: boolean = false;
image_bytes: number = 0;
/** 服务端有没有这份记录(没有记录时上面的值只是默认值,不能拿来覆盖本地) */
saved: boolean = false;
updated_at: string = '';
image_url: string = '';
}
export class AppearanceApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/**
* 读外观。
*
* ⚠️ 路径是**相对基地址**的(base 已含 `/api/v1`):WebUI 那边第一版写成
* `/api/v1/me/appearance`,实际请求成了 `/api/v1/api/v1/...`,
* 整套同步"从来没生效过"而单测全绿(只断言了方法与报文、没断言 URL)。
* 所以这里的路径有判据钉着。
*/
async get(): Promise<AppearanceApiResponse> {
return this.client.get<AppearanceApiResponse>('/me/appearance');
}
/** 写外观档(主题 + 背景档与参数;图片走 `uploadImage`) */
async put(snapshot: AppearanceSnapshot, hasLocalImage: boolean): Promise<AppearanceResponse> {
const payload: AppearanceResponse = payloadFromLocal(snapshot, hasLocalImage);
return this.client.put<AppearanceResponse>('/me/appearance', payload);
}
/** 上传壁纸(multipart,字段名 file)→ 服务端存 blob,库里只留 sha256 */
async uploadImage(filePath: string, fileName: string): Promise<string> {
return this.client.uploadFile('/me/appearance/image', filePath, fileName);
}
/**
* 取壁纸**本体**。
*
* 必须带认证取回来再交给渲染层:`Image('http://…')` 发不出认证头,
* 而 `?token=` 会把密钥写进日志(服务端明确不接受)。
*/
async fetchImageBytes(): Promise<ArrayBuffer> {
return this.client.getBytes('/me/appearance/image');
}
}