跨端: 抽出基础面/玻璃组件(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`(让开时钟/手势条)。
// 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天);

View File

@ -0,0 +1,60 @@
/**
* 动效开关 —— **全仓唯一**决定"动不动"的地方。
*
* ── 为什么要收成一个地方(用户 2026-09-19:「基础组件包含动画」)──
*
* WebUI 侧有一条硬约束(`client/electron/src/index.css:1266`):
*
* @media (prefers-reduced-motion: reduce) {
* .rise-in, .animate-menu-in, … { animation: none !important; }
* }
*
* 它上面的注释写着:「尊重系统设置:关了动画就一点都不动(前庭功能敏感的人
* 会被位移动画伤到)」。这是**无障碍义务**,不是观感偏好。
*
* 鸿蒙侧对应能力是 `accessibility.isAnimationReduceEnabledSync()`
* (`@ohos.accessibility.d.ts`,`@since 23`;本项目 `compatibleSdkVersion 6.1.0(23)`
* 正好够用)。改造前**一次都没调过** —— 系统里关了动画,我们这套照样动。
*
* ★ 为什么不是"在每处动画前写 if":那种写法一定会有人漏(本仓的老毛病)。
* 收成一个 `dur()` 入口,漏掉的人写不出"忘了判断"的代码 ——
* 他必须显式地**绕过**这个函数才可能漏,而绕过是看得见的。
*
* ★ 缓存:这是系统查询,不能每个组件每次 `build()` 都问一遍。
* 代价是运行中改系统设置不会立刻生效 —— 可接受,因为改这个设置的人
* 本来就期望重启一次;而每帧同步调系统 API 的开销是实打实的。
*/
import { accessibility } from '@kit.AccessibilityKit';
export class Motion {
private static cached: boolean | undefined = undefined;
/**
* 系统是否要求「减弱动态效果」。
*
* 读不到时**按 '不减弱' 处理** —— 不能因为查询失败就让整个界面静默不动,
* 那等于把一次 API 异常放大成"应用看起来坏了"。
*/
static reduced(): boolean {
if (Motion.cached === undefined) {
try {
Motion.cached = accessibility.isAnimationReduceEnabledSync();
} catch (e) {
Motion.cached = false;
}
}
return Motion.cached === true;
}
/**
* 把"想要的时长"按系统开关折一下。
*
* ★ 返回 `0` 而**不是**"跳过整个动画块":ArkUI 里 `animateTo` 传 0ms
* 等于状态直接跳到位,语义与 CSS `animation: none` 一致
* (**终态相同、只是没有中间帧**),而代码里不必到处写
* `if (Motion.reduced())` 的分支 —— 那种写法一定会有人漏一半。
*/
static dur(want: number): number {
return Motion.reduced() ? 0 : want;
}
}

View File

@ -0,0 +1,365 @@
/**
* 基础「面」组件 —— 全仓**唯一**决定"一块背景长什么样"的地方。
*
* ── 为什么要有这个文件(2026-09-19 用户的架构判断)──
*
* 用户原话:「组件没有实现全部玻璃化,应该定义一个基础玻璃容器给各个组件引用」
* 「很多页面还是不够精致……我的建议是封装为基础组件供所有页面使用。
* 基础组件包含动画」。
*
* 盘过现状,那两句话指向同一个病根:**"面"的决策被复制到了 13 个地方**。
* 改造前全仓的"透还是不透"是这样写的(散在各页,逐字重复):
*
* .backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
*
* 每加一个页面就要重新想一遍"这里要不要透",而**想漏一次就是一个 bug**
* —— 平板上的壁纸被整片挡住,根因正是宽屏分支漏抄了 `bgActive`(提交 7421ad7)。
* 圆角/内边距也跟着各写各的,同一个"卡片"在四个页面里有四种写法 ⇒ 用户说的"不精致"。
*
* ── 规格来源:WebUI 的玻璃**只有一层模糊**(读出来的,不是猜的)──
*
* `client/electron/src/index.css` 里 `backdrop-filter: blur(...)` 只出现 **2 次**
* (`.glass-control` 8px / `.narrow-nav` 18px),显式 `backdrop-filter: none` 有 **6 处**,
* 包括这一条:
*
* html[data-bg='on'] .bg-white .bg-white { /* 嵌套面板 */
* background-color: rgb(var(--glass-base) / var(--bg-glass-inner));
* backdrop-filter: none; /* ★ 不叠第二层模糊 */
* }
*
* 那条规则上面的注释就是规格原文:
*
* 原则是「玻璃只该出现一次」:最外层负责遮罩与模糊,
* 里面几层只留一点点色调(保住正文对比度)。
*
* 所以本文件把"面"分两档,而不是给每处一个开关:
*
* · `Pane`(正文面)—— 最外层,**只有这一档吃系统材质**(`BlurStyle`),壁纸透过它。
* · `Card`(嵌套面)—— 面板**里面**的卡片,**不吃材质**,只留色调。
* 硬编码"不吃"而不留给调用方决定,正是 WebUI 用 `.bg-white .bg-white` 强制的同一件事:
* 嵌套玻璃不是"少一层好看",而是 `0.82 × 0.82 = 0.97` 把壁纸彻底吃掉。
*/
import { Theme } from './Theme';
import { AmIcon } from './Icons';
import { Motion } from './Motion';
/**
* 「面」的层级。**只有两档,不是调色板** ——
* 多一档就多一个"该用哪个"的判断题,而 WebUI 的规格正好就是两档。
*/
export enum SurfaceDepth {
/** 正文面:最外层面板(列表窗格、内容窗格、整页的卡片容器)。背景开启时吃系统材质。 */
Pane = 0,
/** 嵌套面:面板**里面**的卡片、分组、条目。不吃材质。 */
Card = 1
}
/**
* 玻璃容器。页面里凡是"一块有底色的矩形"**都走这里**,
* 不要再各写 `.backgroundColor(...)`。
*
* 用法:
*
* GlassPane({ depth: SurfaceDepth.Pane, active: this.bgActive }) {
* // 内容
* }.padding(12) // ← 注意:GlassPane 带 @BuilderParam,链式修饰符合法
*/
@Component
export struct GlassPane {
/** 层级:正文面吃材质,嵌套面只留色调。默认正文面(最常用那一档)。 */
@Prop depth: number = SurfaceDepth.Pane;
/**
* 背景是否开启(= 壁纸已铺)。
*
* ★ 由调用方传 `bgActive` 而**不是**组件自己读 `AppStorage`:
* 全仓 13 处这个三元散在各页,就因为每处都得自己知道这个状态。
* 收进来后,"哪里该透"只剩这一个判断点,漏抄不再可能。
*/
@Prop active: boolean = false;
/**
* 圆角(vp)。负数 = 不设圆角(给整屏容器用)。
*
* ★ 用负数当"不设"而不是 `0`:`0` 是合法的"直角",
* 与"这层不该有圆角修饰符"是两件事。
*/
@Prop radius: number = Theme.glassRadius;
/**
* 是否裁切溢出内容。
*
* 默认 `true`(圆角必须配裁切才成立);但**要溢出悬挂元素的**容器
* (如浮起的主按钮、贴边徽标)必须显式传 `false`,否则挂件会被切掉。
*/
@Prop doClip: boolean = true;
/** 内容 */
@BuilderParam content: () => void = this.emptyContent;
/** `@BuilderParam` 的兜底:空面。 */
@Builder
emptyContent() {
}
/**
* 这一档该用什么背景色 —— **整个文件只有这一个函数在做这件事**。
*
* `Pane` 分两种情形:
* · 背景开启 ⇒ `Color.Transparent`:让 `backgroundBlurStyle` 的材质自己显形。
* 这里若再给不透明底色,材质就被盖住了(模糊了但看不见 —— 与平板那个
* "壁纸被挡住"的 bug 是同一个形状的错)。
* · 背景关闭 ⇒ 系统卡片底色。
*
* `Card` **永远**是实体色:它负责"只留色调",且绝不吃材质。
*/
private surfaceColor(): ResourceColor {
if (this.depth === SurfaceDepth.Pane) {
return this.active ? Color.Transparent : Theme.surface;
}
return Theme.surface;
}
/** `Pane` + 背景开启 ⇒ 系统材质;其余 ⇒ `NONE`(嵌套面不吃材质)。 */
private materialOf(): BlurStyle {
if (this.depth === SurfaceDepth.Pane && this.active) {
return Theme.navMaterial;
}
return BlurStyle.NONE;
}
build() {
Column() {
this.content()
}
.width('100%')
.backgroundColor(this.surfaceColor())
.backgroundBlurStyle(this.materialOf())
.borderRadius(this.radius >= 0 ? this.radius : 0)
.clip(this.doClip)
}
}
/**
* 页头 —— 返回按钮 + 标题。
*
* 改造前**四个页面各写了一个返回按钮**,而且是四种尺寸:
*
* AdminUsersPage:343 Text('‹').fontSize(24) .width(40).height(40)
* MainPage:1876 Text('‹').fontSize(24) .width(36).height(36)
* MailDetailPage:564 Text('‹').fontSize(24) .width(36).height(36)
* CalendarPage:1171 Button('‹')
*
* 三件事同时错:① 用**字符**当图标(本仓明令禁止 —— 字形随字体变、基线对不齐,
* 正是不精致的直接来源),而 `ICON_PATHS` 里**早就有** `chevronLeft`;
* ② 同一个东西四种尺寸;③ 触控区各不相同。收进这里以后只剩一个定义。
*/
@Component
export struct PageHeader {
/** 标题 */
@Prop title: string = '';
/** 左侧返回按钮是否显示(根页面不显示) */
@Prop showBack: boolean = true;
/** 返回动作 */
onBack: () => void = () => {};
build() {
Row() {
if (this.showBack) {
Row() {
AmIcon({ iconName: 'chevronLeft', iconSize: 20, iconColor: Theme.accentFor() })
}
.width(HeaderMetrics.HIT)
.height(HeaderMetrics.HIT)
.justifyContent(FlexAlign.Center)
.borderRadius(Theme.radiusControl)
.onClick(() => { this.onBack(); })
}
Text(this.title)
.fontSize(HeaderMetrics.TITLE_FONT)
.fontColor(Theme.textPrimary)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ left: HeaderMetrics.PAD_H, right: HeaderMetrics.PAD_H })
.alignItems(VerticalAlign.Center)
}
}
/**
* 页头几何。
*
* `HIT` 是**触控区下限**:WebUI 的返回按钮是 `p-1.5` + 16px 图标 ≈ 28px,
* 手机上偏小;取 36vp 与本仓既有的可点区下限同值
* —— `MainPage` 折叠头部那次修复正是 14px 的点按区会把手势条拉下来。
*/
class HeaderMetrics {
static readonly HIT: number = 36;
static readonly PAD_H: number = 12;
/** 与 WebUI `text-base font-semibold` 对应的标题字号 */
static readonly TITLE_FONT: number = 16;
}
/**
* 可点容器 —— 把「命中区」「按压反馈」收成一处。
*
* ── 为什么(用户 2026-09-19:「很多页面还是不够精致」)──
*
* 盘出来的两个硬事实:
*
* ① 全仓 **117 处 `onClick`,按压反馈 0 处**(`stateStyles` / `onTouch` 一次都没用)。
* 手指按下去界面上**什么都不动** —— 这是"不精致"最直接的来源:
* 用户无法确认"我点到了没有",只能等结果。
*
* ② WebUI 侧有一个专门的 `.tap` 类(`index.css:630`)干这件事,注释是规格原文:
*
* 「保证 44x44 的触摸命中区,但不改变视觉尺寸。移动端 44x44 是通行下限
* (Apple HIG 与 Material 都取这个数),而这些图标/小字按钮视觉上只有
* 15-24px 高 …… 改用居中的透明伪元素扩大命中区:视觉一像素不动,手指够得到。」
*
* ArkUI 没有伪元素,等价做法是把**命中区**与**视觉**分开:外层给足 `hit`,
* 内容自己按视觉尺寸排。所以本组件把 `hit` 收进来 —— 免得每个调用点
* 再各自拍一个 36/40/44。
*
* ★ 按压用 `animateTo` + 显式 `@State`,**不用 `stateStyles`**:
* `StateStyles` 的字段是 `any`(ArkTS 禁用),而且它**不做补间**
* (瞬切,观感就是"闪一下")。WebUI 那边是
* `transition: background-color var(--dur-fast)` = 120ms 补间,
* 要对齐就得走 `animateTo`。
*/
@Component
export struct Pressable {
/**
* 命中区边长(vp)。默认 `TapSpec.HIT`(44,跟 WebUI `.tap` 同值)。
*
* ★ 拿它当"最小边长"而不是"固定尺寸":调用方要更大的块(整行可点)
* 应传 `width: '100%'`,命中区仍然不小于这个数。
*/
@Prop hit: number = TapSpec.HIT;
/** 宽度;`-1` = 取 `hit` 的方形 */
@Prop boxWidth: number = -1;
/** 圆角;负数 = 不设 */
@Prop radius: number = TapSpec.RADIUS;
/** 按压时的底色。默认取系统"按压态"色。 */
@Prop pressColor: ResourceColor = Theme.surfaceMuted;
/** 常态底色;`Color.Transparent` = 不铺底 */
@Prop baseColor: ResourceColor = Color.Transparent;
/** 点击动作 */
onTap: () => void = () => {};
/** 内容 */
@BuilderParam content: () => void = this.emptyContent;
@Builder
emptyContent() {
}
/** 按压态:0 = 未按,1 = 按下。驱动 `animateTo` 的补间。 */
@State private pressT: number = 0;
build() {
Row() {
this.content()
}
.width(this.boxWidth >= 0 ? this.boxWidth : this.hit)
.height(this.hit)
.justifyContent(FlexAlign.Center)
.borderRadius(this.radius >= 0 ? this.radius : 0)
.backgroundColor(this.pressT > 0 ? this.pressColor : this.baseColor)
.onTouch((e: TouchEvent) => {
/*
* `TouchType.Down` → 进入按压态;`Up`/`Cancel` → 退出。
* ★ `Cancel` 必须一起处理:手势被父容器抢走(列表滚动)时不会来 `Up`,
* 只处理 `Up` 会让按钮**永远停在按下的样子**。
*/
const down = e.type === TouchType.Down;
this.getUIContext().animateTo({
duration: Motion.dur(Theme.durFast),
curve: Theme.easeOutSoft
}, () => {
this.pressT = down ? 1 : 0;
});
})
.onClick(() => { this.onTap(); })
}
}
/**
* 触摸目标的几何规格。
*
* `HIT = 44` 是**抄来的**、不是拍的:WebUI `.tap` 的注释写明
* 「移动端 44x44 是通行下限(Apple HIG 与 Material 都取这个数)」。
* 本仓原先各页写的是 36 / 40,两个都不够。
*/
class TapSpec {
static readonly HIT: number = 44;
static readonly RADIUS: number = 8;
}
/**
* 玻璃卡片 —— **列表里每一张卡**的那一层「面」。
*
* ── 为什么卡片要单独一档(设备像素证据)──
*
* 外壳修好后(`Navigation` + navBar 内容层改透明),实测像素仍然分两段:
*
* x=235–245 面板缝隙 231,236,244 ← 壁纸透出来了 ✓
* x=260–1121 **卡片** 255,255,255 ← 完全不透 ✗
* x=1130–1145 右侧缝隙 232,234,242 ← 壁纸透出来了 ✓
*
* 也就是说"玻璃不透明"的**最后一层**就是卡片本身:它铺的是
* `Theme.surface`(`sys.color.ohos_id_color_list_card_bg`),那是个**实体**色。
*
* WebUI 的规格(`index.css:1641` 与 `.glass-card`)把这件事说得非常明确:
*
* html[data-bg='on'] .comm-pane > .bg-white { background-color: transparent; }
* html[data-bg='on'] .glass-card {
* background-color: rgb(var(--glass-base) / var(--glass-card-wall-a)); /* 0.78 */
* border-color: rgb(var(--hairline-fg));
* }
*
* 注释原文:「面板退成透明,**每一项自己是一张玻璃卡**(圆角 + 半透明 +
* 细边框)…… 这样每项都是独立可点、可拖、可单独高亮的一块。」
*
* ── 为什么用系统材质而不是手写 alpha ──
*
* WebUI 那张卡是 `rgb(255 255 255 / 0.78)`,照抄到 ArkTS 就是 `#C7FFFFFF`
* —— 而 8 位半透明色是本仓**明令禁止**的(`cross-client-theme` 的 B 条:
* 「半透明属于系统材质/语义色的职责」),理由也成立:写死 alpha 等于
* 替系统猜深色该怎么办,而 WebUI 自己在深色下要把这个 alpha 从 0.78 改到 0.04。
*
* 所以这里走 `backgroundBlurStyle`:**同一份代码在深浅两套主题下由系统给出
* 正确的浓度**,这正是"用系统方案"要的东西。
*
* ★ 卡片这层**必须**吃材质,和 `Pane`(正文面)刚好相反 ——
* 在列表这个形状里,卡片才是"浮在壁纸上的那一层",而容器只是让位。
* WebUI 用后代选择器把这套关系写死(`.comm-pane > .bg-white` 透明、
* 再往下每项 `.glass-card`),本文件把同一件事表达成两个组件。
*/
@Component
export struct GlassCard {
/** 壁纸是否开启。关着时不铺材质(退回实体系统色,与原来观感一致) */
@Prop active: boolean = false;
/** 圆角(vp)。默认 WebUI `--radius-card` = 14 */
@Prop radius: number = Theme.glassRadius;
/** 是否画细边框(WebUI `.glass-card` 有 `1px solid var(--hairline-fg)`) */
@Prop hairline: boolean = true;
/** 内容 */
@BuilderParam content: () => void = this.emptyContent;
@Builder
emptyContent() {
}
build() {
Column() {
this.content()
}
.width('100%')
/* 透明底 + 材质:材质自己带色调,再铺一层实色就把材质盖住了 */
.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 })
.clip(true)
}
}

