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

@ -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())