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');
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
240
client/harmony/entry/src/main/ets/model/Appearance.ts
Normal file
240
client/harmony/entry/src/main/ets/model/Appearance.ts
Normal file
@ -0,0 +1,240 @@
|
||||
/*
|
||||
* 外观(主题 + 壁纸)在**服务端**与本地之间的搬运 —— 纯逻辑,无 UI 依赖。
|
||||
*
|
||||
* 参考实现:WebUI 的 `src/lib/appearance.ts` + `src/stores/appearanceSync.ts`。
|
||||
* 那边的由来值得记一句(用户 2026-09-13 的质问):「为什么背景是保存在本地而不是服务器!」
|
||||
* —— 主题与壁纸原先只写客户端存储:换设备就没了,而且**多账号共用一份**。
|
||||
* 现在服务端是权威(账号级 `/me/appearance`),本地只是缓存(秒开、离线降级)。
|
||||
*
|
||||
* 两条最容易写错的规则(WebUI 侧都踩过,判据盯着它们):
|
||||
*
|
||||
* ① **服务端"没有记录"时必须以本地为准**(`saved === false`)。服务端在没有记录时
|
||||
* 回的是一份**默认值**,拿它覆盖本地等于把用户已有的外观抹掉 ——
|
||||
* 首次启用这套同步时每个老用户都会中招。正确动作是把本地那份**推上去**。
|
||||
* ② 降级**必须可见**(`local-only`):服务端不可达时界面照样能用,
|
||||
* 但得能说出"现在这份只在本地",否则用户以为换设备也能带走。
|
||||
*
|
||||
* ⚠️ 本文件必须保持**类型可擦除**(无 enum / namespace / 构造器参数属性),
|
||||
* 否则 node 的 strip-types 跑不起来,判据就断了。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 服务端回包(字段名与 `server/internal/handler/appearance.go` 的 JSON 一致)。
|
||||
*
|
||||
* ⚠️ **字段默认值不是 0/空串,而是"缺字段时的合理默认"**:ArkTS 的反序列化会把
|
||||
* 缺失字段留成类里写的默认值,于是"字段不在"与"字段是 0"分不开。
|
||||
* 若这里写 `bg_dim: number = 0`,一次没带 `bg_dim` 的响应就会把压暗设成 0(无压暗),
|
||||
* 而 WebUI 那边(可选字段 = `undefined`)会退回 12。两边行为必须一致,
|
||||
* 所以默认值在这里对齐 WebUI 的 `clamp(..., dflt)` 语义。
|
||||
* 这条是**判据逼出来的**:`snapshotFromResponse(resp())` 原本返回 bgDim 0。
|
||||
*
|
||||
* `saved` 默认 `false` 也是有意为之:缺字段时按"服务端没有记录"处理,
|
||||
* 即**以本地为准**(见文件头 ①)—— 这是安全的那一侧。
|
||||
*/
|
||||
export class AppearanceResponse {
|
||||
theme: string = 'system';
|
||||
bg_kind: string = 'none';
|
||||
bg_preset_id: string = 'aurora';
|
||||
bg_dim: number = 12;
|
||||
bg_blur: number = 4;
|
||||
has_image: boolean = false;
|
||||
image_bytes: number = 0;
|
||||
/** 服务端**有没有这份记录** —— 与"值是什么"是两件事,别混(见文件头 ①) */
|
||||
saved: boolean = false;
|
||||
}
|
||||
|
||||
/** 可直接用来渲染的快照(认不出的值已退回默认) */
|
||||
export class AppearanceSnapshot {
|
||||
theme: string = 'system'; // light | dark | system
|
||||
bgKind: string = 'none'; // none | preset | image
|
||||
bgPresetId: string = 'aurora';
|
||||
bgDim: number = 12;
|
||||
bgBlur: number = 4;
|
||||
}
|
||||
|
||||
/** 同步结果:动作 + 状态(状态是要**显示给人看**的,不是内部细节) */
|
||||
export class AppearanceSync {
|
||||
snapshot: AppearanceSnapshot = new AppearanceSnapshot();
|
||||
/** 'apply-remote' | 'push-local' —— 谁覆盖谁 */
|
||||
action: string = 'apply-remote';
|
||||
/** 'synced' | 'pending' | 'local-only' */
|
||||
status: string = 'synced';
|
||||
/** 本地那份要不要上传(push-local 时为 true) */
|
||||
shouldPush: boolean = false;
|
||||
}
|
||||
|
||||
const THEMES: string[] = ['light', 'dark', 'system'];
|
||||
const KINDS: string[] = ['none', 'preset', 'image'];
|
||||
|
||||
function clampNumber(v: number, lo: number, hi: number, dflt: number): number {
|
||||
const n: number = Number.isFinite(v) ? v : dflt;
|
||||
const r: number = Math.round(n);
|
||||
if (r < lo) {
|
||||
return lo;
|
||||
}
|
||||
if (r > hi) {
|
||||
return hi;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 服务端回包 → 快照。认不出的值退回默认,不抛错(老数据/新字段/脏值都会走到这里)。 */
|
||||
export function snapshotFromResponse(resp: AppearanceResponse): AppearanceSnapshot {
|
||||
const out: AppearanceSnapshot = new AppearanceSnapshot();
|
||||
out.theme = THEMES.indexOf(resp.theme) >= 0 ? resp.theme : 'system';
|
||||
out.bgKind = KINDS.indexOf(resp.bg_kind) >= 0 ? resp.bg_kind : 'none';
|
||||
out.bgPresetId = resp.bg_preset_id.length > 0 ? resp.bg_preset_id : 'aurora';
|
||||
out.bgDim = clampNumber(resp.bg_dim, 0, 90, 12);
|
||||
out.bgBlur = clampNumber(resp.bg_blur, 0, 40, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 本地快照 → 要 PUT 上去的 JSON 形状(服务端也做同样的归一,两边都要做) */
|
||||
export function payloadFromLocal(local: AppearanceSnapshot, hasLocalImage: boolean): AppearanceResponse {
|
||||
const out: AppearanceResponse = new AppearanceResponse();
|
||||
out.theme = THEMES.indexOf(local.theme) >= 0 ? local.theme : 'system';
|
||||
let kind: string = KINDS.indexOf(local.bgKind) >= 0 ? local.bgKind : 'none';
|
||||
// 选了 image 档却没有图 → 退回 none(否则服务端会存一个指向空图的记录)
|
||||
if (kind === 'image' && !hasLocalImage) {
|
||||
kind = 'none';
|
||||
}
|
||||
out.bg_kind = kind;
|
||||
out.bg_preset_id = local.bgPresetId.length > 0 ? local.bgPresetId : 'aurora';
|
||||
out.bg_dim = clampNumber(local.bgDim, 0, 90, 12);
|
||||
out.bg_blur = clampNumber(local.bgBlur, 0, 40, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并决策:**这是这一期的验收核心**(换账号后外观跟随、服务端无记录时以本地为准)。
|
||||
*
|
||||
* @param local 本地缓存的那份(可能来自上一个账号的缓存,也可能是默认值)
|
||||
* @param resp 服务端回包
|
||||
* @param serverHasImage 服务端是否有壁纸本体(`has_image`)
|
||||
*/
|
||||
export function mergeAppearance(local: AppearanceSnapshot, resp: AppearanceResponse, serverHasImage: boolean): AppearanceSync {
|
||||
const out: AppearanceSync = new AppearanceSync();
|
||||
|
||||
// ① 服务端没有记录:**以本地为准**,并把本地推上去作为这个账号的初始外观
|
||||
if (!resp.saved) {
|
||||
out.snapshot = local;
|
||||
out.action = 'push-local';
|
||||
out.status = 'pending';
|
||||
out.shouldPush = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ② 服务端有记录:以服务端为准,但**图片档要小心**
|
||||
const remote: AppearanceSnapshot = snapshotFromResponse(resp);
|
||||
const merged: AppearanceSnapshot = new AppearanceSnapshot();
|
||||
merged.theme = remote.theme;
|
||||
merged.bgPresetId = remote.bgPresetId;
|
||||
merged.bgDim = remote.bgDim;
|
||||
merged.bgBlur = remote.bgBlur;
|
||||
|
||||
if (remote.bgKind === 'image' && !serverHasImage) {
|
||||
// 服务端记着 image 档但**本体不在**(本地还没推上去,或图被清过):
|
||||
// 不能照着 image 档渲染一块空地,也不能把本地那张图擦掉 —— 先按本地算。
|
||||
if (local.bgKind === 'image') {
|
||||
merged.bgKind = 'image';
|
||||
} else {
|
||||
merged.bgKind = 'none';
|
||||
}
|
||||
} else {
|
||||
merged.bgKind = remote.bgKind;
|
||||
}
|
||||
|
||||
out.snapshot = merged;
|
||||
out.action = 'apply-remote';
|
||||
out.status = 'synced';
|
||||
out.shouldPush = false;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 没登录 / 服务端不可达:本地就是全部,**而且要让用户知道**(状态可见,不是内部细节) */
|
||||
export function localOnly(local: AppearanceSnapshot): AppearanceSync {
|
||||
const out: AppearanceSync = new AppearanceSync();
|
||||
out.snapshot = local;
|
||||
out.action = 'apply-remote';
|
||||
out.status = 'local-only';
|
||||
out.shouldPush = false;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 壁纸模糊档 → **系统材质档次**(不是像素半径)。
|
||||
*
|
||||
* 服务端存的是 WebUI 的 `bg_blur`(0~40 的模糊像素),而鸿蒙这边"模糊"由系统材质提供
|
||||
* (`BlurStyle`)—— 这是"用系统方案"的直接结果:同一个数字在两边含义不同,
|
||||
* 所以要**显式映射**,而不是把 40 当半径塞进某个 API。映射关系写在这里,
|
||||
* 判据可以直接跑它(哪个数字落到哪一档,是行为不是注释)。
|
||||
*/
|
||||
export function blurStyleFor(bgBlur: number): string {
|
||||
const b: number = clampNumber(bgBlur, 0, 40, 4);
|
||||
if (b <= 0) {
|
||||
return 'NONE';
|
||||
}
|
||||
if (b <= 8) {
|
||||
return 'COMPONENT_THIN';
|
||||
}
|
||||
if (b <= 20) {
|
||||
return 'COMPONENT_REGULAR';
|
||||
}
|
||||
return 'COMPONENT_THICK';
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题偏好 → 系统色彩模式。
|
||||
*
|
||||
* 用系统色彩模式而不是自己切一套深色色值:这正是"用系统方案"要的效果 ——
|
||||
* 深浅两套颜色由系统给,我们只表达"偏好哪一种"。
|
||||
* 返回值与 `ConfigurationConstant.ColorMode` 的成员同名(页面那边照着映射)。
|
||||
*/
|
||||
export function colorModeFor(theme: string): string {
|
||||
if (theme === 'light') {
|
||||
return 'COLOR_MODE_LIGHT';
|
||||
}
|
||||
if (theme === 'dark') {
|
||||
return 'COLOR_MODE_DARK';
|
||||
}
|
||||
return 'COLOR_MODE_NOT_SET';
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题偏好 → `setColorMode` 要的**数字**。
|
||||
*
|
||||
* ⚠️ 数值必须与 SDK 的 `ConfigurationConstant.ColorMode` 一致,而这里的顺序**容易记反**:
|
||||
* `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`、`COLOR_MODE_NOT_SET = -1`
|
||||
* (`@ohos.app.ability.ConfigurationConstant.d.ts`)。
|
||||
* 我第一版就是按"0=浅色、1=深色"写的 —— 正好**反了**:选深色会切成浅色。
|
||||
* 之所以能发现,是因为判据把这三个数字与 SDK 里的枚举逐一比对(不是凭印象写)。
|
||||
*
|
||||
* 映射放在纯逻辑里而不是页面里:这样它是**可判据的行为**,
|
||||
* 而不是"某处有个 setColorMode 调用"。
|
||||
*/
|
||||
export function colorModeValue(theme: string): number {
|
||||
if (theme === 'light') {
|
||||
return 1; // COLOR_MODE_LIGHT
|
||||
}
|
||||
if (theme === 'dark') {
|
||||
return 0; // COLOR_MODE_DARK
|
||||
}
|
||||
return -1; // COLOR_MODE_NOT_SET(跟随系统)
|
||||
}
|
||||
|
||||
/** 遮罩浓度:0~90 的"压暗"值 → 0~1(系统遮罩色 + 这个不透明度) */
|
||||
export function scrimOpacity(bgDim: number): number {
|
||||
const d: number = clampNumber(bgDim, 0, 90, 12);
|
||||
return d / 100;
|
||||
}
|
||||
|
||||
/** 状态文案:降级必须看得见(WebUI 侧的原话:「否则用户以为换设备也能带走」) */
|
||||
export function statusLabel(status: string): string {
|
||||
if (status === 'local-only') {
|
||||
return '仅本机';
|
||||
}
|
||||
if (status === 'pending') {
|
||||
return '正在同步到账号';
|
||||
}
|
||||
return '已同步';
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user