feat(harmony)★★: 背景档与预设改用官方 Chip,删掉重复的同步指示器

承接 2026-09-29 的反馈,本批改动是那一轮工作的收尾。

## 1. 自绘 Text → 官方 `Chip`

背景档与预设两排原来是**自绘 `Text`**,而且与设置页主题那排
**圆角/内边距不一致**(预设那排 `borderRadius(4)`,另两处是
`radiusControl`)。改用 `import { Chip, AccessibilitySelectedType,
LengthMetrics } from '@kit.ArkUI'`。

## 2. 删掉 `@Prop statusText`(一个数两个指示器)

它原来是「外观」卡右上角那个「已同步」的**第二份**:页面把同一个
`statusLabel(this.appearanceStatus)` 既画在卡片级标题行、又透传进来
画在「背景」小标题右边 ⇒ 同一屏两个「已同步」,永远同值。

删掉透传这一份,值只从页面进、只画一处。

## 3. Surface.ets(32 行)/ Theme.ets(51 行)/ ContactsTab.ets(21 行)

配套:Chip 的尺寸档、主题色取用、联系人页的调用点。

## 判据

- `test/harmony-appearance.test.mjs`:外观判据随实现更新
- `test/mutants/baseline.sha`:变异体自证底本同步

### ⚠️ 设备端判据本机跑不了(不是代码问题)

    node --test test/harmony-appearance.test.mjs
      pass 26 / fail 3

失败的 3 条**全部**是设备端断言:

    ★ 设备:打开背景开关后,壁纸上真的出现了
      AssertionError: 要能拉起我们的应用并等到它到前台
    ★ 设备:窗格内容不得超出屏幕
      AssertionError: 要能拉起应用
    ★ 设备:切换主题后界面真的整体换色了
      AssertionError: 设备连续 10 轮拉不起应用(本条已连续 3 轮没真跑)

即**本机没有接 Harmony 设备**,不是断言挂在代码上。
判据文本本身断言「设备连续 10 轮拉不起」——它自己在如实报告
「这条从未在真机上跑过」。**需要真机补验**,这里不假装它通过了。

## docs

API.md / GUI-PLAN-HARMONY.md / PLAN.md 同步上述改动。
This commit is contained in:
2026-10-05 09:26:30 +08:00
parent a8d6811cd7
commit 8dc714cb62
9 changed files with 442 additions and 59 deletions

View File

