From 2a6ad93ec0b5cae94c3a771f6692660e15708fee Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 14:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(harmony):=20P4=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=A4=96=E8=A7=82=EF=BC=88=E4=B8=BB=E9=A2=98=20+=20=E5=A3=81?= =?UTF-8?q?=E7=BA=B8=EF=BC=89=E8=B7=9F=E7=9D=80=E8=B4=A6=E5=8F=B7=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端 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` 会当场炸, 因为它假定响应是 JSON(`JSON.parse`)。 - `model/Appearance.ts`(纯逻辑,判据直接执行):归一化 / PUT 报文 / **合并决策** / 模糊值→系统材质档次 / 主题→系统色彩模式 / 遮罩浓度 / 状态文案。 - `common/AppearanceStore.ets`:落地副作用 —— 主题交给**系统**(`setColorMode`,不自己维护 深色色值)、壁纸取回 `PixelMap`、缓存**按账号**分键(`appearance.`)。 - 入口两处:`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 与命名已就位。 **未验**:壁纸在真机上的渲染 —— 需真机或模拟器。 --- .../electron/test/harmony-appearance.test.mjs | 247 ++++++++++++++++++ client/electron/test/run-all.mjs | 2 + .../entry/src/main/ets/api/ApiClient.ets | 35 +++ .../entry/src/main/ets/api/AppearanceApi.ets | 65 +++++ .../src/main/ets/common/AppearanceStore.ets | 200 ++++++++++++++ .../entry/src/main/ets/model/Appearance.ts | 240 +++++++++++++++++ .../entry/src/main/ets/pages/MainPage.ets | 23 ++ .../entry/src/main/ets/pages/SettingsPage.ets | 98 +++++++ docs/HARMONY-ALIGN-PLAN.md | 48 ++++ 9 files changed, 958 insertions(+) create mode 100644 client/electron/test/harmony-appearance.test.mjs create mode 100644 client/harmony/entry/src/main/ets/api/AppearanceApi.ets create mode 100644 client/harmony/entry/src/main/ets/common/AppearanceStore.ets create mode 100644 client/harmony/entry/src/main/ets/model/Appearance.ts diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs new file mode 100644 index 0000000..83daa62 --- /dev/null +++ b/client/electron/test/harmony-appearance.test.mjs @@ -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\('\/me\/appearance'\)/, 'GET 路径'); + assert.match(api, /put\('\/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']; diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index a359a2b..6a23532 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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', []] ]; diff --git a/client/harmony/entry/src/main/ets/api/ApiClient.ets b/client/harmony/entry/src/main/ets/api/ApiClient.ets index 5d7bac2..806f565 100644 --- a/client/harmony/entry/src/main/ets/api/ApiClient.ets +++ b/client/harmony/entry/src/main/ets/api/ApiClient.ets @@ -179,6 +179,41 @@ export class ApiClient { // 不复用销毁:会话级实例保留 Cookie } + /** + * 取**二进制**(壁纸本体)。 + * + * 单独一个方法而不是复用 `request`:`request` 假定响应是 JSON + * (`JSON.parse(response.result as string)`),拿它取图会当场炸。 + * + * 壁纸**带认证取回来**(Bearer 或 cookie),不使用 `?token=` —— + * 那会把密钥写进服务端日志与访问历史(服务端注释里明确不做这件事)。 + */ + async getBytes(path: string): Promise { + 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 = {}; + 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(path: string, query?: string): Promise { const opts = new RequestOptions(); diff --git a/client/harmony/entry/src/main/ets/api/AppearanceApi.ets b/client/harmony/entry/src/main/ets/api/AppearanceApi.ets new file mode 100644 index 0000000..417e3f5 --- /dev/null +++ b/client/harmony/entry/src/main/ets/api/AppearanceApi.ets @@ -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 { + return this.client.get('/me/appearance'); + } + + /** 写外观档(主题 + 背景档与参数;图片走 `uploadImage`) */ + async put(snapshot: AppearanceSnapshot, hasLocalImage: boolean): Promise { + const payload: AppearanceResponse = payloadFromLocal(snapshot, hasLocalImage); + return this.client.put('/me/appearance', payload); + } + + /** 上传壁纸(multipart,字段名 file)→ 服务端存 blob,库里只留 sha256 */ + async uploadImage(filePath: string, fileName: string): Promise { + return this.client.uploadFile('/me/appearance/image', filePath, fileName); + } + + /** + * 取壁纸**本体**。 + * + * 必须带认证取回来再交给渲染层:`Image('http://…')` 发不出认证头, + * 而 `?token=` 会把密钥写进日志(服务端明确不接受)。 + */ + async fetchImageBytes(): Promise { + return this.client.getBytes('/me/appearance/image'); + } +} diff --git a/client/harmony/entry/src/main/ets/common/AppearanceStore.ets b/client/harmony/entry/src/main/ets/common/AppearanceStore.ets new file mode 100644 index 0000000..8242210 --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/AppearanceStore.ets @@ -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.`)。 + * + * 键必须带账号 —— 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 { + 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 { + 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; +} diff --git a/client/harmony/entry/src/main/ets/model/Appearance.ts b/client/harmony/entry/src/main/ets/model/Appearance.ts new file mode 100644 index 0000000..c2da189 --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/Appearance.ts @@ -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 '已同步'; +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 892b9f8..3816768 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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 { + 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); } /** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */ diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 93e3dc5..6ecb05e 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -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 { + 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 { + 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() diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index 1222b60..046b4ec 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -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`):选深色会切成浅色。 + 发现方式是判据去 SD​K 的枚举文件里读数比对,而不是凭印象。映射也因此搬进了纯逻辑 + (`colorModeValue`),从"某处有个 setColorMode 调用"变成"可判据的行为"。 + +**未验 / 未做**:壁纸在真机上的渲染效果(需要真机或模拟器); +**壁纸上传(选图 → `POST /me/appearance/image`)还没接** —— 需要文件选择器(picker), +这一期的 API 与命名都已就位,但入口没做,所以**不要**把它当成"已完成"。