跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据

这一轮从「全面对齐 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% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
This commit is contained in:
2026-09-19 15:03:40 +08:00
parent 4a5318ca28
commit f811c9887a
9 changed files with 736 additions and 20 deletions

View File

@ -6,7 +6,7 @@
* 判据直接跑那一份;这里只负责搬字节。
*/
import { ApiClient } from './ApiClient';
import { AppearanceResponse, AppearanceSnapshot, payloadFromLocal } from '../model/Appearance';
import { AppearancePayload, AppearanceResponse, AppearanceSnapshot, payloadFromLocal } from '../model/Appearance';
/** `GET /me/appearance` 的响应(形状与 handler 的 JSON 一致) */
export class AppearanceApiResponse {
@ -44,7 +44,7 @@ export class AppearanceApi {
/** 写外观档(主题 + 背景档与参数;图片走 `uploadImage`) */
async put(snapshot: AppearanceSnapshot, hasLocalImage: boolean): Promise<AppearanceResponse> {
const payload: AppearanceResponse = payloadFromLocal(snapshot, hasLocalImage);
const payload: AppearancePayload = payloadFromLocal(snapshot, hasLocalImage);
return this.client.put<AppearanceResponse>('/me/appearance', payload);
}

View File

@ -89,9 +89,33 @@ export function snapshotFromResponse(resp: AppearanceResponse): AppearanceSnapsh
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): AppearanceResponse {
const out: AppearanceResponse = new AppearanceResponse();
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(否则服务端会存一个指向空图的记录)

View File

@ -6,6 +6,7 @@
* 「日历」原先写着"等 P6 内容做完再上入口",P6 第一步(只读月视图,见 `pages/CalendarPage.ets`)
* 已完成,所以入口现在上;日历是整个客户端里**常驻挂载**的那一个 pane(见 build() 里的说明)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme';
import { AmIcon } from '../common/Icons';
@ -2164,10 +2165,46 @@ struct MainPage {
* 键名与 `CommPage` 里的常量必须一致 —— 拼错不报错、只会恒为默认 0
* (那正好是"徽标永远不出现"这个症状,与 windowInsets 同一个坑)。
*/
/*
* 外观变更计数器(`SettingsPage.setBackground` 发布)—— 变了就重算壁纸层。
*
* ★★ 2026-09-19 补(设备实测撞出来的真 bug):原先 `bgPlan` 只在启动的
* `syncAppearance()` 里算一次,之后没人动它 ⇒ **在「我的」页改档位、
* 页面背景一点不变**(色板出现、状态显示「已同步」、服务端也真存了,
* 但壁纸层压根没重画)。
*
* 用**计数器**而不是布尔:连改两次压暗也应各触发一次重算;
* 布尔从 true 再设 true 不产生变化通知 —— 那正是"改了没反应"的经典形态。
*/
@StorageProp('agentmail.appearance.revision') @Watch('onAppearanceChanged') appearanceRev: number = 0;
@StorageProp('agentmail.nav.unread') navUnread: number = 0;
@StorageProp('agentmail.nav.pending') navPending: number = 0;
@StorageProp('agentmail.nav.contacts') navContacts: number = 0;
@State bgPlan: BackgroundPlan = new BackgroundPlan();
/**
* 壁纸层的内容版本号 —— **必须有它**,`bgPlan` 单独不够。
*
* ★★ 2026-09-19 修(真 bug,设备实测撞出来的):`bgPlan` 是 `@State`,
* 但 ArkTS 的 `@State` **观察不到类内部字段**的变化 —— 而 `WallpaperLayer()`
* 读的正是 `this.bgPlan.layers`(数组)与 `this.bgPlan.kind`(成员)。
* 于是 `applyAppearance()` 把新的 plan 赋进去之后,**壁纸层不重渲染**,
* 屏幕上一直是最初那份(`kind=none`)。
*
* 实测证据(hilog):
* `Appearance: sync: bgKind=preset ... hasImg=true` ← 数据是对的
* `Wallpaper: kind=none layers=0 active=false` ← 渲染读到的还是旧值
* 两行同一次启动,前后相差 100ms。
*
* ⇒ 加一个**原始类型**(number)的计数器,每次算完 plan 就 +1。
* `@State` 对原始类型必然敏感,`WallpaperLayer()` 的读法也带上它
* (见那里的 `this.bgContentRev` 注释)—— 两者缺一不可:
* 只加计数器而渲染不读它,等于没加。
*
* 同一个坑在本仓出现过(`AppearanceStore` 那段注释里写着"@State 观察不到
* 类内部字段的变化",当时只用来解释"为什么要多存一份显示副本")。
* 这一次是同一个原因、换了个地方发作。
*/
@State bgContentRev: number = 0;
/**
* 侧栏底部头像要显示的账号标签(取前两字,对齐 WebUI `Sidebar.tsx:190`
* 的 `(user?.display_name || user?.username || '?').slice(0, 2)`)。
@ -2357,6 +2394,12 @@ struct MainPage {
this.envCallbackId = -1;
}
onAppearanceChanged(): void {
this.applyAppearance().catch((e: Error) => {
hilog.warn(0x0001, 'MainPage', '外观变更后重算失败:%{public}s', e.message);
});
}
/**
* 读外观(按**账号**)→ 算出画什么 → 落进 @State。
*
@ -2416,6 +2459,8 @@ struct MainPage {
// 模糊强度是**服务端给的 px 原值**:计划只搬运它,映射成系统材质档在画的那一层做
this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark, snap.bgBlur);
this.bgActive = this.bgPlan.kind !== 'none';
/* 让 `@State` 感知到"壁纸内容换了"(对象内部字段的变化观察不到 —— 见它的声明) */
this.bgContentRev = this.bgContentRev + 1;
/*
* 壁纸淡入(对齐 WebUI `index.css:742-752`:`.app-backdrop` 从 `opacity:0`
* 过渡到 `1`,时长 `--dur-base: 180ms`、曲线 `--ease-out-soft`)。
@ -2461,7 +2506,14 @@ struct MainPage {
*/
@Builder
WallpaperLayer() {
if (this.bgPlan.kind === 'preset') {
/*
* ★ `this.bgContentRev` 必须在**这里被读到** —— 光有计数器不够。
* ArkUI 按"这个 Builder 读了哪些 @State"决定要不要重跑它:
* 不读,计数器变了也不会重渲染(那正是"加了计数器却没效果"的形状)。
* `@Builder` 里**不能声明局部变量**(编译报 "Only UI component syntax"),
* 所以直接把条件写进 if —— 表达式里消费它即可。
*/
if (this.bgPlan.kind === 'preset' && this.bgContentRev >= 0) {
Stack() {
ForEach(this.bgPlan.layers, (layer: PresetLayer) => {
if (layer.kind === 'radial') {
@ -2517,7 +2569,7 @@ struct MainPage {
}
.width('100%').height('100%')
.opacity(this.bgFadeIn)
} else if (this.bgPlan.kind === 'image' && this.wallpaperImage !== null) {
} else if (this.bgPlan.kind === 'image' && this.wallpaperImage !== null && this.bgContentRev >= 0) {
Stack() {
Image(this.wallpaperImage)
.width('100%').height('100%')
@ -2864,6 +2916,26 @@ struct MainPage {
}
})
}
/*
* ★★ 2026-09-19 修:内容列改为 `layoutWeight(1)`(用户报「我的」页右侧内容被截)。
*
* 原来只写 `.width('100%')` —— 在 `Row` 里,`100%` 是**父容器全宽**,
* 而父容器里还住着 60vp 的侧栏 ⇒ 侧栏 + 内容 = 100% + 60vp,**必然溢出 60vp**。
*
* 实测(密度 2.875、屏 3184px):内容列 `Column [229,28][3357,2204]` ⇒ 右边缘
* 3357 **超出屏幕 3184** 整整 173px(= 60vp)。后果不是"整体右移",
* 而是**靠右的内容被顶出屏幕**:「我的」页压暗滑杆的数值文本 `Text('56%')`
* 落在 `x=3250` —— 屏幕外。用户只看得到滑杆、看不到数值。
*
* 顺带解释了那个追了很久的"`56.000000`"假象:dumpLayout 里 `Slider` 节点的
* `text='56.000000'` 是**无障碍文本**(屏幕上看不见),截图里根本没有;
* 真正的问题是那个看得见的 `56%` 被挤出了屏。
* ⇒ 教训:**dump 的 `text` 不等于"屏幕上有这串字"**。判"看得见吗"要看边界
* 是否落在屏幕内(或直接截图),不然会去追一个不存在的渲染 bug。
*
* 为什么日历页没这个症状:它是常驻挂载 + 自己算宽度;而「我的」这一支露出来了。
* 同一处错误在不同 pane 上表现不同,所以更容易被当成"某一页的样式问题"去调。
*/
Column() {
if (this.currentIndex === 0) {
CommPage({ bgActive: this.bgActive, navReserve: this.navReserve })
@ -2922,7 +2994,12 @@ struct MainPage {
.opacity(this.calPaneIn)
.translate({ y: (1 - this.calPaneIn) * Theme.riseInOffset })
}
.width('100%')
/*
* 内容列吃 **剩余** 宽度(不是父容器的 100%)——
* 理由见本列开头的长注释:写 `100%` 会与侧栏的 60vp **相加而溢出屏幕**,
* 把靠右的内容(`「我的」页压暗滑杆的数值标签`)顶到可视区之外。
*/
.layoutWeight(1)
.height('100%')
/*
* ★ 2026-09-17:窄屏壁纸可见化(对齐 WebUI 的 .narrow-shell)。

View File

@ -96,6 +96,13 @@ export struct SettingsPane {
* 改成"选择器在**用户改值**时显式回调"之后,方向是单向的:
* 服务端来的值只往下走,不会回头再推一次。**不需要任何抑制标志。**
*/
/*
* 外观变更的发布键(`AppStorage`)—— 改完外观后 MainPage 靠它重算壁纸层。
* 常量而不是字面量:`AppStorage.get('拼错的名字')` 不报错、只会恒为 undefined,
* 而那正好是"壁纸永远不跟着变"这个症状(与 windowInsets 同一个坑)。
*/
private readonly KEY_APPEARANCE_REVISION: string = 'agentmail.appearance.revision';
@State bgKind: string = 'none';
@State bgPresetId: string = 'aurora';
@State bgDim: number = 12;
@ -152,6 +159,29 @@ export struct SettingsPane {
} catch (e) {
this.appearanceStatus = 'local-only';
}
/*
* ★★ 2026-09-19 补(设备实测撞出来的**真 bug**):把"外观变了"这件事
* **发布出去**,让 `MainPage` 重算 `bgPlan` 并重画壁纸层。
*
* 原来的缺口:本页改了档位、也存进了 store,但**只有本页的 @State 变了** ——
* `MainPage` 的 `bgPlan`/`bgActive` 是启动时 `syncAppearance()` 算一次的,
* 之后没人再动它。症状:色板出现、状态显示「已同步」、服务端也真的存了,
* 而**页面背景一点没变**(壁纸层压根没画出来)。
*
* 机制沿用 `AppStorage` 单向发布(与徽标、windowInsets 同一套):
* 窗格侧**写**(这里),MainPage 侧 `@Watch` **读**(见 MainPage 的声明)。
* 不用 AppearanceStore 加订阅 —— 单例上加回调列表就要管注销,
* 而这里只需要一个"变化计数器"(revision),越简单越不容易漏。
*
* 计数而不是布尔:连续改两次压暗也应各触发一次重算
* (布尔从 true 设到 true 不产生变化通知 —— 那是"改了没反应"的经典形态)。
*/
try {
const prev: number = AppStorage.get<number>(this.KEY_APPEARANCE_REVISION) ?? 0;
AppStorage.setOrCreate(this.KEY_APPEARANCE_REVISION, prev + 1);
} catch (e) {
hilog.warn(0x0001, 'Settings', '发布外观变更失败:%{public}s', JSON.stringify(e));
}
}
/**