@ -1656,3 +1656,186 @@ test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值
'★ 「我的」窗格有内容超出屏幕 —— 这正是那个 `56%` 被顶出屏外的形状。' +
'常见原因:内容列写了 `width(\'100%\')` 而不是 `layoutWeight(1)`(与侧栏相加必溢出)');
});
/*
* ═══════════════ ★★ 结算「运行期换肤要设备」这一条欠账 ═══════════════
*
* `STATIC_ONLY` 里挂着本文件,理由是「壁纸/令牌/遮罩渲染:**观感与运行期换肤**要设备」。
* 壁纸那一半本文件早就有设备判据了(上一条「打开背景开关后壁纸上真的出现了」)。
* **还欠的是"运行期换肤"** —— 而它在静态层完全看不见:
*
* 静态层能证:"`onConfigurationUpdated` 里调了 `applyAppearance()`"
* "色板两套、深色那套与 CSS 的 `.dark` 逐个相等"
* 静态层**证不了**:切了主题之后,**界面真的整体换色了吗**。
* ⇒ 这正是本文件自己写的那句话:「观感未验」不是"机制没写",而是"机制写了没跑"。
*
* ★ 为什么选「我的」页的 chip,而不是侧栏那个主题按钮:
*
* 侧栏底部主题按钮(36×28vp)与**退出登录**(也是 36×28vp)在 dumpLayout 里
* **逐属性相同**:`type=Stack clickable=true`,子树里也是一个 34×34 的空 `Stack`,
* 两者都读不到图标名(`moon`/`sun` 不进 dump)。
* 设备实测(屏 2800×1840):
* 头像 [42,1514][127,1599] 85×85 r=1.000
* 主题 [47,1608][124,1668] 77×60 r=1.283 ← 要点的就是这个
* 退出 [47,1676][124,1736] 77×60 r=1.283 ← 形状完全一样
* ⇒ 区分它们只能靠**兄弟次序**,而点错的后果是**把真机登出**、
* `performLogout` → `AccountManager.clearAll()` 清空账号(本文件注释里记过
* 这个事故:09:21 设备真的被登出过,`accounts_json` 变 `[]`)。
*
* ⇒ **判据的失败方式不该是破坏性的**:这条改走「我的」页的三选一 chip
* (跟随系统 / 浅色 / 深色),**按文案认**,无歧义、点错也只是改个主题。
* ("点哪个"能认准,比"点哪个能出效果"更要紧 —— 认不准的判据是一次会动手的赌。)
*/
test('★ 设备:切换主题后界面**真的**整体换色了(不是只换了个选中态)', async (t) => {
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑(静态层仍把住色板与接线)');
}
const CRIT = 'harmony-appearance/运行期换肤';
/*
* 锁屏 ⇒ 走"忙"那条路(K 轮内跳过、连续超 K 轮变红),不硬红。
* 理由同 `appearance-defaults` 那条:设备锁着是**只有人能解**的环境状态,
* 拿它红一个判据是假红;而不设界又会变成永久灰区。两头都要 ⇒ 记账设界。
*/
if (!await D.launchOurApp(hdc)) {
const { streak, over } = D.noteBusySkip(CRIT);
if (over) {
assert.fail(`设备连续 ${streak} 轮拉不起应用(本条已连续 ${D.busyLimit()} 轮没真跑)。` +
` 先分清是**锁屏**(aa start 报 10106102,要人上滑解锁)还是真坏了。`);
}
return t.skip(`这次拉不起应用(设备多半锁着)—— 本次不跑(连续第 ${streak}/${D.busyLimit()} 轮)`);
}
D.noteRan(CRIT);
assert.ok(await D.backToMain(hdc), '要能回到主界面(上一条判据可能停在子页上)');
const screenOf = (root) => {
let w = 0, 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 };
};
/* 进「我的」:宽屏点侧栏头像(按**形状**认,r≤1.15,见本文件另一处那段注释)。 */
const root0 = D.dumpLayout(hdc);
const scr = screenOf(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 [, x1, y1, x2, y2] = m.map(Number);
const w = x2 - x1, h = y2 - y1;
return x2 <= scr.w * 0.08 && y1 > scr.h * 0.5 && w > 80 && h > 80 && w / h <= 1.15;
});
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, 2000));
/*
* 滑到主题那一节。`SettingsPane` 是**常驻挂载**的 ⇒ Scroll 保留上次位置
* (本文件另一条判据已经为此吃过亏,注释就在上面),所以**先回顶再下扫**。
*/
const chipOf = (root, label) => [...D.walk(root)].find((n) => {
const a = n.attributes || {};
return a.clickable === 'true' && (a.text || '').trim() === label;
});
const cx = Math.round(scr.w / 2), topY = Math.round(scr.h * 0.25), botY = Math.round(scr.h * 0.75);
for (let i = 0; i < 5; i++) { // 回顶
D.swipe(hdc, cx, topY, cx, botY);
await new Promise((r) => setTimeout(r, 250));
}
let root = D.dumpLayout(hdc);
for (let i = 0; i < 8 && !chipOf(root, '深色'); i++) { // 逐屏下扫
D.swipe(hdc, cx, botY, cx, topY);
await new Promise((r) => setTimeout(r, 350));
root = D.dumpLayout(hdc);
}
const darkChip = chipOf(root, '深色');
const lightChip = chipOf(root, '浅色');
assert.ok(darkChip && lightChip,
'「我的」页里要能找到「浅色」「深色」两个主题 chip(按**文案**认)—— ' +
'找不到说明这一节没了或文案变了,主题那条路就没法在设备上验了');
assert.ok(chipOf(root, '跟随系统'), '主题档位应当是三选一(跟随系统 / 浅色 / 深色)');
/*
* ★ 判据读的是**整屏页面底色**,不是选中态。
*
* 实测(屏 2800×1840,密度 3.5):满屏那个 `Column`(bounds `[0,83][2800,1840]`)
* 浅色 → `#FFFFFFFF`(纯白) 深色 → `#FF000000`
* 它是**同一个节点、同一处几何**下的两个值 ⇒ 这才是"整体换色了";
* 读 chip 的 `backgroundColor` 只能证明"选中态挪了",证不了界面跟着换了。
*/
const pageBgOf = (r) => {
const s = screenOf(r);
/* 满屏(宽高都贴到屏幕边)、且**不透明**的那个容器 */
const cands = [...D.walk(r)].filter((n) => {
const a = n.attributes || {};
const bg = (a.backgroundColor || '');
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m || !bg || bg === '#00000000') return false;
const [, x1, y1, x2, y2] = m.map(Number);
const w = x2 - x1, h = y2 - y1;
return w >= s.w * 0.95 && h >= s.h * 0.9 && a.type === 'Column';
});
if (!cands.length) return null;
/* 面积最大者;并列时取第一个(同一处几何,稳定) */
cands.sort((a, b) => {
const area = (n) => {
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(n.attributes.bounds || '').map(Number);
return (m[2] - m[0]) * (m[3] - m[1]);
};
return area(b) - area(a);
});
return (cands[0].attributes.backgroundColor || '').toUpperCase();
};
const beforeBg = pageBgOf(root);
assert.ok(beforeBg,
'要能从 dumpLayout 里读出整屏页面底色(读不到说明这一版的判据形状不对)');
/* 当前选的是哪一档 ⇒ 点一个**确定不是当前档**的,才能断言"状态变了" */
const selectedNow = ['跟随系统', '浅色', '深色']
.find((label) => {
const c = chipOf(root, label);
return c && (c.attributes.backgroundColor || '').toUpperCase() === '#FF2563EB';
});
const target = selectedNow === '深色' ? '浅色' : '深色';
const t2 = chipOf(root, target);
const tc = D.boundsCenter(t2.attributes.bounds);
assert.ok(D.tap(hdc, tc.cx, tc.cy), `要能点到「${target}」`);
/* 换色要等一轮重渲染(setColorMode + applyAppearance),并轮询到色真的变 */
let afterBg = null;
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 400));
afterBg = pageBgOf(D.dumpLayout(hdc));
if (afterBg && afterBg !== beforeBg) break;
}
assert.notEqual(afterBg, beforeBg,
`★ 切到「${target}」后整屏页面底色必须变(切换前 ${beforeBg}、切换后 ${afterBg})——\n` +
` 相同意味着**主题只换了选中态、界面没换色**,那正是"观感未验"的那一层:\n` +
` 静态判据能证 \`applyAppearance()\` 被调用,却证不了它真的让界面变了。\n` +
` 查 \`applyThemeNow\` 的顺序(算 → 发布 → 应用)与 \`setColorMode\` 有没有落到应用上。`);
/* 复位:点回**原来那一档**(档是单选,"再点当前那个"不会变)。判据必须幂等。 */
const backLabel = selectedNow || '跟随系统';
const backChip = chipOf(D.dumpLayout(hdc), backLabel);
assert.ok(backChip, `复位时还能找到「${backLabel}」`);
const bc = D.boundsCenter(backChip.attributes.bounds);
assert.ok(D.tap(hdc, bc.cx, bc.cy), '要点得回去');
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 400));
if (pageBgOf(D.dumpLayout(hdc)) === beforeBg) break;
}
assert.equal(pageBgOf(D.dumpLayout(hdc)), beforeBg,
'复位后应回到判据开始时的页面底色(判据必须幂等、不留副作用)');
D.noteBehavioralRan(CRIT);
});

