跨端: 三个真 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

@ -263,3 +263,100 @@ test('★ 预设:鸿蒙 PRESET_IDS 与 WebUI PRESETS 的 id 与顺序逐项一
`两端的预设清单不一致。\n 鸿蒙:${harmonyIds.join('、')}\n WebUI:${webIds.join('、')}\n` +
'顺序也是契约:顺序不同会让两端的选择界面看起来"选错了"。');
});
/* ═══════════ 三个设备实测撞出来的真 bug 的**回锚**判据 ═══════════ */
/*
* 这三条都是 2026-09-19 在真机上撞出来的,且**静态判据全都绿**:
* 静态层判的是"字段对不对""逻辑算得对不对",而它们坏在
* 「数据对、界面不动」「请求被服务端拒收」「内容被顶出屏幕」这三种形状上。
*
* 每条都锚在**能复现该 bug 的那个具体形状**上,不是为了凑数。
*/
test('★ 回锚:PUT 上去的 payload 不得带服务端不认识的字段(响应类型 ≠ 请求类型)', () => {
/*
* 真 bug:`payloadFromLocal` 原先返回 `AppearanceResponse` —— 那个类型是 **GET 的响应**,
* 带着 `has_image` / `image_bytes` / `saved` 三个字段。而服务端的 `Decode()`
* 是 `DisallowUnknownFields()`(严格,有意为之)⇒ **每一次外观保存都 400**。
*
* 症状极隐蔽:本地 @State 立刻变 ⇒ 肉眼看着像成功了;
* 只有看 hilog 的 HTTP 状态码才发现 400。(修好后服务端访问日志是 200。)
*
* 判据形状:请求类型里**不许**出现任何只属于响应的字段。
* ★ 为什么不是"断言有 AppearancePayload 这个类"那种弱判据:
* 那样只要类名还在就绿,而"payload 又用回响应类型"照样漏。
*/
const src = code(join(ETS, 'model/Appearance.ts'));
const payloadBlock = src.match(/export class AppearancePayload \{([\s\S]*?)\n\}/);
assert.ok(payloadBlock, '要有 `AppearancePayload`(PUT 请求体专用类型)');
const fields = [...payloadBlock[1].matchAll(/^\s{2}(\w+):/gm)].map(m => m[1]);
assert.deepEqual(fields.sort(), ['bg_blur', 'bg_dim', 'bg_kind', 'bg_preset_id', 'theme'],
'★ PUT 的字段集必须**恰好**是服务端 `models.Appearance` 认识的那五个 —— ' +
'多一个就被 DisallowUnknownFields 拒收(400)。' +
`实际:${fields.join('、')}`);
/* 反向:那三个响应专属字段不许回到 payload 里 */
for (const bad of ['has_image', 'image_bytes', 'saved']) {
assert.ok(!new RegExp(`AppearancePayload[\\s\\S]{0,300}?\\b${bad}\\b`).test(src),
`★ \`${bad}\` 是 GET 响应字段,绝不能出现在 PUT 的 payload 类型里(会导致 400)`);
}
/* 函数签名也要跟上(返回类型写错同样会漏字段) */
assert.match(src, /export function payloadFromLocal\([^)]*\): AppearancePayload/,
'★ `payloadFromLocal` 必须返回 `AppearancePayload`(不是 `AppearanceResponse`)');
});
test('★ 回锚:改了外观必须**发布变更事件**(否则 MainPage 的画布不重算)', () => {
/*
* 真 bug:在「我的」页切档位 → 色板出现、状态显示「已同步」、服务端也真存了,
* 而**页面背景一点没变**。因为 `MainPage` 的 `bgPlan` 只在启动时算一次。
*
* 修法:`SettingsPage` 用 `AppStorage` 发布 revision,`MainPage` 用
* `@StorageProp + @Watch` 接住并重算。
*
* 判据断**两端都接上了**(只写发布方或只写订阅方,功能都是死的):
*/
const settings = code(join(ETS, 'pages/SettingsPage.ets'));
const main = code(join(ETS, 'pages/MainPage.ets'));
const KEY = 'agentmail.appearance.revision';
assert.ok(settings.includes(KEY) && /AppStorage\.setOrCreate/.test(settings),
'★ 改外观后要**发布**变更(`SettingsPage` 写 AppStorage)—— 不发布则 MainPage 永远不知道');
assert.ok(main.includes(KEY) && /@StorageProp\([^)]*\)\s*@Watch\(/.test(main),
'★ `MainPage` 要**订阅**该变更并带 `@Watch` —— 只发布不订阅等于没做');
/* 计数而不是布尔:连改两次也要各触发一次(布尔 true→true 不发通知) */
assert.match(settings, /prev\s*\+\s*1|appearanceRev\s*\+\s*1/,
'★ 必须是**计数器**(每次 +1):布尔从 true 再设 true 不产生变化通知,' +
'那正是"改了没反应"的经典形状');
});
test('★ 回锚:`@State` 持有对象时,改内部字段必须另加原始类型触发器', () => {
/*
* 真 bug(最隐蔽的一个):`bgPlan` 是 `@State BackgroundPlan`,但 ArkTS 的
* `@State` **观察不到类内部字段**的变化 —— 而 `WallpaperLayer()` 读的正是
* `this.bgPlan.kind` / `.layers`。于是 `applyAppearance()` 赋了新 plan 之后,
* **壁纸层不重渲染**,屏幕一直停在最初的 `kind=none`。
*
* 实测证据(hilog,同一次启动相差 100ms):
* `Appearance: sync: bgKind=preset … hasImg=true` ← 数据是对的
* `Wallpaper: kind=none layers=0 active=false` ← 渲染读到的还是旧值
*
* 修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,
* 并在 `WallpaperLayer()` 的**条件表达式里消费它**。
*
* ★ 判据必须同时断两半 —— 这是本条的全部价值所在:
* 只加计数器而渲染不读它 ⇒ 仍然不重渲染(我第一版就差点这样);
* 只读它而没人 +1 ⇒ 永远同一值,也白搭。
*/
const main = code(join(ETS, 'pages/MainPage.ets'));
assert.match(main, /@State bgContentRev: number/,
'要有原始类型的触发器(`@State bgContentRev: number`)—— ' +
'ArkTS 的 @State 观察不到对象内部字段的变化');
assert.match(main, /this\.bgContentRev = this\.bgContentRev \+ 1/,
'★ 每次算完壁纸 plan 必须 +1(只声明不加,触发器永远不变)');
assert.match(main, /this\.bgPlan\.kind === 'preset'[^)]*&&[^)]*bgContentRev/,
'★ **渲染处必须读它**(写在条件表达式里)—— 不读则 Builder 不会重跑,' +
'加了计数器也没用。ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染。');
});

