diff --git a/client/electron/test/cross-client-theme.test.mjs b/client/electron/test/cross-client-theme.test.mjs index 6a92581..b302a2e 100644 --- a/client/electron/test/cross-client-theme.test.mjs +++ b/client/electron/test/cross-client-theme.test.mjs @@ -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;写裸枚举等于绕开"材质来自设计令牌"这条'); + } + } } // 导航条那一处必须真的还在(形状判定之外,位置本身也要在) diff --git a/client/electron/test/harmony-contacts.test.mjs b/client/electron/test/harmony-contacts.test.mjs index 93c2981..c4766d3 100644 --- a/client/electron/test/harmony-contacts.test.mjs +++ b/client/electron/test/harmony-contacts.test.mjs @@ -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)必须判红'); }); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 35420e2..b1467f6 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -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 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次; diff --git a/client/electron/test/mutants/baseline.sha b/client/electron/test/mutants/baseline.sha index 79a6ae8..631de43 100644 --- a/client/electron/test/mutants/baseline.sha +++ b/client/electron/test/mutants/baseline.sha @@ -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 -- ` ⇒ 非空(是"我改的"); +# 另 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 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index f7814b2..e46d57f 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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`(让开时钟/手势条)。 // 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天); diff --git a/client/harmony/entry/src/main/ets/common/Motion.ets b/client/harmony/entry/src/main/ets/common/Motion.ets new file mode 100644 index 0000000..bf08ebd --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/Motion.ets @@ -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; + } +} diff --git a/client/harmony/entry/src/main/ets/common/Surface.ets b/client/harmony/entry/src/main/ets/common/Surface.ets new file mode 100644 index 0000000..9ed9464 --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/Surface.ets @@ -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) + } +} diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index fe8b865..c0bd07e 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -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 }) ); } } diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets index e533f76..65e2738 100644 --- a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -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) { diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 1ba15aa..de399bb 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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 }) } diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 4517a6a..b2dc94f 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -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' });