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

View File

@ -32,6 +32,12 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { ApiClient } from '../api/ApiClient';
import { AppearanceApi } from '../api/AppearanceApi';
import { Theme } from '../common/Theme';
/*
* ★ 2026-09-29 新增:官方 `Chip`(用户:「尽量使用官方组件而不是自绘」)。
* 背景档与预设两排原来是自绘 `Text`,且与设置页主题那排**圆角/内边距各不相同**
* (预设那排是 `borderRadius(4)`,另两处是 `radiusControl`)—— 见下方注释。
*/
import { Chip, AccessibilitySelectedType, LengthMetrics } from '@kit.ArkUI';
import { PRESET_IDS, presetLabel, normalizePreset } from '../model/Wallpaper';
import {
CompressPlan,
@ -81,8 +87,16 @@ export struct BackgroundPicker {
@Link bgDim: number;
/** 模糊 */
@Link bgBlur: number;
/** 服务端状态文案(只读透传:组件不解释它) */
@Prop statusText: string = '';
/*
* ★ 2026-09-29 **删掉 `@Prop statusText`**(用户:「一个数两个指示器」的同类问题)。
*
* 它原来是「外观」卡右上角那个「已同步」的**第二份**:页面把同一个
* `statusLabel(this.appearanceStatus)` 既画在卡片级标题行、又透传进来
* 画在「背景」小标题右边 ⇒ 同一屏两个「已同步」,永远同值。
*
* 同步状态描述的是**外观整体**(主题 + 背景 + 浓度 + 模糊一起推的),
* 不属于"背景"这一段,所以它留在页面卡片级那一处,组件不再复述。
*/
/**
* **用户**改了背景四值 ⇒ 通知页面推服务端。
*
@ -257,37 +271,87 @@ export struct BackgroundPicker {
build() {
Column() {
Row() {
Text('背景').fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1)
Text(this.statusText).fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
}
.width('100%')
/*
* 小标题降到 11px muted(原来是 `fontBody` 14px 不加粗):
* 它与父级「外观」的 14 Bold 几乎同重,父子分不开(设备截图可见)。
* 与设置页 `SectionTitle()` 同一套量级 —— 全仓"小标题"只此一种样子。
*/
Text('背景').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
.width('100%').margin({ bottom: 8 })
Row() {
/*
* ★★ 2026-09-29 改:**自绘 chip → 官方 `Chip`**(用户:「尽量使用官方组件而不是自绘」)。
*
* ── 改前的「杂乱」到底在哪 ──
* 同一个屏幕上三处自绘 chip,**圆角与内边距互不相同**:
* · 这里(背景档) `radiusControl` + padding 12/6
* · 预设那排 `borderRadius(4)` + padding 8/4 ← 圆角只有上面一半
* · 设置页主题那排 `radiusControl` + padding 12/6
* 设备 dump 也量得出来:背景档 chip 高 95px、预设 chip 高 73px。
* 同类控件三种长相,谁也不比谁"对"—— 这就是用户说的「杂乱」。
*
* ── 改后 ──
* 全部走官方 `Chip`,尺寸/圆角/内边距由组件按系统规范给,
* 三处从此**同一套外观**。选中态用 `activated` + `activatedFontColor`,
* 读屏软件按单选播报(`AccessibilitySelectedType.SELECTED` ——
* 枚举成员叫 SELECTED 而不是 RADIO,见设置页那处的说明)。
*
* ★ 说清楚这次改的**只是形状**,不是配色:原来的三元
* `.fontColor(选中 ? Theme.accentFg : Theme.textMuted)` 是**对的**
* (选中态用的就是不翻转的 `accentFg`,没犯 2026-09-19 那个
* "面色当墨水"的错)。`activatedFontColor: Theme.accentFg` 是把它
* 原样搬到官方组件的参数上,语义不变 —— 别把这次改写成"修了对比度"。
* 未选中态的字色由 `textMuted` 提到 `textPrimary`:官方 Chip 的
* 未选中底是 `surfaceMuted`(比原来那张更实),配 `textMuted` 偏灰。
*/
Row({ space: 8 }) {
ForEach(KIND_OPTIONS, (opt: KindOption) => {
Text(opt.label)
.fontSize(Theme.fontSmall)
.fontColor(this.bgKind === opt.value ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.bgKind === opt.value ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => { this.bgKind = opt.value; this.emitUserChange(); })
Chip({
label: {
text: opt.label,
fontSize: Theme.fontSmall,
fontColor: Theme.textPrimary,
activatedFontColor: Theme.accentFg
},
activated: this.bgKind === opt.value,
backgroundColor: Theme.surfaceMuted,
activatedBackgroundColor: Theme.accent,
borderRadius: Theme.radiusControl,
/*
* ★ `allowClose: false` 必需(官方默认 **true**)—— 不关掉
* 每个 chip 右边会长一个 ✕ 删除图标。详见设置页那处的长注释。
*/
allowClose: false,
accessibilitySelectedType: AccessibilitySelectedType.SELECTED,
onClicked: () => { this.bgKind = opt.value; this.emitUserChange(); }
})
}, (opt: KindOption) => opt.value)
}
.width('100%').margin({ top: 10 })
if (this.bgKind === 'preset') {
Flex({ wrap: FlexWrap.Wrap }) {
/*
* `Flex` 的间距走 `space: { cross: LengthMetrics.vp(6) }` ——
* 同样是因为 `Chip()` 返回 void、链不了 `.margin()`(见上面那段)。
* 原来这里是给每个 `Text` 手写 `.margin({ right: 6, top: 6 })`。
*/
Flex({ wrap: FlexWrap.Wrap, space: { cross: LengthMetrics.vp(6) } }) {
ForEach(PRESET_IDS, (pid: string) => {
Text(presetLabel(pid))
.fontSize(Theme.fontTiny)
.fontColor(this.bgPresetId === pid ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.bgPresetId === pid ? Theme.accent : Theme.surfaceMuted)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.margin({ right: 6, top: 6 })
.onClick(() => { this.bgPresetId = normalizePreset(pid); this.emitUserChange(); })
Chip({
label: {
text: presetLabel(pid),
fontSize: Theme.fontTiny,
fontColor: Theme.textPrimary,
activatedFontColor: Theme.accentFg
},
activated: this.bgPresetId === pid,
backgroundColor: Theme.surfaceMuted,
activatedBackgroundColor: Theme.accent,
borderRadius: Theme.radiusControl,
allowClose: false,
accessibilitySelectedType: AccessibilitySelectedType.SELECTED,
onClicked: () => { this.bgPresetId = normalizePreset(pid); this.emitUserChange(); }
})
}, (pid: string) => pid)
}
.width('100%').margin({ top: 8 })

View File

@ -304,7 +304,9 @@ export struct GlassCard {
.backgroundColor(this.active ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.active ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(this.radius)
.border({ width: this.hairline ? 1 : 0, color: Theme.border })
/* ★ 2026-09-28:轮廓走 `Theme.hairline`(系统玻璃材质轮廓色),
与 GlassCardModifier 同一档 —— 见那处注释的实测数据。 */
.border({ width: this.hairline ? 1 : 0, color: Theme.hairline })
.clip(true)
}
}
@ -468,8 +470,32 @@ export class GlassCardModifier implements AttributeModifier<CommonAttribute> {
* `backgroundColor`)。WebUI 的卡片正是这两件 —— 它没有投影仍然读得出是"一块卡"。
* 投影留给**真正悬浮在内容之上**的层(底部导航条),那里它才有信息意义。
*/
/* 发丝描边:用系统分隔线色(深浅由系统给),不是手写 alpha */
instance.border({ width: 1, color: Theme.border });
/*
* 发丝描边:**卡片轮廓专用**色(`Theme.hairline`,系统
* `glass_material_outline_primary`)。
*
* ★★ 2026-09-28 修(用户:「没有 webui 精致美观…十分杂乱」)。
* 原来这里用 `Theme.border`(`ohos_id_color_list_separator`)——
* 那是**行间分隔线**的色,职责是"分开两行同底色文本",所以必须够重;
* 而卡片轮廓压在壁纸/异色上,只需要"一条若有若无的边"。
*
* 设备实测(模拟器 1256×2760,同一张列表头卡 y=440):
* 改前(list_separator) rgb(204,204,204) → 亮度 202
* 改后(本令牌) 实测亮度 236
* WebUI `--hairline-fg` 压白 = rgb(238,239,240)
* ⇒ 原来比 WebUI **深约 34 级(约 3 倍)**。
*
* 一屏十几张卡各描一圈重边,就是"杂乱"里**边框**那一半的来源。
* 白纱与 alpha 本来就是对的(framework 布局转储实测 `#C7FFFFFF` = 0.78,
* 与 WebUI `--glass-card-wall-a` 逐位一致)——**只有描边这一件用错了档位**。
*
* ★ 为什么是系统色:我第一版照 WebUI 自己写了两个半透明值,判据当场报红
* 三条,逼出一个更好的答案 —— SDK 的 `id_defined.json` 里有
* `glass_material_outline_primary`("玻璃材质轮廓"),正是这一档,
* 深浅两套值由系统给。**不自己造系统已有的值**,这是"用官方件而非自绘"
* 在**令牌层**的对应物。完整推理见 `Theme.hairline` 的注释。
*/
instance.border({ width: 1, color: Theme.hairline });
} else {
/* 壁纸关着时不铺任何材质(退了才不浪费一次模糊) */
instance.backgroundBlurStyle(BlurStyle.NONE);

View File

@ -107,6 +107,57 @@ export class Theme {
static readonly textSubtleLight: string = '#6B7280'; // WebUI gray-400,压白底 4.83:1
static readonly textSubtleDark: string = '#8A92A1'; // WebUI .dark gray-400,压深卡片 5.51:1
/*
* 发丝描边(**卡片轮廓**专用)—— **走系统材质轮廓色**,不自己写。
*
* ★★ 2026-09-28 新增(用户:「鸿蒙 app 样式…没有 webui 精致美观」)。
*
* ── 病因(**实测**,不是推断)──
* 卡片轮廓此前一律用 `Theme.border`
* (`sys.color.ohos_id_color_list_separator`)—— 那是**列表行之间**的
* 分隔线色,不是"卡片轮廓"色。两者职责不同,而 list_separator 明显更重:
*
* 设备实测(模拟器 1256×2760 横剖,同一张列表头卡 y=440):
* 改前(list_separator) = rgb(204,204,204) → 亮度 202
* 改后(本令牌) = 实测亮度 236
* WebUI `--hairline-fg` = rgb(15 23 42 / 0.07) 压白 = rgb(238,239,240)
* ⇒ 原来比 WebUI **深约 34 级(约 3 倍)**,一屏十几张卡就是
* 十几道重描边 —— 这是"杂乱"里**边框**那一半的来源。
*
* 注意:玻璃卡的三件套"白纱 + 发丝边 + 层次"里,**白纱与 alpha 本来就是对的**
* (framework 布局转储实测容器 `#C7FFFFFF` = 0.78,与 WebUI
* `--glass-card-wall-a: 0.78` 逐位一致)—— 只有描边这一件用错了档位。
*
* ── 为什么是系统色,而不是自己写 ----
*
* 第一版我照 WebUI 写了 `hairlineLight = #120F172A` / `hairlineDark = #17FFFFFF`
* (逐字对齐 `--hairline-fg` 的两个值)。**判据当场报红三条**:
* · `A2` —— 新写死的色没登记;
* · `B` —— 8 位半透明色属于"手写玻璃/手写遮罩",只允许 glassCard 那两档;
* · 遮罩那条 —— 同一原因。
*
* B 条那条注释把问题问得很准:「加第三个半透明色 ⇒ 先回答
* **系统为什么没有对应物**」。我照做了,去 SDK 的 `id_defined.json`
* (7826 条系统资源,唯一权威表)里搜,**找到了对应物**:
*
* glass_material_outline_primary ← 「玻璃材质轮廓」(主)
* glass_material_outline_secondary ← (次)
*
* 这正是"玻璃卡的边"这一档,系统连深浅两套值一起给 —— 所以**不该自己写**。
* 这也正是用户那句「尽量使用官方组件而不是自绘」在**令牌层**的对应物:
* 自绘的边界不是"画了个控件",而是"造了一个系统已有的值"。
*
* ⇒ `Theme.hairline` 是**系统色**,深浅由系统给,不登记 `SELF_OWNED_COLORS`
* (登记册管的是"自己写的色",这个是系统色)。
*
* ★ 与 `Theme.border` 的分工(**不是替换关系**,两者都留着):
* · `Theme.border` = 行间分隔线(Divider、列表行之间、页签条下沿)
* —— 需要它够重,才能分开同底色的两行;
* · `Theme.hairline` = 卡片/面板**轮廓** —— 压在壁纸或异色上,
* 只需要"一条若有若无的边"。
*/
static readonly hairline: Resource = $r('sys.color.glass_material_outline_primary');
/*
* ──────── 三级文字在**深色**下的可读性下限(2026-09-19 加)────────
*

View File

@ -497,12 +497,26 @@ export struct ContactsTab {
ListItem() {
Column() {
Row() {
/*
* ★ 2026-09-28:未读 6px 圆点(与收件箱组头同一做法,
* 对齐 WebUI `w-1.5 h-1.5 rounded-full`);
* 时间 10 → 11px(10px 与同列表的 11/14 不成体系)。
* 原来只有**加粗**这一条未读线索,在密集列表里扫不出来。
*/
if (m.status === 'unread') {
Text('')
.width(6).height(6).borderRadius(3)
.backgroundColor(Theme.accent)
.margin({ right: 6 })
}
Text(m.from_name.length > 0 ? m.from_name : '—')
.fontSize(12).fontColor(Theme.textPrimary)
.fontSize(12)
.fontWeight(m.status === 'unread' ? FontWeight.Bold : FontWeight.Normal)
.fontColor(Theme.textPrimary)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Blank()
Text(compactMailTime(m.created_at))
.fontSize(10).fontColor(Theme.textSubtleFor())
.fontSize(11).fontColor(Theme.textSubtleFor())
}
.width('100%')
Text(m.subject)
@ -520,7 +534,8 @@ export struct ContactsTab {
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.attributeModifier(GlassCardModifier.of(this.bgActive))
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
/* ★ 2026-09-28:描边只在未读时给(同收件箱组头,见该处注释)。 */
.border({ width: 1, color: m.status === 'unread' ? Theme.accentEdgeFor() : Color.Transparent })
}
.width('100%')
.attributeModifier(PressEffectModifier.of())