View File

@ -39,7 +39,10 @@ import { code, prose } from './lib/read.mjs';
* 出自 `lib/harmony-device.mjs`(唯一权威处 —— 包名也从那里读,
* 不在这里写第二份,见那条「包名漂移」的教训)。
*/
import { findHdc, hasTarget, foregroundBundle, ourBundle, dumpLayout, walk, swipe } from './lib/harmony-device.mjs';
import {
findHdc, hasTarget, dumpLayout, walk, swipe, tapText,
launchOurApp, boundsCenter, tap,
} from './lib/harmony-device.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
@ -266,19 +269,88 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
if (!hdc || !hasTarget(hdc)) {
return t.skip('设备不在 —— 手势本体本次不跑(上面 ①~④ 静态层仍把住契约)');
}
const fg = foregroundBundle(hdc);
if (fg !== ourBundle()) {
return t.skip(`前台不是我们的应用(${fg})—— 不拿别人的界面断言`);
}
/*
* ★★ 2026-09-19 修:本判据原先要求"前台已经是我们的应用、且已经在日历月档",
* 不满足就 `t.skip` —— 于是**在套件里永远跳过**(run-all 跑到它时前台是别的),
* 只有我手动摆好现场才通过。那等于没有这条判据。
*
* 现在它**自己搭现场**:拉起应用 → 切到日历窗格 → 必要时切回月档。
* 这才是设备判据该有的样子(前置条件自己满足,而不是等人摆好)。
*/
assert.ok(await launchOurApp(hdc), '要能拉起我们的应用并等到它到前台');
await new Promise((r) => setTimeout(r, 1500));
const titleOf = (root) => {
/* 切到日历窗格:宽屏点侧栏第 2 项 / 窄屏点底栏「日历」 */
const rootA = dumpLayout(hdc);
const screenDims = (root) => {
let w = 0;
let h = 0;
for (const n of walk(root)) {
const txt = n.attributes?.text || '';
const m = /^(\d{4})年(\d{1,2})月$/.exec(txt.trim());
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const dims = screenDims(rootA);
const wide = dims.w / dims.h > 1.2;
if (wide) {
/* 侧栏导航轨第 2 项(日历):贴顶、在左 1/6 内、高 > 屏高 4% */
/*
* ★ 必须**要求有文字标签** —— 品牌标(左上那个 40vp 方块,实测 `y1=174`)
* 也是"可点 + 在左 1/6 + 高度够",会被一起选进来。
* 我第一版没排除它,于是排序后 `rail[0]` 是品牌标、`rail[1]` 是**通信**
* ⇒ 想点日历却点到了通信,判据卡在"找不到月档"。
* (`harmony-nav` 的 `navItemsOf` 早就有这条过滤,我这里漏了 —— 同一形状。)
*/
const textsUnder = (n) => {
const out = [];
const sub = (x) => {
if (Array.isArray(x)) { x.forEach(sub); return; }
if (!x || typeof x !== 'object') return;
const t = (x.attributes?.text || '').trim();
if (t) out.push(t);
for (const c of (x.children || [])) sub(c);
};
sub(n);
return out;
};
const rail = [...walk(rootA)].filter((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= dims.w * 0.08 && y1 < dims.h * 0.5 && (y2 - y1) > dims.h * 0.04
&& textsUnder(n).length > 0; // ← 排除品牌标
}).sort((a, b) => {
const y = (n) => Number(/\[(-?\d+),(-?\d+)\]/.exec(n.attributes.bounds)[2]);
return y(a) - y(b);
});
if (rail.length < 2) {
return t.skip(`宽屏侧栏导航轨只找到 ${rail.length} 项 —— 无法切到日历`);
}
const c = boundsCenter(rail[1].attributes.bounds);
assert.ok(tap(hdc, c.cx, c.cy), '要能点到侧栏的「日历」');
} else {
assert.ok(tapText(hdc, '日历'), '要能点到「日历」');
}
await new Promise((r) => setTimeout(r, 2500));
/* 确保在**月档**(标题是 `YYYY年M月`;周/日档是范围式) */
const monthTitle = (root) => {
for (const n of walk(root)) {
const m = /^(\d{4})年(\d{1,2})月$/.exec((n.attributes?.text || '').trim());
if (m) return { year: Number(m[1]), month: Number(m[2]) };
}
return null;
};
if (monthTitle(dumpLayout(hdc)) === null) {
assert.ok(tapText(hdc, '月'), '要在日历里点到「月」档');
await new Promise((r) => setTimeout(r, 2000));
}
/*
* ★★ 2026-09-19:本判据第一版用「滑一次 + 固定等 2500ms + dump」。
@ -294,7 +366,7 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
/** 等标题出现(最多 tries 次),返回读到的标题或 null */
const readTitle = async (tries = 6) => {
for (let i = 0; i < tries; i++) {
const t2 = titleOf(dumpLayout(hdc));
const t2 = monthTitle(dumpLayout(hdc));
if (t2 !== null) return t2;
await settle();
}
@ -302,7 +374,7 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
};
/* 先在月档(周/日档标题是范围式,这里判最简单的那一档) */
const before = await readTitle();
const before = monthTitle(dumpLayout(hdc));
if (before === null) {
return t.skip('月标题找不到(可能在周/日档,或日历页没打开)—— 本次不跑');
}
@ -363,7 +435,7 @@ test('★ 设备:在网格列上左滑,日历标题真的翻到下一段', a
const seen = [];
for (let k = 0; k < 16; k++) { // 最多等 8 秒
await settle(500);
const now = titleOf(dumpLayout(hdc));
const now = monthTitle(dumpLayout(hdc));
if (now !== null) {
seen.push(`${now.year}-${now.month}`);
const delta = (now.year * 12 + now.month) - beforeMonths0;

View File

@ -915,3 +915,378 @@ test('★ 碑文:`blurStyleFor` 不许回来 + 理由必须留在原处(旧
assert.match(prose(WALL_TS), /blurStyleFor[\s\S]{0,200}?已随/,
'碑文仍在原处:删除理由必须写在它被删掉的地方(否则下一个人看不出这里曾经有过什么)');
});
/* ═══════════════ 设备层:壁纸/外观**真的画在屏幕上** ═══════════════ */
/*
* 上面全部判据的最后一层都停在"数据/接线",从没验过 **屏幕上真的出现了那个东西**。
*
* ★ 这不是可有可无的一层 —— 2026-09-19 我在手势上刚吃过一模一样的教训:
* `PanGesture` 的代码全对、静态判据全绿,而真机上**一次都没触发**
* (起点坐标落在相邻的右栏里)。静态判据证明不了"用户点/看得见"。
*
* 这一条验的是 P4 的那句原始要求:**「壁纸开关打开后,屏幕上真的有壁纸」**。
* 做法:进「我的」页 → 打开背景开关 → 对比开关前后**顶层结构**里
* 壁纸层的存在(而不是截图比像素 —— 那太脆且依赖具体图片)。
*/
test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只看数据流)', async (t) => {
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住数据/接线)');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起我们的应用并等到它到前台');
await new Promise((r) => setTimeout(r, 1200));
/*
* 进「我的」窗格。★ **两种布局的入口不同**,必须分别处理 ——
* 这是我第一版直接红掉的地方(写 `tapText(hdc,'我的')`,而宽屏下根本没有底栏):
*
* 窄屏:底栏 4 项,第 4 项文字是「我的」(WebUI `NarrowNav.tsx` 同形)
* 宽屏:**没有底栏**,侧栏只有 3 项导航轨,「我的」是**底部那个头像按钮**
* (WebUI `Sidebar.tsx:186` 的 `setViewMode('account')`,二者同构)
*
* 判定方式与 `harmony-nav` 一致:用**宽高比 > 1.2** 区分形态
* (不是写死 vp 阈值,也不是拿屏幕 px 去比源码里的 vp —— 密度是第二个真相)。
*/
const screenOf = (root) => {
let w = 0;
let h = 0;
for (const n of D.walk(root)) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const root0 = D.dumpLayout(hdc);
const { w: scrW } = screenOf(root0);
const wide = scrW / screenOf(root0).h > 1.2;
if (wide) {
/*
* 宽屏:点侧栏底部**头像**(无文字、贴屏底)。用"左 1/6 内 + 在屏幕下半部 +
* 可点的方块"定位它 —— 与 `harmony-nav` 的 `navRailItemsOf` 同一套形状判法。
*/
const screenH = screenOf(root0).h;
const avatar = [...D.walk(root0)].find((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 && (x2 - x1) > 80 && (y2 - y1) > 80;
});
assert.ok(avatar,
'宽屏侧栏底部要有可点的头像方块(「我的」的入口)—— 找不到它说明入口没了');
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像');
} else {
assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」窗格(底栏第 4 项)');
}
/*
* ★ 等页面真的切过去(**轮询**,不是睡固定时长)。
* 我第一版睡 1800ms 后断言,实测红 —— 而**手动点同一个坐标是立刻切过去的**。
* 原因就是固定等待不够稳(同一条纪律:手势判据也栽在固定 2500ms 上)。
* 现在等"「我的」页的标志性字段出现",最多 ~10 秒。
*/
const inMePane = (root) => [...D.walk(root)].some((n) => {
const t = (n.attributes?.text || '').trim();
/* 「我的」页独有:这几样在别的窗格里不会同时出现 */
return t.includes('用户名') || t.includes('连接密钥') || t.includes('主题由系统');
});
let landed = false;
/*
* ★★ 2026-09-19 修(套件里红、单独跑绿):轮询成功后必须**用新的 dump**,
* 不能继续用切页之前的 `root0`。
*
* 这个错法很隐蔽:`root0` 是"进「我的」之前"那一屏(套件里常常是日历页),
* 于是后面找「背景」时读的是**日历页的文字表** ⇒ 假红。
* 单独跑时之所以绿,是因为我手动摆现场时前台**恰好**已经在「我的」页 ——
* 也就是说:判据通过与否取决于跑之前那一屏是什么,而不是取决于代码对不对。
* (这正是"设备判据必须自己搭现场"的另一个理由。)
*/
let meRoot = null;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
const snap = D.dumpLayout(hdc);
if (inMePane(snap)) { landed = true; meRoot = snap; break; }
}
assert.ok(landed && meRoot !== null,
'点完之后应真的**在「我的」页**(要能看到「用户名」这类只属于该页的字段)—— ' +
'只断言"点到了坐标"证明不了页面切过去了');
/* 「我的」页里找背景开关:WebUI 那边叫「背景」,鸿蒙侧同名 */
const texts0 = [...D.walk(meRoot)]
.map((n) => (n.attributes?.text || '').trim())
.filter(Boolean);
assert.ok(texts0.some((x) => x.includes('背景') || x.includes('壁纸')),
'「我的」页里要能找到背景/壁纸那一节(这是本判据的定位锚点)——' +
`实际读到:${texts0.slice(0, 40).join(' | ')}`);
/*
* 记下"壁纸层是否存在"的判据:`MainPage.WallpaperLayer()` 在有壁纸时会挂
* 一个铺满屏幕的图片/渐变层。它在 dump 里表现为一个**铺满全屏的 Image 或
* 带渐变背景的 Column**,且 `MainPage` 的 `bgActive` 为真时页面底的
* 背景色会变成 Transparent。
*
* ★ 判据断的是**结构**(有铺满全屏的壁纸容器)而不是像素颜色 ——
* 后者要与主题/图片内容耦合,会在换壁纸时假红。
*/
/*
* ★★ 判定"壁纸层在不在"的**正确形状**(我前两版都写错了):
*
* 第一版:找"铺满屏幕的 Image/Stack" ⇒ **恒为 true**(外壳本身就有铺满的结构)。
* 第二版:找"全屏 Column 的渐变属性" ⇒ dump 里根本没有渐变字段(不报)。
*
* 有效的信号是**几何**:壁纸开启时 `bgActive=true` ⇒ 各内容面板的底色变
* `Transparent`,于是**面板与屏幕边缘之间的缝隙**会露出壁纸;关闭时那里是
* 面板自己的不透明底色。
*
* 实测(密度 2.875、屏 3184px):
* 预设档:侧栏区 `(60,1200)` = **#F4F5F7**、缝隙 `(215,1200)` = **#E0E2E4**
* 不设档:两处都是 **#FFFFFF**(纯白)
*
* 判据读的是 `dumpLayout` 里对应节点的 `backgroundColor` —— 比截图比像素稳
* (不依赖具体预设色的取值,只看"是不是纯白/是否透明让位")。
*/
const panelSlotColor = (root) => {
const all = [...D.walk(root)];
let screenW = 0;
let screenH = 0;
for (const n of all) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
screenW = Math.max(screenW, Number(m[1]));
screenH = Math.max(screenH, Number(m[2]));
}
}
if (screenW === 0) return null;
/*
* 找侧栏(左 1/6 内、纵贯大部分屏幕)与内容面板(在侧栏右侧、宽度很大),
* 取它们各自最外层容器的背景色。
*/
let sidebarBg = null;
let contentBg = null;
for (const n of all) {
const a = n.attributes || {};
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m || typeof a.backgroundColor !== 'string') continue;
const [, x1, y1, x2, y2] = m.map(Number);
const w = x2 - x1;
const h = y2 - y1;
if (h < screenH * 0.8) continue; // 要纵贯屏幕的容器
if (x2 <= screenW * 0.08 && sidebarBg === null) sidebarBg = a.backgroundColor;
if (x1 > screenW * 0.07 && x1 < screenW * 0.2 && w > screenW * 0.5 && contentBg === null) {
contentBg = a.backgroundColor;
}
}
return { sidebarBg, contentBg };
};
/**
* 壁纸开着 ⇔ **档位选中态不是"不设"**。
*
* ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事):
* ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有)
* ② 找"面板底色是否透明" ⇒ 恒 false(ArkUI 报的实色与我的正则对不上)
* 两版都没真正问出"壁纸开了吗"。
*
* 而这个问题**有一个确定答案**:段式选择器的选中态。
* 实测:选中 = `#FF2563EB`(品牌蓝)、未选中 = `#FFF1F3F5`(浅灰)。
* `bg_kind` 与选中态是一一对应的(同一个 @State),所以问它就等于问数据库。
*
* ★ 这一版为什么不脆:它读的是**控件自己的状态**,不依赖渲染管线的实现细节
* (材质/透明色如何上报、渐变节点是否出现在 dump 里)。
*/
const SELECTED_BG = '#FF2563EB';
const isSelected = (root, label) => chipState(root, label) === SELECTED_BG;
const chipState = (root, label) => {
const node = [...D.walk(root)].find((n) => {
const a = n.attributes || {};
return (a.text || '').trim() === label && typeof a.backgroundColor === 'string';
});
return node ? (node.attributes.backgroundColor || '').toUpperCase() : null;
};
const currentKind = (root) => {
for (const [label, kind] of [['不设', 'none'], ['预设', 'preset'], ['自定义图片', 'image']]) {
if (isSelected(root, label)) return kind;
}
return null;
};
const before = currentKind(meRoot) !== 'none';
/*
* 切换背景开关。**幂等性处理**:开关当前是什么状态未知(上一次跑可能留下了),
* 所以先读当前状态、只在需要时点它 —— 盲目点会把"已开"点成"关"。
* 判据断的是**切换后状态真的变了**,不是"点了就成功"。
*/
/*
* ★ 开关的形态是**三选一的段式选择**:「不设 / 预设 / 自定义图片」——
* 不是 `Toggle`(我第一版按 Toggle 找,于是**永远 skip**)。
* 「背景」本身只是那一节的**标签文本**(`clickable=false`),点它没用。
*
* 这里用「切换到一个与当前不同的档位」的方式做幂等切换:
* 当前是「不设」就点「预设」,否则点「不设」。
* 这样跑第二遍时方向相反,但结论一致(判据必须幂等)。
*/
const clickableText = (root, want) => [...D.walk(root)].find((n) => {
const a = n.attributes || {};
return a.clickable === 'true' && (a.text || '').trim() === want;
});
/*
* ★★ 第二版修正(第一版又错了,这次是**判"当前档"的方式错**):
*
* 我第一版用「能不能找到『不设』那个按钮」来判"当前是不设档" ——
* 但**三个按钮永远都在**(它是段式选择器,不是"点了才出现")。
* 于是它每次都去点「预设」,而壁纸本来就开着 ⇒ 切换前后无变化 ⇒ 假红。
*
* 正确判法是读**选中态**。实测(dump 的属性):
* 选中 `backgroundColor = #FF2563EB`(品牌蓝)
* 未选中 `backgroundColor = #FFF1F3F5`(浅灰)
* 这与 WebUI 的段式选择器同形(选中项是品牌底)。
*/
/*
* ★★ 第三版(前两版都被同一个毛病坑了:判据的形状与它声称的不是一回事):
* ① 找"铺满屏幕的 Image/Stack" ⇒ 恒 true(外壳本来就有)
* ② 找"面板底色是否透明" ⇒ 恒 false(dump 里的色值与我的正则对不上)
* 两版都没真正问出"壁纸开了吗"。
*
* 而这个问题**有一个确定答案**:段式选择器的选中态(`chipState`,下面定义)。
* 选中的 `bg_kind` 与 `@State` 一一对应 ⇒ 问它就等于问状态。
* 它读的是**控件自己的状态**,不依赖渲染管线细节(材质/透明色如何上报、
* 渐变节点是否出现在 dump 里)—— 这正是前两版脆的原因。
*/
const isNone = isSelected(meRoot, '不设');
const targetLabel = isNone ? '预设' : '不设';
const sw = clickableText(meRoot, targetLabel);
if (!sw) {
return t.skip(`找不到背景档位「${targetLabel}」(可能在图片档或文案变了)—— 本次不跑,但不假装通过`);
}
const c = D.boundsCenter(sw.attributes?.bounds);
assert.ok(c, '背景档位按钮要有可解析的边界');
assert.ok(D.tap(hdc, c.cx, c.cy), `要能点到背景档位「${targetLabel}」`);
await new Promise((r) => setTimeout(r, 2500)); // 切换 + 推服务端 + 重绘
const after = currentKind(D.dumpLayout(hdc)) !== 'none';
/*
* ★ 断"**状态变了**"而不是断"变成 true":开关初始状态取决于是上一次跑留下的,
* 而判据必须**幂等**(跑两遍结论相同)。所以两种切换方向都算通过,
* 真正要防的是"点了却什么都没发生"(那才是接线断了)。
*/
assert.notEqual(after, before,
'★ 切换背景开关后,壁纸层的存在性必须发生变化 —— ' +
`切换前=${before}、切换后=${after}。两者相同说明**开关点了没反应**:` +
'要么开关没接上状态,要么壁纸层没跟着重绘(这正是静态判据看不到的那一层)。');
/* 复位:点回**原来那一档**(不是"再点一次当前那个"——档位是单选,再点不变) */
const backLabel = isNone ? '不设' : '预设';
const backBtn = clickableText(D.dumpLayout(hdc), backLabel);
assert.ok(backBtn, `复位时还能找到「${backLabel}」`);
const bc = D.boundsCenter(backBtn.attributes.bounds);
assert.ok(D.tap(hdc, bc.cx, bc.cy), '要能点回去');
await new Promise((r) => setTimeout(r, 2000));
assert.equal(currentKind(D.dumpLayout(hdc)) !== 'none', before,
'复位后应回到判据开始时的状态(判据必须幂等,不留副作用)');
});
test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值曾被顶出屏外)', async (t) => {
/*
* ★★ 这条来自一个追了很久的真 bug(2026-09-19)。
*
* 症状:用户/我反复看到「我的」页压暗滑杆旁边印着 `56.000000`。
* 真相有两层,两层都值得记下来:
*
* ① `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
* —— 屏幕上根本没有这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
* ② 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,
* 而屏幕宽 3184 ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。
*
* 根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,而内容列写的是
* `.width('100%')` —— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp
* **相加** ⇒ 必然溢出 60vp。修法:内容列改 `.layoutWeight(1)`(吃剩余空间)。
*
* 为什么值得一条判据:**同一处错误在不同 pane 上表现不同**(日历页自己算宽度,
* 就没露出来),所以很容易被当成"某一页的样式问题"去调,而不是回到布局本身。
* 这条判据检查的是**所有窗格**,任何一个新 pane 犯了同样的错都会红。
*/
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用');
/*
* 逐个窗格检查。宽屏下「我的」用侧栏头像进,窄屏用底栏 —— 这里只跑宽屏那份
* (窄屏的几何不同,若要覆盖应单独一条,不要在一个判据里塞两种形态)。
*/
const root0 = D.dumpLayout(hdc);
const screenOf = (root) => {
let w = 0;
let h = 0;
for (const n of D.walk(root)) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const { w: scrW, h: scrH } = screenOf(root0);
/*
* 越界判定:右边缘超出屏宽、或左边缘为负。
* **排除系统组件**(状态栏那一条 `Stack` 实测 x2=3230 > 3184,那是系统的,
* 不归我们管)—— 按"是否属于我们应用的组件树"排除不现实,改用
* "越界幅度 + 节点类型":系统状态栏整条横贯顶部(y 很小且高度小)。
*/
const overflows = (root) => {
const bad = [];
for (const n of D.walk(root)) {
const a = n.attributes || {};
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) continue;
const [, x1, y1, x2, y2] = m.map(Number);
if (y1 < scrH * 0.05 && (y2 - y1) < scrH * 0.06) continue; // 状态栏,跳过
if (x1 < -2) bad.push(`${a.type} 左越界 x1=${x1}`);
else if (x2 > scrW + 2) bad.push(`${a.type} 右越界 x2=${x2}(屏宽 ${scrW})`);
}
return bad;
};
assert.deepEqual(overflows(root0), [],
`★ 「通信」窗格有内容超出屏幕 —— 超出 = 用户看不见(不是"偏了一点")`);
/* 进「我的」窗格(宽屏走侧栏头像;找不到就跳过,不假装通过) */
const avatar = [...D.walk(root0)].find((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
});
if (!avatar) {
return t.skip('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑');
}
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像进「我的」');
await new Promise((r) => setTimeout(r, 2500));
const rootMe = D.dumpLayout(hdc);
assert.deepEqual(overflows(rootMe), [],
'★ 「我的」窗格有内容超出屏幕 —— 这正是那个 `56%` 被顶出屏外的形状。' +
'常见原因:内容列写了 `width(\'100%\')` 而不是 `layoutWeight(1)`(与侧栏相加必溢出)');
});

