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:
@ -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();
|
||||
|
||||
65
client/harmony/entry/src/main/ets/api/AppearanceApi.ets
Normal file
65
client/harmony/entry/src/main/ets/api/AppearanceApi.ets
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user