跨端: 抽出基础面/玻璃组件(Motion + Surface)+ 三处硬弹的弹层补过渡

用户两条要求,各自都指向"决策被复制到各处"这个病根:
  ① 「应该定义一个基础玻璃容器给各个组件引用」
  ② 「很多页面还是不够精致……封装为基础组件供所有页面使用。基础组件包含动画」

★ 玻璃不透明的真根因(前几轮都没找到,这次是像素证据定的)
  实测模拟器 3184×2232、壁纸 aurora + 压暗 37:
      Navigation [229,140,3156,2204]   ← 255,255,255(整块内容区)
      y=2210(Navigation 之外那条缝)    ← 226,225,235(壁纸清楚可见)
  那条缝是决定性的:**壁纸层本身是好的**,白是因为上面盖了不透明的壳。
  链路上一共三层,逐层修:
    · `Navigation` 外壳:不设背景 ⇒ 系统默认不透明白,把里面已透明的窗格整片盖住;
    · navBar 内容层(列表栏根容器):同样没有背景;
    · **卡片**:铺的是 `Theme.surface`(实体系统色)。
  修后:缝隙 203,213,228 / 卡片 191,204,220 —— 壁纸透得出来。

★ 新增基础组件(common/)
  · `Surface.ets`  —— `GlassPane`(正文面)/ `GlassCard`(嵌套面)/ `PageHeader` / `Pressable`
  · `Motion.ets`   —— `Motion.dur()`:接系统「减弱动态效果」
    (`accessibility.isAnimationReduceEnabledSync()`,API 23 正好够用)。
    WebUI 侧有 `prefers-reduced-motion` 硬约束(index.css:1266),
    鸿蒙这边**改造前一次都没调过** —— 系统里关了动画,我们照样动。这是无障碍义务。
  · `Theme.cardMaterial` —— 卡片材质令牌。不是拍脑袋选的:
    `COMPONENT_THIN` 实测卡片 252,254,254(吃掉 94% 壁纸,就是用户看到的"白卡");
    `BACKGROUND_THIN` → 185,190,201,壁纸透得出来且文字对比度仍够。

★ 判定形状按"玻璃从例外变成默认"重写(判据 C 条)
  原来是"允许出现玻璃的位置"白名单,逐处登记。那个形状在"玻璃是少数例外"时成立,
  但用户要的是**全部玻璃化** ⇒ 白名单退化成"把每处抄一遍",且挡不住新写的裸枚举。
  改成**规则**:材质档次只能来自 `Theme.navMaterial` / `Theme.cardMaterial`
  (或 `BlurStyle.NONE`),页面里出现裸 `BlurStyle.XXX` 即红;
  辅助方法(`this.materialOf()`)体内也查,否则等于开了后门。两条变异都验证咬得住。

★ 判据 C 条自身的两个 bug(都被本次触发)
  · 嵌套判定**没比文件**:`c.at`/`o.at` 是各文件自己的字符下标,直接比大小
    于是判出"MainPage 内含 SettingsPage 的玻璃"这种物理上不可能的红。
    假红比漏报更坏 —— 会让人去改本来对的代码(本次差一点)。
  · 参数抽取用 `[^)]*`:`this.materialOf()` 里含一层 `()`,在内层括号处截断,
    把合法写法读成 `this.materialOf(` 判红。改成配对括号抽取。

★ 出现/消失动画:用户点名的三处硬弹
  这些挂在 `if (cond)` 上,条件一变整块出现/消失,原来一帧过渡都没有 ——
  而代码上"看着像挂了动画"(外层页面有 transition),所以最容易漏。
  · `MainPage` 账号选择器下拉 → `menuIn()`(从上往下落的浮层档)
  · `AdminUsersPage` 新建用户表单 → `paneRiseIn()`(就地展开档)
    ★ 过渡必须挂在**调用点的 Column**:`@Builder` 返回 void,链不上修饰符。
      第一版写进了 `CreateForm()` 内部,是新判据抓出来的。
  · `SettingsPage` 新增账号弹层 → `paneRiseIn()`(与回复/转发弹层同一挂法)
  新增判据「出现/消失的那类元素真的挂了过渡」:**枚举所有条件挂载的浮层/展开块**
  (不是数数 —— 数数挡不住"新增第四处又漏了"),变异验证过。