View File

@ -332,3 +332,44 @@ export function swipe(hdc, x1, y1, x2, y2, velocity = 5000) {
String(x1), String(y1), String(x2), String(y2), String(v)], 20000);
return (r.stdout || '').includes('No Error');
}
/** 我们的应用是否在**前台且已就绪**(有可点的东西)。 */
export function ourAppInFront(hdc) {
return Boolean(hdc) && hasTarget(hdc) && foregroundBundle(hdc) === ourBundle();
}
/**
* 拉起我们的应用(`aa start`),然后等它真的到前台。
*
* **这是写操作** —— 它会切走当前前台应用。只在"本判据需要自己的应用在前台"
* 时调用,并且调用方要能接受"别人的界面被切走"。
*
* ★ 等待要**轮询**(前台 BundleName 变成我们的),不能睡固定时长:
* 冷启动在不同负载下能从 3 秒到 15 秒不等,写死一个数必然在忙时假红
* (手势判据那条就是被固定 2500ms 坑过 —— 单跑通过、接进套件失败)。
*/
export async function launchOurApp(hdc, { settle = 400, tries = 40 } = {}) {
if (!hdc) return false;
sh(hdc, ['shell', 'aa', 'start', '-a', 'EntryAbility', '-b', ourBundle()], 20000);
for (let i = 0; i < tries; i++) {
await new Promise((r) => setTimeout(r, settle));
if (foregroundBundle(hdc) === ourBundle()) return true;
}
return false;
}
/**
* 点一个**文本完全匹配**的节点,返回是否点到。
*
* 比裸 `tap(hdc,x,y)` 好在:坐标从当前 dump 里算,不写死 pixel。
* (写死坐标的判据在密度/折叠态变化时静默点到别处 —— 本仓吃过这类亏。)
*/
export function tapText(hdc, text) {
if (!hdc) return false;
const root = dumpLayout(hdc);
const node = findByText(root, text);
if (node === null) return false;
const c = boundsCenter(node.attributes?.bounds);
if (c === null) return false;
return tap(hdc, c.cx, c.cy);
}

View File

@ -81,7 +81,7 @@ const SUITE = [
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
['test/harmony-system-api.test.mjs', [], 5],
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 25],
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 18],
// 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半:
@ -90,7 +90,7 @@ const SUITE = [
// 只做后半 ⇒ 黑边照旧。**两半缺哪一半都要判红**,所以变异自检两个方向都跑过。
['test/harmony-window.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9],
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
['test/appearance-defaults.test.mjs', [], 4],
['test/appearance-defaults.test.mjs', [], 7],
['test/build-stamp.test.mjs', [], 7],
['test/packaging.test.mjs', [], 5],
['test/align-refs.test.mjs', [], 3],

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));
}
}
/**