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

@ -10,6 +10,7 @@ import { Theme } from '../common/Theme';
import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models';
import {
MailLike,
@ -1034,6 +1035,28 @@ struct CommPage {
aboutToAppear(): void {
this.refreshCounts();
this.applyAppearance();
}
/**
* 外观(主题 + 壁纸)跟着**账号**走:这里进入主界面时先应用一次。
*
* 为什么主界面也要做一次、而不只在设置页里做:用户改了主题后如果只有设置页生效,
* 一进主界面就"变回去了"(WebUI 侧踩过:服务端存了外观、界面却毫无变化)。
* 没登录/离线时 store 会退回本地缓存并标 `local-only`(降级可见)。
*/
async applyAppearance(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const store: AppearanceStore = AppearanceStore.getInstance();
// 缓存**按账号**读:多账号共用一份是 WebUI 侧的原始缺陷
store.loadLocal(ctx, acctMgr.getActiveId());
const client: ApiClient = new ApiClient(ctx);
await store.syncFromServer(ctx, client);
}
/** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */

View File

@ -7,6 +7,9 @@ import { Theme } from '../common/Theme';
import { AuthApi } from '../api/AuthApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
import { AppearanceApi } from '../api/AppearanceApi';
import { AppearanceSnapshot, statusLabel } from '../model/Appearance';
@Entry
@Component
@ -19,6 +22,12 @@ struct SettingsPage {
@State newUsername: string = '';
@State newToken: string = '';
@State adding: boolean = false;
/*
* 外观(主题 + 壁纸):服务端是权威、本机只是缓存。
* 页面只保存**显示用的一份副本** —— ArkTS 的 @State 观察不到类内部字段的变化。
*/
@State appearanceTheme: string = 'system';
@State appearanceStatus: string = 'local-only';
private client: ApiClient | null = null;
private acctMgr: AccountManager | null = null;
@ -30,10 +39,58 @@ struct SettingsPage {
this.acctMgr = AccountManager.getInstance(ctx);
this.acctMgr.load().then(() => {
this.refreshList();
this.loadAppearance();
});
}
}
/**
* 拉一次外观:按合并规则落地(规则在 `model/Appearance.ts`,判据跑那一份)。
*
* 这里只把结果复制进 @State 供渲染 —— 主题的**应用**(系统色彩模式)在 store 里做,
* 因为那不是页面的事:换页时主题也该保持。
*/
async loadAppearance(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
const client: ApiClient | null = this.client;
if (ctx === undefined || client === null) {
return;
}
const store: AppearanceStore = AppearanceStore.getInstance();
// 缓存按账号分:换账号时读的是那一个账号的那一份
store.loadLocal(ctx, this.activeId);
await store.syncFromServer(ctx, client);
const snap: AppearanceSnapshot = store.current();
this.appearanceTheme = snap.theme;
this.appearanceStatus = store.status;
}
/** 换主题:写服务端 + 立刻应用(写失败也要让本机先跟上,并如实标"仅本机") */
async setTheme(theme: string): Promise<void> {
const ctx = this.getUIContext().getHostContext();
const client: ApiClient | null = this.client;
if (ctx === undefined) {
return;
}
const store: AppearanceStore = AppearanceStore.getInstance();
const snap: AppearanceSnapshot = store.current();
snap.theme = theme;
this.appearanceTheme = theme;
store.applyTheme(ctx, theme);
if (client === null) {
this.appearanceStatus = 'local-only';
return;
}
try {
await new AppearanceApi(client).put(snap, store.wallpaper !== null);
store.saveLocal(ctx, snap);
this.appearanceStatus = 'synced';
} catch (e) {
// 服务端没写成:本机已经生效,但状态必须说实话(否则用户以为换设备也带着走)
this.appearanceStatus = 'local-only';
}
}
refreshList(): void {
const manager: AccountManager | null = this.acctMgr;
if (manager === null) {
@ -171,6 +228,47 @@ struct SettingsPage {
.divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 })
}
/*
* ── 外观(主题 / 壁纸)──
*
* 主题只表达"偏好哪一种",深浅两套颜色**由系统给**(`setColorMode`):
* 这就是「用系统方案」在这里的意思 —— 不自己维护一套深色色值。
*
* 状态(已同步 / 仅本机)必须显示:WebUI 侧的教训是"降级不可见",
* 用户以为换设备也能带走,打开另一台才发现没有。
*/
Column() {
Row() {
Text('外观').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank()
Text(statusLabel(this.appearanceStatus))
.fontSize(11)
.fontColor(this.appearanceStatus === 'synced' ? Theme.textMuted : Theme.warnFg)
}
.width('100%')
Row() {
ForEach(['system', 'light', 'dark'], (t: string) => {
Text(t === 'system' ? '跟随系统' : (t === 'light' ? '浅色' : '深色'))
.fontSize(13)
.fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary)
.backgroundColor(this.appearanceTheme === t ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => { this.setTheme(t); })
}, (t: string) => t)
}
.width('100%').margin({ top: 10 })
Text('主题由系统按色彩模式给色(深浅两套不靠手写色值);换账号时外观跟着账号走。')
.fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
if (this.showAddDialog) {
Column() {
Column()