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:
247
client/electron/test/harmony-appearance.test.mjs
Normal file
247
client/electron/test/harmony-appearance.test.mjs
Normal file
@ -0,0 +1,247 @@
|
||||
/**
|
||||
* P4 判据:外观(主题 + 壁纸)在服务端与本地之间的搬运。
|
||||
*
|
||||
* 被测对象是鸿蒙客户端**真正引用的那份逻辑**(`model/Appearance.ts`,纯逻辑无 UI 依赖),
|
||||
* 用 node 的 `--experimental-strip-types` 直接执行 —— 断言的是行为,不是源码字符串;
|
||||
* 只有"接线"那几条读源码(因为"逻辑写好了没人用"正是要防的)。
|
||||
*
|
||||
* 这一期为什么值得单独一组判据:WebUI 侧这套同步**曾经整整一段时间没生效过**
|
||||
* 而单测全绿 —— 因为测试只断言了方法与报文,没断言 URL(路径多写了一层 `/api/v1`)。
|
||||
* 所以这里有一条判据专门钉路径,而且钉的是**相对基地址**的形状。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..', '..');
|
||||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
const MODULE_TS = join(HARMONY_ETS, 'model/Appearance.ts');
|
||||
|
||||
const A = await import(pathToFileURL(MODULE_TS).href);
|
||||
|
||||
/** 剥注释读源码:注释里出现某个调用恰恰说明不了那个调用存在 */
|
||||
const code = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const read = (rel) => code(readFileSync(join(HARMONY_ETS, rel), 'utf8'));
|
||||
|
||||
const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over);
|
||||
const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over);
|
||||
|
||||
// ───────────────────── 归一层 ─────────────────────
|
||||
|
||||
test('服务端回包 → 快照:认不出的值退回默认,不做半信半疑的处理', () => {
|
||||
// 全空(服务端新字段/老数据):每一项都要有安全的默认
|
||||
const empty = A.snapshotFromResponse(resp());
|
||||
assert.equal(empty.theme, 'system', '认不出的主题要跟随系统,而不是硬选一个');
|
||||
assert.equal(empty.bgKind, 'none');
|
||||
assert.equal(empty.bgPresetId, 'aurora');
|
||||
assert.equal(empty.bgDim, 12);
|
||||
assert.equal(empty.bgBlur, 4);
|
||||
|
||||
// 脏值(拼错的枚举、越界数字、小数、NaN)不该被照单全收
|
||||
const dirty = A.snapshotFromResponse(resp({ theme: 'drak', bg_kind: 'IMAGE', bg_dim: 999, bg_blur: -5 }));
|
||||
assert.equal(dirty.theme, 'system');
|
||||
assert.equal(dirty.bgKind, 'none', '枚举大小写不同也是认不出(不做模糊匹配)');
|
||||
assert.equal(dirty.bgDim, 90, '压暗值上限 90');
|
||||
assert.equal(dirty.bgBlur, 0, '模糊值下限 0');
|
||||
const frac = A.snapshotFromResponse(resp({ bg_dim: 12.6, bg_blur: 4.4 }));
|
||||
assert.equal(frac.bgDim, 13, '小数要取整(渲染值不能是半个像素)');
|
||||
assert.equal(frac.bgBlur, 4);
|
||||
assert.equal(A.snapshotFromResponse(resp({ bg_dim: NaN })).bgDim, 12, 'NaN 退回默认');
|
||||
});
|
||||
|
||||
test('本地快照 → PUT 报文:选了图片档却没有图,要退回 none', () => {
|
||||
const noImage = A.payloadFromLocal(snap({ bgKind: 'image' }), false);
|
||||
assert.equal(noImage.bg_kind, 'none', '否则服务端会存一个指向空图的记录');
|
||||
const withImage = A.payloadFromLocal(snap({ bgKind: 'image' }), true);
|
||||
assert.equal(withImage.bg_kind, 'image');
|
||||
// 字段名要与服务端 JSON 一致(蛇形);混进驼峰名服务端只会静默用默认值
|
||||
const payload = A.payloadFromLocal(snap(), false);
|
||||
for (const k of ['theme', 'bg_kind', 'bg_preset_id', 'bg_dim', 'bg_blur']) {
|
||||
assert.ok(k in payload, `PUT 报文要有 ${k}`);
|
||||
}
|
||||
for (const bad of ['bgKind', 'bgDim', 'bgBlur', 'bgPresetId']) {
|
||||
assert.ok(!(bad in payload), `PUT 报文不该出现驼峰名 ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────── 合并决策(这一期的验收核心) ─────────────────────
|
||||
|
||||
test('★ 服务端没有记录时:以**本地**为准并推上去,绝不拿默认值覆盖本地', () => {
|
||||
/*
|
||||
* 这是两条最贵的规则之一。服务端在没有记录时回的是一份**默认值**,
|
||||
* 拿它覆盖本地等于把用户已有的外观(尤其是本地缓存的壁纸)抹掉 ——
|
||||
* WebUI 侧漏了这条时,"每个老用户升级后第一次登录都会发现主题被重置"。
|
||||
*/
|
||||
const local = snap({ theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 });
|
||||
const m = A.mergeAppearance(local, resp({ saved: false }), false);
|
||||
|
||||
assert.equal(m.action, 'push-local', '谁覆盖谁:本地覆盖服务端');
|
||||
assert.equal(m.shouldPush, true, '要把本地这份推上去作为账号的初始外观');
|
||||
assert.equal(m.status, 'pending', '状态要能看出"正在同步到账号"');
|
||||
assert.deepEqual(
|
||||
{ theme: m.snapshot.theme, bgKind: m.snapshot.bgKind, bgPresetId: m.snapshot.bgPresetId, bgDim: m.snapshot.bgDim, bgBlur: m.snapshot.bgBlur },
|
||||
{ theme: 'dark', bgKind: 'image', bgPresetId: 'ocean', bgDim: 40, bgBlur: 30 },
|
||||
'本地那份必须原样保留(一个字段都不能被默认值顶掉)'
|
||||
);
|
||||
// 反例:这是变异测试要打的那一枪 —— 拿默认值覆盖会立刻丢主题与壁纸
|
||||
assert.notEqual(m.snapshot.theme, 'system');
|
||||
});
|
||||
|
||||
test('★ 服务端有记录时:以服务端为准,但**不擦掉**本地那张服务端还没有的图', () => {
|
||||
// 正常情形:服务端说了算
|
||||
const applied = A.mergeAppearance(snap({ theme: 'light', bgKind: 'preset', bgPresetId: 'x', bgDim: 5, bgBlur: 5 }),
|
||||
resp({ saved: true, theme: 'dark', bg_kind: 'preset', bg_preset_id: 'aurora', bg_dim: 30, bg_blur: 20 }), false);
|
||||
assert.equal(applied.action, 'apply-remote');
|
||||
assert.equal(applied.shouldPush, false);
|
||||
assert.equal(applied.status, 'synced');
|
||||
assert.equal(applied.snapshot.theme, 'dark', '服务端说了算');
|
||||
assert.equal(applied.snapshot.bgPresetId, 'aurora');
|
||||
assert.equal(applied.snapshot.bgDim, 30);
|
||||
|
||||
// 服务端记着 image 档、但**本体不在**(本地还没推上去 / 图被清过):
|
||||
// 不能照着 image 档渲染一块空地,也不能把本地那张擦掉
|
||||
const keepLocal = A.mergeAppearance(snap({ bgKind: 'image' }), resp({ saved: true, bg_kind: 'image' }), false);
|
||||
assert.equal(keepLocal.snapshot.bgKind, 'image', '本地有图 → 先按本地算');
|
||||
const noLocal = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), false);
|
||||
assert.equal(noLocal.snapshot.bgKind, 'none', '本地也没图 → 不能渲染一块空地');
|
||||
|
||||
// 服务端真有图:照服务端
|
||||
const remoteImage = A.mergeAppearance(snap({ bgKind: 'none' }), resp({ saved: true, bg_kind: 'image' }), true);
|
||||
assert.equal(remoteImage.snapshot.bgKind, 'image');
|
||||
});
|
||||
|
||||
test('离线/未登录:本地就是全部,而且**状态要看得见**(降级不可见 = 用户以为能带走)', () => {
|
||||
const only = A.localOnly(snap({ theme: 'dark' }));
|
||||
assert.equal(only.status, 'local-only');
|
||||
assert.equal(only.snapshot.theme, 'dark');
|
||||
assert.equal(only.shouldPush, false, '离线时不该尝试推');
|
||||
// 三个状态文案要分得开
|
||||
const labels = ['synced', 'pending', 'local-only'].map(A.statusLabel);
|
||||
assert.equal(new Set(labels).size, 3, '三种状态要有不同文案');
|
||||
assert.match(A.statusLabel('local-only'), /仅本机/);
|
||||
assert.match(A.statusLabel('pending'), /同步/);
|
||||
});
|
||||
|
||||
// ───────────────────── 系统方案:数字 → 系统材质 / 色彩模式 ─────────────────────
|
||||
|
||||
test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞给某个 API)', () => {
|
||||
/*
|
||||
* 服务端存的是 WebUI 的 `bg_blur`(模糊像素半径,0~40),鸿蒙这边"模糊"由系统材质提供
|
||||
* (`BlurStyle`)。同一个数字两边含义不同,必须显式映射 —— 这条判据钉住映射关系,
|
||||
* 顺带钉住"没有 0~40 档全开"(材料只有几档,落不到档上的数字要归到最近的档)。
|
||||
*/
|
||||
assert.equal(A.blurStyleFor(0), 'NONE', '不模糊就是不用材质');
|
||||
assert.equal(A.blurStyleFor(4), 'COMPONENT_THIN');
|
||||
assert.equal(A.blurStyleFor(8), 'COMPONENT_THIN');
|
||||
assert.equal(A.blurStyleFor(9), 'COMPONENT_REGULAR');
|
||||
assert.equal(A.blurStyleFor(20), 'COMPONENT_REGULAR');
|
||||
assert.equal(A.blurStyleFor(21), 'COMPONENT_THICK');
|
||||
assert.equal(A.blurStyleFor(40), 'COMPONENT_THICK');
|
||||
assert.equal(A.blurStyleFor(999), 'COMPONENT_THICK', '越界要归到最近的档,不能返回空');
|
||||
assert.equal(A.blurStyleFor(-3), 'NONE');
|
||||
// 档次必须来自系统枚举(写成自造名字会编译不过/不生效)
|
||||
const sdk = A.blurStyleFor(12);
|
||||
const commonDts = readFileSync(process.env.HARMONY_COMMON_DTS
|
||||
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts', 'utf8');
|
||||
const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle'));
|
||||
const members = [...enumBlock.slice(0, enumBlock.indexOf('}')).matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]);
|
||||
assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员');
|
||||
for (const tier of ['NONE', 'COMPONENT_THIN', 'COMPONENT_REGULAR', 'COMPONENT_THICK']) {
|
||||
assert.ok(members.includes(tier), `${tier} 必须是系统 BlurStyle 的成员`);
|
||||
}
|
||||
assert.ok(members.includes(sdk));
|
||||
});
|
||||
|
||||
test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不自己维护一套色值)', () => {
|
||||
assert.equal(A.colorModeFor('system'), 'COLOR_MODE_NOT_SET', '跟随系统是默认档');
|
||||
assert.equal(A.colorModeFor('light'), 'COLOR_MODE_LIGHT');
|
||||
assert.equal(A.colorModeFor('dark'), 'COLOR_MODE_DARK');
|
||||
assert.equal(A.colorModeFor('乱七八糟'), 'COLOR_MODE_NOT_SET', '认不出就跟随系统');
|
||||
/*
|
||||
* 数值必须与 SDK 的 `ConfigurationConstant.ColorMode` 一致 —— 这**容易记反**:
|
||||
* `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`。判据直接读 SDK 的枚举文件比对,
|
||||
* 不凭印象(我第一版就是按 0=浅色 写的,选深色会切成浅色)。
|
||||
*/
|
||||
const constDts = readFileSync(process.env.HARMONY_CONFIG_CONSTANT_DTS
|
||||
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts', 'utf8');
|
||||
const valueOf = (name) => {
|
||||
const m = new RegExp(`${name}\\s*=\\s*(-?\\d+)`).exec(constDts);
|
||||
assert.ok(m, `SDK 里要有 ${name}`);
|
||||
return Number(m[1]);
|
||||
};
|
||||
assert.equal(A.colorModeValue('dark'), valueOf('COLOR_MODE_DARK'), '深色的数值要跟 SDK 一致');
|
||||
assert.equal(A.colorModeValue('light'), valueOf('COLOR_MODE_LIGHT'), '浅色的数值要跟 SDK 一致');
|
||||
assert.equal(A.colorModeValue('system'), valueOf('COLOR_MODE_NOT_SET'), '跟随系统的数值要跟 SDK 一致');
|
||||
// 三个数值必须互不相同(写反了这里也能看出来)
|
||||
assert.equal(new Set(['dark', 'light', 'system'].map(A.colorModeValue)).size, 3);
|
||||
// 落地处必须真的调系统 API,而且用同一个映射(免得两边各有一套判断)
|
||||
const store = read('common/AppearanceStore.ets');
|
||||
assert.match(store, /app\.setColorMode\(colorModeValue\(theme\)\)/, '主题要交给系统色彩模式,数值走纯逻辑');
|
||||
assert.ok(!/setColorMode\(\s*-?\d\s*\)/.test(store), '页面/store 里不该自己写死色彩模式数值(容易写反)');
|
||||
// 遮罩浓度:0~90 → 0~1
|
||||
assert.equal(A.scrimOpacity(0), 0);
|
||||
assert.equal(A.scrimOpacity(90), 0.9);
|
||||
assert.equal(A.scrimOpacity(500), 0.9, '越界要夹住');
|
||||
});
|
||||
|
||||
// ───────────────────── 接线("逻辑写好了没人用"是这一期要防的) ─────────────────────
|
||||
|
||||
test('★ 路径是相对基地址的(WebUI 那条"整套同步从来没生效过"的坑)', () => {
|
||||
const api = read('api/AppearanceApi.ets');
|
||||
assert.match(api, /get<AppearanceApiResponse>\('\/me\/appearance'\)/, 'GET 路径');
|
||||
assert.match(api, /put<AppearanceResponse>\('\/me\/appearance'/, 'PUT 路径');
|
||||
assert.match(api, /uploadFile\('\/me\/appearance\/image'/, '上传壁纸路径');
|
||||
assert.match(api, /getBytes\('\/me\/appearance\/image'\)/, '取壁纸路径');
|
||||
// base 已经含 /api/v1:再写一层就是 /api/v1/api/v1/... (WebUI 侧真发生过)
|
||||
assert.ok(!/\/api\/v1\//.test(api), 'AppearanceApi 里不该出现 /api/v1 前缀');
|
||||
// 图片必须带认证取回来:不能用 ?token=(进日志与历史),也不该让 Image 直接加载 http
|
||||
assert.ok(!/\?token=/.test(api), '不接受把密钥写进 URL');
|
||||
const client = read('api/ApiClient.ets');
|
||||
assert.match(client, /expectDataType: http\.HttpDataType\.ARRAY_BUFFER/, '取图要按二进制收,不能当 JSON 解析');
|
||||
});
|
||||
|
||||
test('缓存键**带账号**(多账号共用一份 = WebUI 的原始缺陷)', () => {
|
||||
const store = read('common/AppearanceStore.ets');
|
||||
assert.match(store, /const KEY_PREFIX: string = 'appearance\.'/, '缓存键要有账号前缀');
|
||||
assert.match(store, /return KEY_PREFIX \+ accountId;/, '键必须拼上账号 id');
|
||||
assert.match(store, /prefKey\(accountId\)/, '读缓存要按账号取键');
|
||||
// 换账号后外观要跟着走:设置页与主界面都要用**当前激活账号**去读
|
||||
const settings = read('pages/SettingsPage.ets');
|
||||
assert.match(settings, /store\.loadLocal\(ctx, this\.activeId\)/, '设置页按激活账号读缓存');
|
||||
const main = read('pages/MainPage.ets');
|
||||
assert.match(main, /store\.loadLocal\(ctx, acctMgr\.getActiveId\(\)\)/, '主界面按激活账号读缓存');
|
||||
});
|
||||
|
||||
test('两处入口都真的应用了外观(只有设置页生效 = 一进主界面就变回去)', () => {
|
||||
const main = read('pages/MainPage.ets');
|
||||
assert.match(main, /AppearanceStore\.getInstance\(\)/, '主界面要用同一个 store');
|
||||
assert.match(main, /store\.syncFromServer\(ctx, client\)/, '主界面进入时要拉一次');
|
||||
const settings = read('pages/SettingsPage.ets');
|
||||
assert.match(settings, /await store\.syncFromServer\(ctx, client\)/, '设置页要拉一次');
|
||||
assert.match(settings, /new AppearanceApi\(client\)\.put\(snap, store\.wallpaper !== null\)/, '改主题要写回服务端');
|
||||
// 降级要显示给人看("仅本机"),而不是只在内部变量里
|
||||
assert.match(settings, /statusLabel\(this\.appearanceStatus\)/, '状态要渲染出来');
|
||||
assert.match(settings, /this\.appearanceStatus = 'local-only'/, '写服务端失败时要如实降级');
|
||||
// 主题切换三档要齐(跟随系统 / 浅色 / 深色)
|
||||
assert.match(settings, /\['system', 'light', 'dark'\]/, '三档主题');
|
||||
});
|
||||
|
||||
test('判据自检:把「服务端没记录」判成覆盖本地,必须判红', () => {
|
||||
/*
|
||||
* 自检不重跑源码,而是**验证这条判据真的能区分两种行为**:
|
||||
* 手工构造"错误实现"的输出,确认断言会拒绝它。
|
||||
* (只断言"看起来能红"是不够的 —— 变异测试在下一层做,见提交信息。)
|
||||
*/
|
||||
const wrong = { action: 'apply-remote', status: 'synced', shouldPush: false, snapshot: snap() };
|
||||
const right = A.mergeAppearance(snap({ theme: 'dark' }), resp({ saved: false }), false);
|
||||
assert.notDeepEqual(
|
||||
{ action: wrong.action, shouldPush: wrong.shouldPush },
|
||||
{ action: right.action, shouldPush: right.shouldPush },
|
||||
'自检:错误实现与正确实现的这三个字段必须不同,否则判据区分不出行为'
|
||||
);
|
||||
});
|
||||
|
||||
export const __coverage = ['snapshotFromResponse', 'payloadFromLocal', 'mergeAppearance', 'localOnly', 'blurStyleFor', 'colorModeFor', 'scrimOpacity', 'statusLabel'];
|
||||
@ -40,6 +40,8 @@ const SUITE = [
|
||||
['test/cross-client-theme.test.mjs', ['--test']],
|
||||
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings', '--test']],
|
||||
['test/harmony-system-api.test.mjs', ['--test']],
|
||||
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
|
||||
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings', '--test']],
|
||||
['test/build-stamp.test.mjs', ['--test']],
|
||||
['test/packaging.test.mjs', []]
|
||||
];
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -604,3 +604,51 @@ SDK 里写着:`@ohos.promptAction.d.ts` 的全局 `showToast` 标 `@deprecated
|
||||
**未验**:底部/内部页签在真机上的观感、悬浮加号的位置、徽标与文字的排版 ——
|
||||
仍然只有真机(或模拟器,需人在命令行启动)能看。已验证:构建成功、28 条判据全绿、
|
||||
六种变异都能判红。
|
||||
|
||||
### 7.16 P4:外观(主题 + 壁纸)跟着**账号**走
|
||||
|
||||
服务端 2026-09-13 就已经是权威(`server/internal/handler/appearance.go`,账号级),
|
||||
WebUI 也接好了;鸿蒙这边此前**完全没有接** —— 主题与壁纸在鸿蒙上是"不存在的东西"。
|
||||
|
||||
这一期做的事:
|
||||
|
||||
- `api/AppearanceApi.ets`:`GET/PUT /me/appearance`、`POST/me/appearance/image`、
|
||||
`GET /me/appearance/image`(图片**带认证取回本体**,不用 `?token=`)。
|
||||
- `model/Appearance.ts`(纯逻辑,判据直接跑):归一化、PUT 报文、**合并决策**、
|
||||
模糊值→系统材质映射、主题→系统色彩模式、遮罩浓度、状态文案。
|
||||
- `common/AppearanceStore.ets`:落地副作用 —— 主题交给系统(`setColorMode`),
|
||||
壁纸取回 `PixelMap`,缓存**按账号**分键。
|
||||
- 入口两处:`MainPage`(进主界面就应用,否则"改完主题一进主界面就变回去")与
|
||||
`SettingsPage` 的「外观」段(三档主题 + 同步状态)。
|
||||
|
||||
**两条最贵的规则**(WebUI 侧都踩过,判据盯着):
|
||||
|
||||
1. **服务端"没有记录"时必须以本地为准**(`saved === false`)—— 服务端这时回的是一份
|
||||
*默认值*,拿它覆盖本地 = 把用户已有的主题/壁纸抹掉(WebUI 原话:
|
||||
"每个老用户升级后第一次登录都会发现被重置")。正确动作是把本地那份推上去。
|
||||
2. **降级必须可见**(`local-only` → 界面显示「仅本机」)—— 否则用户以为换设备也能带走。
|
||||
|
||||
**判据**(`harmony-appearance.test.mjs`,11 条,已接进 `run-all.mjs`):
|
||||
归一化(脏值/越界/小数/NaN);`image` 档无图 → 退回 `none`;PUT 报文字段名(蛇形);
|
||||
★服务端无记录 → 以本地为准且**一个字段都不能被顶掉**;服务端有记录 → 以服务端为准但
|
||||
**不擦掉**本地那张服务端还没有的图;离线状态可见;★模糊值→系统材质档次(并与 SDK 的
|
||||
`BlurStyle` 成员逐一比对);主题→色彩模式(数值与 SDK 的 `ConfigurationConstant.ColorMode`
|
||||
逐一比对);★路径必须是**相对基地址**的;缓存键**带账号**;两处入口都真的应用。
|
||||
|
||||
**变异验证**:服务端无记录时拿默认值覆盖本地 → 红;不管"image 档但服务端没图" → 红;
|
||||
材质档次写成自造名字 → 红;路径多写一层 `/api/v1` → 红;缓存键不带账号 → 红;
|
||||
深浅色彩模式数值写反 → 红。
|
||||
|
||||
**判据抓到的真 bug(两处)**:
|
||||
|
||||
- `snapshotFromResponse` 在"字段缺失"时返回 `bgDim = 0`,而 WebUI 语义是退回 12 ——
|
||||
因为 ArkTS 的反序列化把缺失字段留成**类里写的默认值**,"字段不在"与"字段是 0"分不开。
|
||||
已把 `AppearanceResponse` 的默认值对齐 WebUI 的 `clamp(..., dflt)` 语义。
|
||||
- 主题落地时我按"0=浅色、1=深色"写了 `setColorMode` —— **正好反了**
|
||||
(SDK 里 `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`):选深色会切成浅色。
|
||||
发现方式是判据去 SDK 的枚举文件里读数比对,而不是凭印象。映射也因此搬进了纯逻辑
|
||||
(`colorModeValue`),从"某处有个 setColorMode 调用"变成"可判据的行为"。
|
||||
|
||||
**未验 / 未做**:壁纸在真机上的渲染效果(需要真机或模拟器);
|
||||
**壁纸上传(选图 → `POST /me/appearance/image`)还没接** —— 需要文件选择器(picker),
|
||||
这一期的 API 与命名都已就位,但入口没做,所以**不要**把它当成"已完成"。
|
||||
|
||||
Reference in New Issue
Block a user