★ 顺手修的两处真嵌套玻璃
  `SettingsPage` 的账号列表与密钥列表都是"卡里面的一段列表",两层都铺材质 =
  WebUI 用 `.bg-white .bg-white { backdrop-filter: none }` 明确禁止的嵌套
  (alpha 相乘 0.82×0.82=0.97 把壁纸吃光)。去掉内层材质,玻璃只留一层。

★ 判据放宽一处(不是放水)
  `harmony-contacts` 那句写死 `pushUrl`,判的是"tryRestore 有没有跳转",
  与用哪个原语无关。修 LoginPage 返回栈时改成 `replaceUrl` 后它误报了;
  改成 `(pushUrl|replaceUrl)`,原语该用哪个由 `harmony-nav` 那条专门判。

判据:files=32 ran=32 checks=518 pass=518 fail=0 red=0;
baseline 7/7✓(第 6 次重算,两个文件逐个 `git diff --quiet HEAD` 取证为有意编辑)。
This commit is contained in:
2026-09-20 07:37:16 +08:00
parent a7896a7e3a
commit 5103e0aee3
11 changed files with 740 additions and 46 deletions

View File

@ -347,13 +347,25 @@ test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与
* ① **不许嵌套**(模糊叠模糊,视觉上互相打架、性能也白花);
* ② 每一处玻璃都要**登记**(新开一处玻璃面必须显式过一道,而不是悄悄多出来)。
*/
const GLASS_REGISTRY = [
// 文件(相对 ets 根) + 组件/Builder 名:为什么这里可以有一层系统材质
{ file: 'pages/MainPage.ets', provider: 'NavBar', why: 'P5 悬浮玻璃条:浮在**会滚动的内容**之上(pi 给的放行条件②),壁纸层整个不吃材质' },
// ★ 2026-09-16:宽屏 app-shell 复刻 WebUI —— 面板走系统材质(半透明由 BlurStyle 给,不手写 alpha)
{ file: 'pages/WideSidebar.ets', provider: 'SidebarItem', why: '宽屏侧栏(判据按最近的 @Builder 命名):浮在壁纸之上、背后是会滚动的导航内容(WebUI Sidebar 同形状),bgActive 才开。★ 2026-09-19 改名:原先登记的是 NavItemBuilder,但那是我上一版编造的“复刻”(参见 Theme.ets 里那段更正)—— 重写后按 WebUI 拆成导航轨 + 底部一簇,@Builder 相应改名,登记同步跟上(判据就是为此存在的)' }
];
/*
* ★★ 2026-09-19 重写(用户:「应该定义一个基础玻璃容器给各个组件引用」)。
*
* 这张名单**原来**是"允许出现玻璃的位置"白名单,逐处登记、附一句理由。
* 那个形状在"玻璃是少数例外"时成立(当时全仓只有导航条一处)。
* 但用户的要求是**全部玻璃化** —— 玻璃成了**默认**,于是白名单变成了
* "把每一处都抄一遍"的负担,而且它挡不住真正该挡的东西:
* 一个人**新写** `.backgroundBlurStyle(BlurStyle.COMPONENT_THICK)` 直接就过了,
* 因为白名单只检查"登记过的位置还在不在",不检查"材质档次从哪来"。
*
* 换成**规则**(这才是"基础组件"该有的防线):
* ① 材质档次**只能**来自 `Theme` 的两个令牌(`navMaterial` / `cardMaterial`),
* 不许在页面里直接出现 `BlurStyle.XXX` —— 否则深浅两套浓度就没人统一管了;
* ② `BlurStyle.NONE` 是**开关的另一半**(壁纸关时退回实体面),不在此限;
* ③ 仍不许嵌套(上一段那两条断言)。
*
* 「枚举挡实例,类才挡漂移」—— 这条从枚举位置改成了枚举**来源**。
*/
const MATERIAL_TOKENS = ['Theme.navMaterial', 'Theme.cardMaterial'];
/**
* 往回找"这次材质作用在哪个组件块上",返回块尾 `}` 的下标(找不到返回 -1)。
*
@ -421,6 +433,8 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
const owner = [...src.slice(0, at).matchAll(/(?:struct|@Builder\s+)\s*(\w+)/g)].pop();
found.push({
key: `${f.slice(etsRoot.length + 1)}#${owner ? owner[1] : '(未识别)'}`,
/* 所属文件:嵌套/叠用判定必须先比它(见下面那段注释) */
file: f.slice(etsRoot.length + 1),
at,
block: span ? { start: span[0], end: span[1] } : null
});
@ -439,6 +453,21 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
for (const c of found) {
for (const o of found) {
if (o === c) continue;
/*
* ★★ 2026-09-19 修:先比**文件**,再比偏移。
*
* `c.at` / `o.at` 是**各文件自己的字符下标**,之间没有可比性。
* 原来缺了这一层,于是 `MainPage.ets` 的偏移被拿去和 `SettingsPage.ets`
* 的偏移比大小 —— 判出 `MainPage#GroupHeader 内含 SettingsPage#SettingsPane 的玻璃`
* 这种**物理上不可能**的结论(一个文件不可能内含另一个文件的块)。
*
* 这一条正是本仓反复出现的形状:**判据报出来了、但它声称的东西是假的**。
* 那种假红比漏报更坏 —— 会让人去改本来对的代码(本次差一点)。
*
* WebUI 用后代选择器天然只在**同一棵 DOM** 里成立;鸿蒙没有那个机制,
* 所以文件边界就是它的等价物。
*/
if (c.file !== o.file) continue;
if (c.block && o.block && c.block.start === o.block.start && c.block.end === o.block.end) {
const pair = [c.at, o.at].sort((a, b) => a - b).join('-');
doubled.add(`${c.key}@${pair}`);
@ -450,17 +479,65 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
assert.deepEqual([...doubled], [], `同一个组件上不该叠多层模糊:${[...doubled].join('、')}`);
assert.deepEqual([...nested], [], `玻璃不该套在另一层玻璃的子树里:${[...nested].join('、')}`);
// 每一处都要登记;名单里也不能有已经不存在的位置(否则名单会烂成化石)
const keys = found.map(c => c.key);
const registered = GLASS_REGISTRY.map(g => `${g.file}#${g.provider}`);
const unregistered = keys.filter(k => !registered.includes(k));
assert.deepEqual(unregistered, [],
`这些位置开了玻璃但没登记:${unregistered.join('、')} —— ` +
'要开新的玻璃面就在 GLASS_REGISTRY 里登记(附一句为什么),否则该用普通系统背景色');
const stale = registered.filter(k => !keys.includes(k));
assert.deepEqual(stale, [], `名单里这些位置已经没有玻璃了(名单要跟着改):${stale.join('、')}`);
for (const g of GLASS_REGISTRY) {
assert.ok(g.why && g.why.length >= 8, `GLASS_REGISTRY 里 ${g.file}#${g.provider} 要写一句为什么可以在这里开玻璃`);
/*
* 材质来源检查:每个 `backgroundBlurStyle(...)` 的实参要么是设计令牌,
* 要么是 `BlurStyle.NONE`("+关闭"的那一半)。
*/
const rawMaterial = [];
for (const f of collectEts(etsRoot)) {
const src = code(f);
const rel = f.slice(etsRoot.length + 1);
/*
* 参数用"配对括号"抽取,不能用 `[^)]*` ——
* `this.materialOf()` 里含一层 `()`,`[^)]*` 会在内层 `(` 处截断,
* 于是把合法写法读成 `this.materialOf(` 并判红(本次就是这么被误伤了一次)。
*/
for (const m of src.matchAll(/\.backgroundBlurStyle\(/g)) {
let i = m.index + m[0].length, d = 1, j = i;
while (j < src.length && d > 0) {
if (src[j] === '(') d++;
else if (src[j] === ')') d--;
j++;
}
const arg = src.slice(i, j - 1).trim();
/*
* 三种合法形态:
* · 直接引令牌(`Theme.cardMaterial`);
* · `BlurStyle.NONE`("关闭"的那一半);
* · 调**基础组件内部**的取值方法(`this.materialOf()`)—— 那是设计系统
* 把"选哪档"收敛到一处的手段,本身就该允许;但要求该方法体内
* 只出现令牌与 `NONE`(下面单独校验),否则等于借方法绕开这条判据。
*/
const isHelper = /^this\.\w+\(\)$/.test(arg);
const ok = MATERIAL_TOKENS.some(t => arg.includes(t)) || arg === 'BlurStyle.NONE' || isHelper;
if (!ok) rawMaterial.push(`${rel}:${src.slice(0, m.index).split('\n').length} → ${arg}`);
}
}
assert.deepEqual(rawMaterial, [],
'这些地方直接写了 `BlurStyle.XXX`,没走设计令牌:' + rawMaterial.join(' | ') +
' —— 材质档次必须来自 Theme(navMaterial / cardMaterial):' +
'深浅两套浓度由令牌统一管,写在页面里就等于没人管(本仓为此重写过一次)');
/*
* 辅助方法也要查:`this.materialOf()` 这种形态若不查它体内,
* 就等于判据开了个后门 —— 把裸 `BlurStyle.COMPONENT_THICK` 塞进方法里照样过。
* 这正是本仓反复出现的形状:**判据给了放行口,放行口本身没人管**。
*/
for (const f of collectEts(etsRoot)) {
const src = code(f);
const rel = f.slice(etsRoot.length + 1);
for (const m of src.matchAll(/(\w+)\s*\(\s*\)\s*:\s*BlurStyle\s*\{([\s\S]*?)\n \}/g)) {
const body = m[2];
for (const b of body.matchAll(/BlurStyle\.([A-Z_]+)/g)) {
const v = b[1];
if (v === 'NONE') continue;
const line = src.slice(0, m.index).split('\n').length +
body.slice(0, b.index).split('\n').length - 1;
assert.fail(
`${rel}:${line} 的 ${m[1]}() 里直接写了 BlurStyle.${v} —— ` +
'返回材质的方法体内只许引令牌或 NONE;写裸枚举等于绕开"材质来自设计令牌"这条');
}
}
}
// 导航条那一处必须真的还在(形状判定之外,位置本身也要在)

View File

@ -156,7 +156,15 @@ test('★ 用已存 token 恢复登录也必须**真的进主界面**(不能
const rest = src.slice(start);
const end = rest.indexOf('\n }\n');
const body = rest.slice(0, end > 0 ? end : 2000);
assert.match(body, /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
/*
* ★ 2026-09-19 放宽:`pushUrl` → `(pushUrl|replaceUrl)`。
*
* 本条判的是**"有没有跳转"**,不是"用哪个原语"。原先写死 `pushUrl`,
* 于是在修 LoginPage 的返回栈 bug 时(三处 `pushUrl` → `replaceUrl`,
* 见 `harmony-nav` 那条"登录/退出必须用同一个原语")它误报了。
* 原语该用哪个由 harmony-nav 那条专门判;这里只管"跳了没有"。
*/
assert.match(body, /(?:pushUrl|replaceUrl)\(\s*\{\s*url:\s*'pages\/MainPage'/,
'tryRestore 成功后必须跳转主界面 —— 它原来只设 loggedIn=true 就结束了');
/*
* 还要登记账号 + 建 SSE:少了它们,"恢复登录"进主界面是个瘸的状态
@ -170,6 +178,7 @@ test('★ 用已存 token 恢复登录也必须**真的进主界面**(不能
const bStart = broken.indexOf('async tryRestore(');
const bRest = broken.slice(bStart);
const bEnd = bRest.indexOf('\n }\n');
assert.doesNotMatch(bRest.slice(0, bEnd > 0 ? bEnd : 2000), /pushUrl\(\s*\{\s*url:\s*'pages\/MainPage'/,
assert.doesNotMatch(bRest.slice(0, bEnd > 0 ? bEnd : 2000),
/(?:pushUrl|replaceUrl)\(\s*\{\s*url:\s*'pages\/MainPage'/,
'自检:原形状(只有 loggedIn=true)必须判红');
});

View File

@ -539,6 +539,52 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵
'切窗格要开动画窗口:`getUIContext().animateTo(...)`');
});
test('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不能硬弹)', () => {
/*
* 用户 2026-09-19:「元素的出现消失动画呢?」
*
* 这条补的是上一条判据**判不到的那一类**:上一条管"切窗格"(整棵子树换掉),
* 而**弹层/下拉/就地展开**是另一回事 —— 它们挂在 `if (cond)` 上,
* 条件一变就整块出现/消失。这类位置最容易漏,因为:
*
* · 代码上"看着像是挂了动画"(外面的页面有 transition),
* 但那个 transition 作用在**别的地方**;
* · 漏掉时没有任何静态信号 —— 页面照样能跑、判据全绿,只是硬弹。
*
* 实测漏掉的三处(本次修的):
* · `MainPage` 账号选择器下拉(点一下,列表凭空冒出来)
* · `AdminUsersPage` 新建用户表单(就地展开)
* · `SettingsPage` 新增账号弹层(底部弹上来)
*
* ★ 判据的形状:**枚举所有"会被条件挂载的浮层/展开块",要求每个都有 transition**。
* 不是数数("至少 N 处"),因为数数挡不住"新增第四处又漏了"。
* 名单显式列出,加了新的浮层就要一起改 —— 与 GLASS_REGISTRY 同一套纪律。
*/
const targets = [
{ file: 'pages/MainPage.ets', needle: 'if (this.showAccountPicker && this.accountList.length > 1) {', why: '账号选择器下拉' },
{ file: 'pages/SettingsPage.ets', needle: 'if (this.showAddDialog) {', why: '新增账号弹层' },
{ file: 'pages/AdminUsersPage.ets', needle: 'if (this.showCreate) {', why: '新建用户表单' },
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showReplyBox) {', why: '回复弹层' },
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showForwardBox) {', why: '转发弹层' }
];
const missing = [];
for (const t of targets) {
const src = code(join(HARMONY_ETS, t.file));
const at = src.indexOf(t.needle);
assert.ok(at >= 0, `${t.file} 里找不到「${t.why}」的挂载条件(${t.needle})—— 改名字要一起改判据`);
/*
* 从挂载点往后扫到这一块的收尾(缩进回到同级),在这段里找 `.transition(`。
* 扫 4000 字符足够:这些都是同一个 build 里的邻近修饰符链。
*/
const window = src.slice(at, at + 4000);
assert.match(window, /\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/,
`${t.file} 的「${t.why}」没有挂过渡 —— 它会硬弹出来。` +
'就地展开用 `paneRiseIn()`,浮层/下拉用 `menuIn()`(取值出处见 Theme 的注释)');
if (!/\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/.test(window)) missing.push(t.why);
}
assert.deepEqual(missing, [], `这些位置会硬弹:${missing.join('、')}`);
});
test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS 登记的放行条件)', () => {
/*
* pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次;

View File

@ -3,6 +3,19 @@
#
# 取基线是**有意的动作**,不是随手重算 —— 重算会把"某次变异没还原"永久掩盖掉。
# 每次重算都要在此记一行"为什么":
# 2026-09-19 21:0x dsh:baseline 重算(第 6 次)—— 用户要求「基础玻璃容器 + 补出现/消失动画」。
# ① `pages/AdminUsersPage.ets`:有意编辑 —— 「新建用户」表单补 `paneRiseIn()` 过渡
# (原来挂着 `if (this.showCreate)` 却硬弹;用户:「元素的出现消失动画呢?」)。
# 过渡挂在**调用点的 Column** 上,不是 `CreateForm()` 内部:`@Builder` 调用返回 void,
# 链不上修饰符(第一版就写错在内部,是新判据抓出来的)。
# ② `pages/SettingsPage.ets`:有意编辑 —— 所有卡片面 `Theme.surface` →
# `bgActive ? Transparent : surface` + `Theme.cardMaterial`(玻璃卡);
# 并**去掉**两处内层容器的材质(账号列表 / 密钥列表)——
# 那两处是"卡里面的一段列表",两层都铺材质 = 嵌套玻璃(判据 C 条会红)。
# ③ 两个都逐个跑过 `git diff --quiet HEAD -- <f>` ⇒ 非空(是"我改的");
# 另 grep 确认无裸 `BlurStyle.XXX` 残留、无变异留下的空 transition。
# ★ 本次判据侧也改了:C 条从"逐处登记白名单"改成"材质必须来自设计令牌"
# (玻璃从少数例外变成**默认**,白名单挡不住新写的裸枚举)。
# 2026-09-15 11:22 dsh:`AdminUsersPage.ets` 的哈希变了,**不是变异残留**。
# 该文件被**另一个会话/进程**改过(11:20:48,我 11:21 的提交之后):
# `Chip(text, bg: string, fg: string)` → `Chip(text, bg: ResourceColor, fg: ResourceColor)`。
@ -75,8 +88,8 @@
# 下次再手滑批量替换会直接判红,不必再靠事后复核。
4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts
c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts
7cdaa94bb0b4473aea652d50e5fe1b7b43c60b00076d0d3ac2cc49a3226f0aa5 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
dce63e1b847fb0656afcac0ed034f3d084859ce8246f3051cbc24a1101baa545 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
73c66c7045f579c3eb8b8e0aa07803e7c9363b7ae8375972b251633d3ce969be client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
b53872c62acdeb88803df79d48ee94cca0810715b26e6cbb336e526c00b528b8 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
073c646af27496aa21d56d0f0c1a3e328c5a474e5f5406c24d6368ad61eb9cd2 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

@ -83,7 +83,7 @@ const SUITE = [
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 19],
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 20],
// 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半:
// `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。
// 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天);