View File

@ -33,6 +33,7 @@
*/
import { curves } from '@kit.ArkUI';
import { Motion } from './Motion';
export class Theme {
// ─────────────── 表面:系统语义色 ───────────────
@ -176,6 +177,23 @@ export class Theme {
*/
static readonly navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK;
/**
* **卡片**的材质档次。
*
* 与 `navMaterial` 分开而不是共用:导航条是**厚重**的一层浮条(长期压住内容),
* 卡片是**轻薄**的一块列表项。两者同档,"每项都是一张卡"的层次感就没了
* —— WebUI 那边两者也确实是不同的 alpha(面板 0.88 / 卡 0.78,`index.css`)。
*
* 用 `BACKGROUND_THIN`(不是 `COMPONENT_THIN`):设备实测(模拟器 3184×2232、
* 壁纸 aurora + 压暗 37)卡片采样:
* · `COMPONENT_THIN` → `252,254,254`,而它两侧缝隙是 `203,213,228`
* ⇒ 卡片把壁纸吃掉了 94%,看着仍是"白卡",用户报的就是这个;
* · `BACKGROUND_THIN` → `185,190,201`,壁纸透得出来,且文字对比度仍够。
* `BACKGROUND_*` 系是"背景景深"材质,本来就为"垫在内容下面"设计,
* 与卡片这个用途正好对上。
*/
static readonly cardMaterial: BlurStyle = BlurStyle.BACKGROUND_THIN;
// ─────────────── 圆角:系统尺寸 ───────────────
/** 卡片圆角:系统"卡片"圆角(不再与 WebUI 的 14vp 绑死 —— 允许各自跟随系统) */
@ -701,8 +719,8 @@ export class Theme {
return TransitionEffect.asymmetric(
TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ y: Theme.riseInOffset })
).animation({ duration: Theme.durRise, curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Theme.durFast, curve: Theme.easeRise })
).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeRise })
);
}
@ -723,7 +741,7 @@ export class Theme {
TransitionEffect.translate({ y: -4 })
).combine(
TransitionEffect.scale({ x: 0.985, y: 0.985 })
).animation({ duration: Theme.durMenu, curve: Theme.easeRise });
).animation({ duration: Motion.dur(Theme.durMenu), curve: Theme.easeRise });
}
/**
@ -739,8 +757,8 @@ export class Theme {
return TransitionEffect.asymmetric(
TransitionEffect.opacity(0).combine(
TransitionEffect.translate({ x: forward ? '12%' : '-12%' })
).animation({ duration: Theme.durCal, curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Theme.durFast, curve: Theme.easeRise })
).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise }),
TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeRise })
);
}
}

View File

@ -312,7 +312,25 @@ struct AdminUsersPage {
.width('100%').padding({ left: 12, right: 12, top: 8 })
if (this.showCreate) {
this.CreateForm()
/*
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
*
* 这个表单挂在 `if (this.showCreate)` 上,原来硬弹出来。
* 它是**就地展开**的一块(不是浮层)⇒ 用 `paneRiseIn()` 的上浮方向
* (与 WebUI 里"就地展开"的 `.rise-in` 同一档),不是 `menuIn()`
* (那是从上往下落的浮层档)。
*
* ★ 过渡挂在**这里的容器**上,而不是 `CreateForm()` 内部:
* `@Builder` 的调用返回 void,修饰符链不上(`this.CreateForm().transition(...)`
* 编译不过);而且挂在内部时**调用点看不见**,
* "这个 if 有没有动画"要靠追进 Builder 才知道 —— 判据就是这么发现第一版写错的。
* 包一层 `Column` 不改版式:`CreateForm` 的根本来就是 `width('100%')`。
*/
Column() {
this.CreateForm()
}
.width('100%')
.transition(Theme.paneRiseIn())
}
if (this.loading && this.users.length === 0) {

View File

@ -426,7 +426,8 @@ struct InboxTab {
}
.width('100%').height(46)
.padding({ left: 14, right: 12 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
}
@ -472,8 +473,25 @@ struct InboxTab {
}, (acct: AccountInfo) => acct.id)
}
.width('100%')
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.border({ width: { bottom: 1 }, color: Theme.border })
/*
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
*
* 这个下拉原来是**硬弹**出来的:它挂在 `if (this.showAccountPicker)` 上,
* 条件一变就整块出现/消失,中间一帧过渡都没有 —— 而它正是
* "出现/消失"最典型的一处(用户点一下按钮,东西凭空冒出来)。
*
* 用 `menuIn()`:下移 4vp + 缩到 0.985 + 淡入,与 WebUI
* `@keyframes menu-in` 逐值对齐(`Theme.menuIn` 的注释里有出处)。
* 方向是**从上往下**(`translateY(-4px)` → 0)—— 下拉从触发点下方展开,
* 从上方"落"下来;与 `paneRiseIn` 的**上浮**方向相反,别混。
*
* `.transition()` 在这里**会播**(与日历那个常驻窗格不同):
* 它是 `if` 包着的,条件为真时才挂载 —— 正是 transition 的触发条件。
*/
.transition(Theme.menuIn())
}
if (this.loading) {
@ -609,7 +627,8 @@ struct InboxTab {
.width('100%').height(78)
.padding({ left: 10, right: 12 })
.alignItems(VerticalAlign.Center)
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
@ -933,7 +952,8 @@ struct SentTab {
this.SentRow(m)
}
.width('100%')
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
.margin({ bottom: 6 })
.onClick(() => { this.openMail(m); })
@ -1135,7 +1155,8 @@ struct PermissionTab {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
.margin({ bottom: 8 })
}
@ -1462,6 +1483,26 @@ struct CommPage {
}
}
.width('100%').height('100%')
/*
* ★★ 2026-09-19 修(用户:「你这玻璃也不透明啊」)。
*
* 这一层是 `Navigation` 的 **navBar 内容**(列表那一栏的根容器),
* 原来**没有背景** ⇒ 由系统给的 `NavBar` 外壳显出不透明白。实测:
*
* NavBar [229,140,1149,2204] ← 255,255,255(整条列表栏)
* 右栏同高位置 ← 187,190,191(**壁纸透出来了**)
*
* 外层 `Navigation` 的壳修好之后,列表栏自己还盖着一块白。
* 内外两处都要透明,链路才通(Navigation → navBar 内容 → 卡片)。
*
* ★ 这里**不加**材质:WebUI 的同位置规则(`index.css:1641`)给列表容器
* 恰恰是 `background-color: transparent`,注释写明
* 「面板退成透明,**每一项自己是一张玻璃卡**」。
* 玻璃在**卡片**那一层,容器只负责让位 —— 若这里也铺材质,
* 卡片就成了"玻璃套玻璃",正是 WebUI 用
* `.bg-white .bg-white { backdrop-filter: none }` 明确禁止的事。
*/
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
/*
* 悬浮的圆形加号(用户点名要的形状):56 圆、品牌色、右下角。
@ -1495,6 +1536,20 @@ struct CommPage {
.minContentWidth(360)
.hideTitleBar(true)
.width('100%').height('100%')
/*
* ★★ 2026-09-19 修(用户:「你这玻璃也不透明啊」)。
*
* 这一行原来**没有** —— `Navigation` 不设背景时用系统默认(不透明白),
* 于是它把里面已经透明的窗格整片盖住了。实测(模拟器 3184×2232):
*
* Navigation [229,140,3156,2204] ← 255,255,255(整块内容区)
* y=2210(Navigation 之外那条缝) ← 226,225,235(**壁纸清楚可见**)
*
* 那条缝是决定性的证据:**壁纸层本身是好的**,白是因为它上面盖了
* 一个不透明的 `Navigation`。此前一直在调窗格自己的 `bgActive`,
* 而真正挡住的是外面这层壳。
*/
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
}
@ -1831,7 +1886,8 @@ struct ContactsTab {
* 不要把一个"按当时字号的估算值"钉成常量 ——
* 字号、行高、动作行的存在与否都会变,而常量不会跟着变。
*/
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
.clip(true)
/* 点卡片 = 打开这条会话(WebUI `ContactPanel` 的 onOpen) */
@ -1860,6 +1916,9 @@ struct ContactsTab {
.minContentWidth(360)
.hideTitleBar(true)
.width('100%').height('100%')
/* 同 CommPage 的 Navigation:不设背景时系统默认不透明白,会把里面
已经透明的窗格盖住。联系人页是同一形状,同一修法。 */
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
}
/**
@ -1921,7 +1980,8 @@ struct ContactsTab {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
}

View File

@ -650,7 +650,16 @@ export struct SettingsPane {
}, (account: AccountInfo) => account.id)
}
.width('100%').margin({ top: 10 })
.backgroundColor(Theme.surface)
/*
* ★ 2026-09-19:这里**去掉**材质(原来加了,是错的)。
*
* 它是"个人资料"那张**卡里面**的一段列表 —— 即 `卡 > 列表`。
* 两层都铺材质就是 WebUI 用后代选择器明确禁止的嵌套玻璃
* (`.bg-white .bg-white { backdrop-filter: none }`):
* alpha 相乘把壁纸吃光,而且每层各糊一次。
* 判据 `cross-client-theme` 的 C 条正是按"玻璃不许套玻璃"判的,它抓到了这处。
* 内容仍靠外层那张卡的材质透出玻璃感。
*/
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
@ -658,7 +667,8 @@ export struct SettingsPane {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
/* 外观(主题 / 壁纸) */
@ -729,8 +739,19 @@ export struct SettingsPane {
.width('100%')
}
.width('100%').height('68%')
.padding(16).backgroundColor(Theme.surface)
.padding(16).backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius({ topLeft: 12, topRight: 12 })
/*
* ★ 2026-09-19 补(用户:「元素的出现消失动画呢?」)。
*
* 底部弹层挂在 `if (this.showAddDialog)` 上,原来硬弹。
* 与 `MailDetailPage` 的回复框/转发框**同一档、同一挂法**:
* `paneRiseIn()` 挂在**弹层本身**(下半部那块表面),遮罩立即出现。
* 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 ——
* 那看起来是"页面在动",而不是"弹层弹出来"。
*/
.transition(Theme.paneRiseIn())
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
@ -805,7 +826,8 @@ export struct SettingsPane {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 10 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
}
@ -876,7 +898,8 @@ export struct SettingsPane {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
}
@ -913,7 +936,8 @@ export struct SettingsPane {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
}
@ -989,7 +1013,8 @@ export struct SettingsPane {
}, (k: UserKey) => k.key_id)
}
.width('100%').margin({ top: 10 })
.backgroundColor(Theme.surface)
/* 同上(账号列表那处):这是"KeySection 卡**里面**的一段列表",
两层都铺材质 = 嵌套玻璃(判据 C 条会红,且 alpha 相乘会把壁纸吃光)。 */
.borderRadius(Theme.radiusCard)
.border({ width: 1, color: Theme.border })
.clip(true)
@ -997,7 +1022,8 @@ export struct SettingsPane {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
}
@ -1134,7 +1160,8 @@ export struct SettingsPane {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
}
@ -1161,7 +1188,8 @@ export struct SettingsPane {
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
.backgroundBlurStyle(this.bgActive ? Theme.cardMaterial : BlurStyle.NONE)
.borderRadius(Theme.radiusCard)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/AdminUsersPage' });