View File

@ -168,10 +168,40 @@
# 这两个文件**不在** jobs.manifest.json 里(已实测),而底本覆盖的是**清单内**
# 被变异过的文件 => 它们不会被变异,哈希变了只可能是**有意编辑**。
# 若哪天它们被加进变异清单,本条推理作废,须重新核对有无变异残留。
#
# 2026-09-29 baseline 重算(第 12 次)—— 用户:「设置界面更是重灾区」「尽量使用官方组件而不是自绘」。
# ① 两个文件漂移:`common/BackgroundPicker.ets`、`pages/SettingsPage.ets`。
# ② 取证(照本文件那条纪律逐条做,**这次比上次更硬**):
# · `sha256sum -c baseline.sha` → 恰好这 2 个 FAILED(其余 OK)
# · `git diff --quiet HEAD -- <f>` → **非空** ⇒ 工作树有改动
# · 并且**逐文件比对三方哈希**,证明漂移**只**来自工作树:
# 底本 == HEAD(两者逐字节相同),工作树 != 两者
# ⇒ 既然底本与 HEAD 完全相同,"哈希对不上"就**只可能**是我的改动,
# 排除了"底本过期"与"变异残留"两种混淆。命令:
# base=$(grep " $f\$" baseline.sha | awk '{print $1}')
# [ "$base" = "$(git show HEAD:$f | sha256sum | awk '{print $1}')" ] && echo SAME
# · 再逐块读过 `git diff`:只有 chip 迁移/文案/布局,**无半截替换、无占位**。
# ③ ★ 顺带更正第 11 次(2026-09-28)留在这里的一句**过期推理**:
# 那条说「这两个文件**不在** jobs.manifest.json 里」—— **现在不成立了**。
# `SettingsPage.ets` 被 `jobs/jobs-arkts.json`、`jobs-arkts2.json`、`jobs-all.json`
# 引用,`BackgroundPicker.ets` 被 `jobs1.json`、`jobs2.json`、`jobs-all.json` 引用。
# ⇒ 它们是**会被变异**的。所以"不在清单⇒必是有意编辑"这条捷径**不适用于本文件**,
# 本次改用上面那条更强的"底本==HEAD"证明。
# ★ 这正是那句"若哪天它们被加进变异清单,本条推理作废"的**兑现**:
# 推理的失效前提真的发生了,且是被**主动核对**发现的,不是它自己报警的。
# ④ 本次有意编辑的内容(用户:「设置界面更是重灾区」):
# · 「吹销」→「吊销」错别字(3 处:注释/错误文案/界面按钮)
# · 密钥行:`token_hint` 定宽 + 备注 `maxLines(1)` 截断 —— 修"长备注把行高撑成 2 倍"
# (设备 dump 实测 90px vs 45px)
# · 去掉重复的「已同步」:`BackgroundPicker` 不再收 `@Prop statusText`,
# 状态只留页面卡片级一处(同一屏原先印两遍)
# · 补「主题」子段标题 + 子段标题统一 11px muted(原来"背景"是 14px,与父级同重)
# · 自绘 chip → 官方 `Chip`(主题/背景档/预设三处统一;`allowClose: false` 去掉默认 ✕)
# ⑤ 重算后 `sha256sum -c baseline.sha` 应全 OK。
4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts
c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts
7f05d4efd12ac686f6600befd2c07d52567550d5e43d0dbc81d22e43594b6919 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
a41ffefd6c4ba383075ccfcc8c80d8cb1fadb2cb03177601d81061a03f6c2b6e client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
015ae779192bd63e4137e155b48703fe172be5f002d0a415596bb31102da92a6 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
38e59b35c57dc0aeb29adb7fd31191892a86051b9b971b2fb11fa28c599a10b9 client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
4faccade481d5958e5e0ab9a5362dae1eb864c8880b151aa73171d07338cd48c client/harmony/entry/src/main/ets/pages/SettingsPage.ets
f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets
6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets