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:
200
client/harmony/entry/src/main/ets/common/AppearanceStore.ets
Normal file
200
client/harmony/entry/src/main/ets/common/AppearanceStore.ets
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user