这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。
## 一、外观保存从来就没成功过(PUT 400)
`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。
症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。
★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。
## 二、改了档位,页面背景一点不变(两层原因)
**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。
**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:
Appearance: sync: bgKind=preset … hasImg=true ← 数据是对的
Wallpaper: kind=none layers=0 active=false ← 渲染读到的还是旧值
修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。
★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。
## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)
真相有**两层,两层都值得记**:
1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
—— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。
根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。
★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。
## 四、设备判据基建(这一轮加的能力)
- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
`swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
(拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
`harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
· `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
· 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」
## 验证
`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。
设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。
**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
/*
|
||
* 外观(主题 + 壁纸)在**服务端**与本地之间的搬运 —— 纯逻辑,无 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 形状 —— 注意它与 `AppearanceResponse`(GET 回来的)**不是**同一个类型。
|
||
*
|
||
* ★★ 2026-09-19 修(真 bug,设备实测撞出来的):原先这里复用 `AppearanceResponse`,
|
||
* 而那个类型带 `has_image` / `image_bytes` / `saved` —— **服务端不认识这三个字段**,
|
||
* 且服务端的 `Decode()` 是 `DisallowUnknownFields()`(严格,有意为之)。
|
||
* 于是每一次外观保存都 400,症状是"点了预设档、色板出来了、但壁纸没画上去":
|
||
* 界面看着切了(本地 @State 变了),服务端其实拒收(`PUT /me/appearance` → 400)。
|
||
*
|
||
* 为什么能潜伏这么久:**本地状态立刻变**,所以肉眼看着像成功了;
|
||
* 只有看 hilog 的 HTTP 状态码才发现 400。而所有静态判据判的都是
|
||
* "payload 的字段对不对" —— 它们读的是同一份类型定义,**看不见'多带了字段'**。
|
||
* 这正是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
|
||
*
|
||
* 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。
|
||
*/
|
||
export class AppearancePayload {
|
||
theme: string = 'system';
|
||
bg_kind: string = 'none';
|
||
bg_preset_id: string = 'aurora';
|
||
bg_dim: number = 12;
|
||
bg_blur: number = 4;
|
||
}
|
||
|
||
/** 本地快照 → 要 PUT 上去的 JSON 形状(服务端也做同样的归一,两边都要做) */
|
||
export function payloadFromLocal(local: AppearanceSnapshot, hasLocalImage: boolean): AppearancePayload {
|
||
const out: AppearancePayload = new AppearancePayload();
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 壁纸模糊档 → **系统材质档次名**(不是像素半径)。
|
||
*
|
||
* ── ★ 已删除(2026-09-15,pi 的裁定)──
|
||
*
|
||
* 它曾是"`bg_blur` → `BlurStyle` 档位"的唯一映射函数。删除的理由是**消费者不存在,而且不该存在**:
|
||
* · 页面上**没有任何调用点** —— 它只被自己的判据调用;
|
||
* · 唯一的计划消费者是"导航条档位跟随 `bg_blur`",而 pi 2026-09-15 **否决了那个方案**
|
||
* (导航条是 chrome,材质固定;见 `MainPage.ets` 的 `NavBar` 注释)。
|
||
* 所以它不是"暂时还没有消费者",是**不该有**。
|
||
* · 这正是本仓刚立的规矩:**"有测试"不等于"有人用"**。一个只靠自己的判据活着的函数,
|
||
* 与 `Theme.navMaterial` 那个"只被不可达 `??` 分支引用"的死令牌是**同一个形状**,
|
||
* 只是换了个层级。当时我在 `harmony-appearance.test.mjs` 里诚实地标了它是"孤岛",
|
||
* 但**"孤岛"写在注释里等于没有机制** —— 注释拦不住下一个读者。
|
||
*
|
||
* **想法若真回来**:`git log --all -- <本文件>` 里有实现,而 (a) 那次提交(`426caf9`)
|
||
* 本身就是"它为什么不该回来"的记录。**不要为了"万一有用"把它留在代码里。**
|
||
*
|
||
* 注意与它**不是**一回事的:壁纸层的 `.blur(px)` 用的是**像素半径**
|
||
* (服务端 `bg_blur` 原值,0~40,与 WebUI 同一个数)—— 那个是**活的**,没有删。
|
||
* "px 半径"与"系统材质档"是两个量,别再合到一起。
|
||
*/
|
||
|
||
/**
|
||
* 主题偏好 → 系统色彩模式。
|
||
*
|
||
* 用系统色彩模式而不是自己切一套深色色值:这正是"用系统方案"要的效果 ——
|
||
* 深浅两套颜色由系统给,我们只表达"偏好哪一种"。
|
||
* 返回值与 `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(跟随系统)
|
||
}
|
||
|
||
/**
|
||
* 当前主题**实际**算不算深色 —— 预设色板要不要用深色那套由它决定。
|
||
*
|
||
* 为什么不直接看 `theme` 就完事:`theme === 'system'` 时我们并不知道系统当时是深是浅,
|
||
* 要把系统反馈进来的 `colorMode` 一起看。数值同样与 SDK 的
|
||
* `ConfigurationConstant.ColorMode` 对齐(0=深、1=浅、-1=未设置)。
|
||
*
|
||
* **未设置/读不到时按浅色算**:与 WebUI 的默认(`:root` 那套)一致 ——
|
||
* 宁可"系统深浅还没定下来时先用浅色那套",也不要拿深色色板去配浅色系统表面。
|
||
*
|
||
* ⚠️ 已知边界:用户在**应用运行期间**改系统深浅色,这里不会自动重算
|
||
* (要重新进一次页面/重进应用)。系统侧的正确做法是订阅
|
||
* `applicationContext.on('environment', ...)` 的配置变化回调 —— 记在文档里,没做。
|
||
*/
|
||
export function isDarkMode(theme: string, systemColorMode: number): boolean {
|
||
if (theme === 'dark') {
|
||
return true;
|
||
}
|
||
if (theme === 'light') {
|
||
return false;
|
||
}
|
||
return systemColorMode === 0; // COLOR_MODE_DARK
|
||
}
|
||
|
||
/** 遮罩浓度: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 '已同步';
|
||
}
|