From 477479a37059df19c7d39ab9f20e548c7555ed80 Mon Sep 17 00:00:00 2001 From: dsh Date: Thu, 1 Oct 2026 18:49:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(harmony):=20=E2=98=85=E2=98=85=20=E4=B8=89?= =?UTF-8?q?=E9=A1=B5=20AppHeader=20=E9=A1=B6=E6=A0=8F=E9=81=BF=E8=AE=A9?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=200=20=E2=87=92=20=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E5=8E=8B=E8=BF=9B=E7=B3=BB=E7=BB=9F=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=8F=EF=BC=88=E7=9C=9F=E6=9C=BA=E5=AE=9E=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备:HUAWEI MatePad Pro(MRDI-W00),HarmonyOS NEXT,API 26, `hdc tconn 192.168.2.87:43679`(此前一直无真机,本条挂了 5 天)。 ## 症状(用户报:「左上角全屏状态下不应该显示全屏,会与顶部系统顶栏冲突」) 截图像看是状态栏压住顶栏。**实测证伪了这个读法**:像素扫描 + `uitest dumpLayout` 显示顶栏文字在 y=134..166、系统状态栏止于 y=82,**两者不重叠**。 真正被切掉的是**列表第一封邮件的标题**(y=294..319,只剩一条细线)。 ⇒ 两处独立问题,第二个(窗格头 y=222..320 与列表首行 y=294..319 重叠) **不是顶栏避让**造成的,本次未修,见下。 ## 已修:AppHeader 那一半的避让确实是坏的 `EntryAbility` 早就把避让读到了(真机日志 `insets: statusBar=38.588235 navIndicator=27.764706 windowDecor=37`),`CommPage`/`MainPage` 内容层也用了 (`top: max(statusBar, windowDecor) + paneGap`)。**但 `AppHeader` 是另一个消费者, 三处都写死了 `topInsetPx: 0`**: · `SentTab`(发件箱) MainPage.ets:1658 · `SettingsPage`(我的) SettingsPage.ets:687 · `PermissionTab`(授权) PermissionTab.ets:487 只有 `AdminUsersPage` 是对的(`topInset(this.windowInsets)`)—— 抄它。 ## 为什么已有的判据没抓到(这才是关键) `harmony-window.test.mjs` 接线⑤「每一个 @Entry 页都要消费避让」是**绿的**, 因为它的 `consumes()` 认两种形状,其中一种只要**文件里出现过** `this.windowInsets.statusBar` 就算过 —— 而 `MainPage` 的**内容层**正好读了它。 ⇒ 页面上有**两个**避让消费者,判据只问"页里有没有出现过那个字段", 于是 `AppHeader` 里那个 0 被完全放过。 新判据(判据 10)改成**逐个消费者问**:任何传给 `AppHeader` 的 `topInsetPx` 不得是字面量 0。自检要求扫到 ≥4 处,防"遍历写错 ⇒ 永远绿"。 ★ 这条判据自己先犯过一次同类错并当场被抓:第一版用 `prose()`(含注释), 把**我自己写进注释里的**「原先 topInsetPx: 0」抓成了红 —— 判据在判自己的注释。改用本文件已有的 `stripped()`(其注释原话: 「判据的锚不能落在被守对象的自述上」)。红绿已验:把 SettingsPage 退回 缺陷版 ⇒ 红;恢复 ⇒ 绿。 ## 编译期抓到的一个坑 `MainPage.ets:1658` 的 AppHeader 在 **`SentTab`** 里,而 `windowInsets` 原本 只声明在 `CommPage` 上。ArkTS 报 `Property 'windowInsets' does not exist on type 'SentTab'`。⇒ `@StorageLink` 是**每个组件各自**订阅 `AppStorage` 的, 父组件的不会自动传给子组件;直接各自订阅同一把键(比"父传子"少一层)。 ## 真机验证 装机后重跑 dumpLayout:发件箱顶栏 `y=134..166`(状态栏底 y=82,间隙 52px), 截图确认「发件箱」完整显示、不再被压。 ## 未修(诚实登记) 列表第一行标题被窗格头盖住(`List` 首项 y=294..319 落在窗格头 y=222..320 内), 与顶栏避让**无关**,本次未动。根因待查:`MailRow` 是 `.height(64)` 定高, 而 List 容器从 y=320 起算,首项被画到容器上方。 --- client/electron/test/harmony-window.test.mjs | 55 ++++ .../entry/src/main/ets/pages/MainPage.ets | 251 +++++++++++++++--- .../src/main/ets/pages/PermissionTab.ets | 10 +- .../entry/src/main/ets/pages/SettingsPage.ets | 169 ++++++++++-- 4 files changed, 429 insertions(+), 56 deletions(-) diff --git a/client/electron/test/harmony-window.test.mjs b/client/electron/test/harmony-window.test.mjs index b68bf24..ca6b726 100644 --- a/client/electron/test/harmony-window.test.mjs +++ b/client/electron/test/harmony-window.test.mjs @@ -304,3 +304,58 @@ test('★ 行为(设备):全屏后窗口的避让区读得到(真机口 */ assert.equal(fg, ourBundle()); }); + +/* + * ★★ 2026-10-01 真机实测抓到的漏洞(MatePad Pro / MRDI-W00,HarmonyOS NEXT API 26): + * + * 上一条判据("每个 `@Entry` 页都要消费 windowInsets")在 `MainPage` 上是**绿的**, + * 而真机上顶栏被系统状态栏压住了。**为什么绿**: + * + * `consumes()` 认两种形状 —— `topInset(this.windowInsets)` 或 + * `this.windowInsets.statusBar`。`MainPage` 的**内容层**确实读了 + * `this.windowInsets.statusBar`(`top: max(statusBar, windowDecor) + paneGap`), + * 于是整页被判为"已消费" ⇒ 绿。 + * + * ★ 但那说的是**内容层**,而 `AppHeader({ topInsetPx: 0 })` 是**另一个消费者**, + * 它硬编码 0。页面上有两处顶栏避让的消费者,判据只问"页里有没有出现过那个字段"。 + * + * ⇒ 修法:判据改成**逐个消费者问**,而不是逐页问一次。 + * 任何传给 `AppHeader` 的 `topInsetPx` 都不得是字面量 0 —— + * 要么走 `topInset(this.windowInsets)`,要么显式说明为什么可以给 0。 + */ +test('★★ AppHeader 的 topInsetPx 不得硬编码 0(真机:顶栏被系统状态栏压住)', () => { + const pageDir = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages'); + const files = readdirSync(pageDir).filter(f => f.endsWith('.ets')); + + // 自检:这条必须真的扫到东西,否则改名后它会变成"永远绿"的空判据。 + let seenAppHeader = 0; + + const bad = []; + for (const f of files) { + // ★ 必须用 `stripped` 而不是 `prose`:判据要锚在**代码**上, + // 不是锚在代码对自己的描述上(见本文件 `stripped` 的注释)。 + // 我第一版用了 `prose`,结果把**我刚写进注释里的**「原先 topInsetPx: 0」 + // 抓成了红 —— 判据在判自己的注释。(同一类错:注释里那个串不是代码。) + const src = stripped(prose(join(pageDir, f))); + // 抓所有 `topInsetPx: <表达式>`,逐个看表达式是不是字面量 0。 + const re = /topInsetPx:\s*([^,\n}]+)/g; + let m; + while ((m = re.exec(src)) !== null) { + seenAppHeader++; + const expr = m[1].trim(); + if (/^0$/.test(expr)) { + bad.push(`${f}: topInsetPx: 0 —— 顶栏不避让,会被系统状态栏压住`); + } + } + } + + assert.ok(seenAppHeader >= 4, + `自检:要扫到至少 4 处 topInsetPx(实际 ${seenAppHeader} 处)——` + + '扫不到说明遍历写错了,这条会变成永远绿的空判据'); + + assert.deepEqual(bad, [], + '这些页把 AppHeader 的状态栏避让硬编码成 0:\n · ' + bad.join('\n · ') + + '\n\n真机实测(MatePad Pro,API 26):EntryAbility 已读到 insets: statusBar=38.588235,' + + '\n但 MainPage 的 AppHeader 收到的是 0 ⇒ 顶栏「收件箱/发件箱/授权」压进状态栏。' + + '\n修法:传 topInset(this.windowInsets)(见 AdminUsersPage 的写法)。'); +}); diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 198f05a..92378f3 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -112,7 +112,7 @@ import { CalendarPage } from './CalendarPage'; import { ComposeView } from './ComposePage'; import { SettingsPane } from './SettingsPage'; import { LengthMetrics, ComponentContent } from '@kit.ArkUI'; -import { Insets, KEY_WINDOW_INSETS } from '../model/WindowInsets'; +import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets'; import { LIST_FADE_LENGTH, NAV_BAR_BOTTOM, @@ -602,7 +602,9 @@ struct InboxTab { .padding({ left: 14, right: 12 }) .attributeModifier(GlassCardModifier.of(this.bgActive)) .borderRadius(Theme.radiusCard) - .border({ width: 1, color: Theme.border }) + /* ★ 2026-09-28:**列表头卡**轮廓 —— 同样从行间分隔线色换成发丝档。 + 见 `Surface.ets` 里 GlassCardModifier 那处注释。 */ + .border({ width: 1, color: Theme.hairline }) } .width('100%') .padding({ left: 10, right: 10 }) @@ -957,25 +959,77 @@ struct InboxTab { * ① 有态色(未读/选中)时铺 `accentSoft` —— 它本身就是带 alpha 的令牌; * ② 无态色时走玻璃卡(透明 + backgroundEffect)。 */ + /* + * ★★ 2026-09-28 修(本轮样式审计撞出来的**真 bug**,不是观感偏好)。 + * + * ── 症状 ── + * 设备布局转储(`uitest dumpLayout`)里,本栏**每一行**的底色都是 + * `#00000000`(全透明),而组头是 `#C7FFFFFF`(= `glassCardWall`,α=0.78): + * + * ListItem [70,826][1186,1050] + * Row [70,826][1186,1050] bg=#00000000 + * Row[73,829][1183,1047] bg=#00000000 ← 邮件行 + * 对比 GroupHeader: + * Row [73,562][1183,802] bg=#C7FFFFFF + * + * ⇒ 邮件行**从来没有**玻璃底、也**从来没有**选中/未读的品牌浅底。 + * + * ── 根因:链在 `.attributeModifier` **后面**的内联 `.backgroundColor` 会赢 ── + * 原来的写法是「modifier 算状态色」**加上**「末尾再内联一个 hover 三目」: + * + * .attributeModifier(选中||未读 ? 合成(玻璃 + accentSoft) : 玻璃) + * .onHover(...) + * .backgroundColor(hovered && !选中 && !未读 ? surfaceMuted : **Color.Transparent**) + * ^^^^^^^^^^^^^^^^ + * 那个 `Color.Transparent` 是**普通态**的分支,于是绝大多数行的底色被它写成透明, + * 把上面 modifier 里的 `glassCardFor` / `accentSoft` **整个盖掉**。 + * + * `attributeModifier` 是**单一插槽**,而内联属性在它之后求值 —— 这是 + * `Surface.ets:917-936`(`CompositeModifier` 抬头)已经写过一次的同一件事, + * 只是那次栽在"两个 modifier 互相覆盖",这次栽在"内联覆盖 modifier"。 + * + * ── 为什么能藏这么久(这是它最值得记的一点)── + * 本轮之前**面板本身**是一层 `#C7FFFFFF` 的玻璃(`PaneModifier` 之外又套了 + * 一张玻璃卡)。行透明了没关系 —— 透出来的是面板的白纱,肉眼看着"行有底"。 + * 本轮把嵌套玻璃按 WebUI 去掉后,面板不再提供那层白, + * 行底色才**第一次真的显示出来**,于是"选中行没有白底"当场暴露。 + * ⇒ 一个被上层盖住的 bug 可以在视觉上"正确"很久;**修掉遮它的那层,才是它的显影剂**。 + * + * ── 修法:让 modifier 成为**唯一**的底色写入者 ── + * 三档(**选中/未读 > 悬停 > 普通**)全部并进 modifier,删掉末尾那个内联三目。 + * 优先级与 WebUI 逐一对应(`MailList.tsx:280`): + * active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50' + * · 选中/未读 → `accentSoft`(= blue-50) + * · 悬停 → `surfaceMuted`(系统 `sub_background`;Theme.ets:63 记的用途 + * 正是「分组底、列表行 hover」) + * · 普通 → 玻璃卡基础件(壁纸档给 `glassCardFor`,否则系统实色面) + * + * ★ 为何悬停档也传 `pressable = false`:与选中档同一个理由 —— + * `PressFeedbackModifier` 是在 `onTouch` 里写 `backgroundColor` 的, + * 开着它就会在按下的瞬间把这一档的底色擦成透明(见 `Surface.ets:905-914`)。 + */ .attributeModifier(this.currentMailId === mail.mail_id || mail.status === 'unread' ? CompositeModifier.of([ GlassCardModifier.of(this.bgActive, false), TintModifier.of(Theme.accentSoftFor(this.isDarkNow)) ]) - : GlassCardModifier.of(this.bgActive)) + : this.hoveredMailId === mail.mail_id + ? CompositeModifier.of([ + GlassCardModifier.of(this.bgActive, false), + TintModifier.of(Theme.surfaceMuted) + ]) + : GlassCardModifier.of(this.bgActive)) .borderRadius(Theme.radiusCard) .border({ width: 1, - color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border + /* ★ 2026-09-28:轮廓走**系统发丝**档(`Theme.hairline`),不是行间分隔线色。 + 见 `Surface.ets` 里 GlassCardModifier 那处注释(设备实测深浅差约 3 倍)。 */ + color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.hairline }) .clip(true) .onHover((isHover: boolean) => { this.hoveredMailId = isHover ? mail.mail_id : ''; }) - .backgroundColor(this.hoveredMailId === mail.mail_id - && this.currentMailId !== mail.mail_id - && mail.status !== 'unread' - ? Theme.surfaceMuted : Color.Transparent) .onClick(() => { this.openMail(mail); }) @@ -1020,40 +1074,67 @@ struct InboxTab { .width(20) Column() { Row() { + /* + * ★ 2026-09-28:未读圆点搬到**发件人这一行**(与 WebUI 同位)。 + * 原来数字胶囊在**主题那一行**前面 ⇒ 视觉上"数字属于标题", + * 而 WebUI 的点是紧贴发件人(谁没读一目了然)。 + * 6px 圆点 = WebUI `w-1.5 h-1.5`。 + */ + if (g.unreadCount > 0) { + Text('') + .width(6).height(6).borderRadius(3) + .backgroundColor(Theme.accent) + .margin({ right: 6 }) + } Text(g.latest?.from_name ?? '') .fontSize(12).fontWeight(g.unreadCount > 0 ? FontWeight.Bold : FontWeight.Normal) .fontColor(Theme.textPrimary) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1) Text(compactMailTime(g.latest?.created_at ?? '')) - .fontSize(10).fontColor(Theme.textSubtleFor()).margin({ left: 8 }) + .fontSize(11).fontColor(Theme.textSubtleFor()).margin({ left: 8 }) } .width('100%') Row() { - if (g.unreadCount > 0) { - Text(g.unreadCount > 99 ? '99+' : g.unreadCount.toString()) - .fontSize(9).fontWeight(FontWeight.Bold).fontColor(Theme.accentFg) - .backgroundColor(Theme.accent).borderRadius(8) - .constraintSize({ minWidth: 16 }).height(16).textAlign(TextAlign.Center) - .margin({ right: 6 }) - } Text(g.subject.length > 0 ? g.subject : '(无主题)') - .fontSize(12).fontWeight(g.unreadCount > 0 ? FontWeight.Bold : FontWeight.Normal) - .fontColor(Theme.textPrimary) + .fontSize(13).fontWeight(g.unreadCount > 0 ? FontWeight.Bold : FontWeight.Normal) + .fontColor(g.unreadCount > 0 ? Theme.textPrimary : Theme.textMuted) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1) } .width('100%').margin({ top: 3 }) Row() { Text(g.alias.length > 0 ? '.' + g.alias : '(未命名会话)') - .fontSize(10).fontColor(Theme.accentFor()) + .fontSize(11).fontColor(Theme.accentFor()) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1) - Text(g.mails.length + ' 封').fontSize(10).fontColor(Theme.textSubtleFor()).margin({ left: 8 }) + Text(g.mails.length + ' 封').fontSize(11).fontColor(Theme.textSubtleFor()).margin({ left: 8 }) } .width('100%').margin({ top: 2 }) } .layoutWeight(1).alignItems(HorizontalAlign.Start) } - .width('100%').height(78) - .padding({ left: 10, right: 12 }) + /* + * ★★ 2026-09-28 密度(对齐 WebUI `MailList.tsx:281` 的 + * `glass-card w-full text-left px-3 py-2.5 border`): + * + * 原来:`height(78)` 钉死 + 12/10 字号 + **16px 高的数字胶囊** + 每行描边。 + * 三处都与 WebUI 不一致(真机截图 2026-09-26, + * 用户:「没有 webui 精致美观…十分杂乱」): + * + * ① **未读标记**:WebUI 是 `w-1.5 h-1.5 rounded-full bg-blue-500` + * 的 **6px 圆点**(`MailList.tsx:296`);这里却是 `height(16)` 的 + * **数字胶囊**。一屏 14 组就是 14 个实心色块 + * —— **"杂乱"的主要来源不是动效,是这个**。 + * ⇒ 改成 6px 圆点;数字不再塞进行内(WebUI 也只显示点,不显示数字)。 + * + * ② **行高**:`height(78)` 是钉死的,三行文字实测约 46px, + * 余量全压在一处 ⇒ 视觉上"行内空、行间挤"。 + * WebUI 是 `py-2.5`(内容自适应)。⇒ 去掉钉死的 height,改 padding。 + * + * ③ **描边**:WebUI 默认 `border-transparent`,**只有 active** 才给 + * `border-blue-200`;这里每行都画 `border 1px` ⇒ 一屏 14 条竖线。 + * ⇒ 改成"有未读或展开时才描边"。 + */ + .width('100%') + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) .alignItems(VerticalAlign.Center) /* * ★★ 2026-09-20 简:这里原来写的是 @@ -1065,7 +1146,20 @@ struct InboxTab { */ .attributeModifier(GlassCardModifier.of(this.bgActive)) .borderRadius(Theme.radiusCard) - .border({ width: 1, color: Theme.border }) + /* + * ★ 2026-09-28:描边改成**有未读或展开时才给**(对齐 WebUI + * `MailList.tsx:281` 的 `active ? 'bg-blue-50 border-blue-200' : 'border-transparent'`)。 + * 原来每行都画 1px ⇒ 一屏 14 条竖线,是"杂乱"的另一半来源。 + * + * ★ 这里用 `accentEdgeFor()` 而不是新加一个"描边色":仓库里**早就有** + * `accentEdge`/`accentEdgeDark`(`Theme.ets:739-740`,2026-09-19 为 + * "选中邮件"那圈边登记的,值与 WebUI `border-blue-200` 逐字一致)。 + * 我第一版新写了 `accentBorder`/`accentBorderDark` 两个同值 token, + * 是判据 `cross-client-theme` 的 A2 与"手写色清册跨文件"两条**当场报红** + * 才发现的 —— 值一样、名字不同,登记册里查不到。 + * ⇒ 「加颜色之前先搜一遍有没有同值的既有 token」,别按语义新造。 + */ + .border({ width: 1, color: g.unreadCount > 0 || this.isExpanded(g.key) ? Theme.accentEdgeFor() : Color.Transparent }) .clip(true) .attributeModifier(PressEffectModifier.of()) .onClick(() => { @@ -1119,7 +1213,8 @@ struct InboxTab { .margin({ left: 6 }) } Blank() - Text(compactMailTime(mail.created_at)).fontSize(10).fontColor(Theme.textSubtleFor()) + /* ★ 2026-09-28:10 → 11px(与组头/联系人页的时间同档,见组头注释)。 */ + Text(compactMailTime(mail.created_at)).fontSize(11).fontColor(Theme.textSubtleFor()) } .width('100%') @@ -1246,6 +1341,19 @@ struct SentTab { * (`InboxTab` / `SentTab`)上,所以要存"是哪一封"。 */ @State hoveredMailId: string = ''; + /* + * 状态栏避让(窗口级事实,见 `model/WindowInsets.ts`)。 + * + * ★ 为什么 `SentTab` 要自己订阅一份,而不用 `CommPage` 那份传下来: + * `AppHeader` 在**这个**结构体里,而 `@StorageLink` 是**每个组件各自**订阅 + * `AppStorage` 的 —— 父组件的 `@StorageLink` 不会让子组件自动拥有该属性。 + * (编译器原话:`Property 'windowInsets' does not exist on type 'SentTab'`。) + * 直接订阅同一把键比"父传子"少一层,且横竖屏/折叠时两者都会跟着变。 + * + * ★ 原先这里是 `topInsetPx: 0` 硬编码 ⇒ 真机(MatePad Pro)上发件箱顶栏 + * 压进系统状态栏,而避让值其实早就读到了(statusBar=38.588235)。 + */ + @StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets(); /** 深浅色(`InboxTab` 同名属性同一口径:`Theme` 是静态类,算不出主题,走 AppStorage) */ @StorageProp('agentmail.appearance.isDark') isDarkNow: boolean = false; /** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */ @@ -1414,15 +1522,35 @@ struct SentTab { * **同一个三目**(选中 > 未读 > 普通)—— 发件箱没有未读概念, * 所以只有两档。 */ + /* + * ★★ 2026-09-28 修:与 `MailRow` **同一处、同一类**的错(那份注释里有完整取证)。 + * + * 末尾原来也链着一个内联三目: + * .backgroundColor(hovered && !选中 ? surfaceMuted : **Color.Transparent**) + * 普通态落到 `Color.Transparent` ⇒ 把 modifier 里的玻璃底/`accentSoft` 整个盖掉。 + * + * 发件箱没有未读概念,所以是**三档**(选中 > 悬停 > 普通), + * 与 `MailRow` 同一个形状(那边多一档未读)。 + * + * ★ 本仓第二次撞见这个形状了:**同一件事两处各写一遍,然后一起写错**。 + * 改这里时务必回头看 `MailRow` 一眼。 + */ .attributeModifier(this.currentMailId === mail.mail_id ? CompositeModifier.of([ GlassCardModifier.of(this.bgActive, false), TintModifier.of(Theme.accentSoftFor(this.isDarkNow)) ]) - : GlassCardModifier.of(this.bgActive)) + : this.hoveredMailId === mail.mail_id + ? CompositeModifier.of([ + GlassCardModifier.of(this.bgActive, false), + TintModifier.of(Theme.surfaceMuted) + ]) + : GlassCardModifier.of(this.bgActive)) .border({ width: 1, - color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.border + /* ★ 2026-09-28:轮廓走**系统发丝**档(`Theme.hairline`),不是行间分隔线色。 + 见 `Surface.ets` 里 GlassCardModifier 那处注释(设备实测深浅差约 3 倍)。 */ + color: this.currentMailId === mail.mail_id ? Theme.accentEdgeFor(this.isDarkNow) : Theme.hairline }) .borderRadius(Theme.radiusCard) .clip(true) @@ -1450,9 +1578,17 @@ struct SentTab { .onHover((isHover: boolean) => { this.hoveredMailId = isHover ? mail.mail_id : ''; }) - .backgroundColor(this.hoveredMailId === mail.mail_id - && this.currentMailId !== mail.mail_id - ? Theme.surfaceMuted : Color.Transparent) + /* + * ★★ 2026-09-28 删掉末尾的内联 `.backgroundColor(...)`。 + * + * 它原来是这个三目: + * .backgroundColor(hovered && !选中 ? surfaceMuted : **Color.Transparent**) + * 普通态落到 `Color.Transparent`,且它链在 `.attributeModifier` **之后** + * ⇒ 把 modifier 里的玻璃底/`accentSoft` 整个盖掉。完整取证与原理见 `MailRow`。 + * + * 悬停档已经并进上面的 modifier(`TintModifier.of(Theme.surfaceMuted)`), + * 所以这里**不能再写任何 `backgroundColor`** —— 写了就又把它盖回去。 + */ .onClick(() => { this.openMail(mail); }) @@ -1468,8 +1604,8 @@ struct SentTab { .fontSize(13).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) Row() { - Text(g.subject.length > 0 ? g.subject : '(无主题)') - .fontSize(12).fontColor(Theme.textMuted) + Text(g.subject.length > 0 ? g.subject : '(无主题)') + .fontSize(13).fontColor(Theme.textMuted) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) Blank() @@ -1479,8 +1615,8 @@ struct SentTab { } .layoutWeight(1).alignItems(HorizontalAlign.Start) } - .width('100%').height(60) - .padding({ left: 12, right: 12 }) + .width('100%') + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) .alignItems(VerticalAlign.Center) /* * ★★ 2026-09-21 修(真 bug,用户:「发件箱存在严重问题」)—— @@ -1497,7 +1633,8 @@ struct SentTab { */ .attributeModifier(GlassCardModifier.of(this.bgActive)) .borderRadius(Theme.radiusCard) - .border({ width: 1, color: Theme.border }) + /* ★ 2026-09-28:同收件箱组头 —— 描边只在展开时给(见该处注释)。 */ + .border({ width: 1, color: this.isExpanded(g.key) ? Theme.accentEdgeFor() : Color.Transparent }) .margin({ bottom: 6 }) .clip(true) .attributeModifier(PressEffectModifier.of()) @@ -1527,7 +1664,11 @@ struct SentTab { title: '发件箱', showBack: false, active: this.bgActive, - topInsetPx: 0 + /* `@Entry` 页:状态栏避让由页面自己消费(窗口级事实,见 AppHeader 的注释)。 + * ★ 这里曾是硬编码 0 —— 而本结构体 `:2556` 明明已经持有 windowInsets, + * 真机(MatePad Pro)实测 statusBar=38.58 读到了却被丢掉, + * 顶部第一封邮件的标题被系统状态栏压掉上半截。 */ + topInsetPx: topInset(this.windowInsets) }) { this.SentCountTrailing() } @@ -3868,7 +4009,45 @@ struct MainPage { * ★ 修法与窄屏那一支**对齐**(窄屏本来就是对的): * 壁纸开着就透明,让底下的 `WallpaperLayer()` 透上来。 */ - .attributeModifier(GlassCardModifier.of(this.bgActive)) + /* + * ★★ 2026-09-28 修 —— **上面那句注释与当时的代码不一致,这才是"卡片发闷"的根因**。 + * + * ── 症状(用户:「没有 webui 精致美观」)── + * 卡片看得见,但整片发闷、壁纸几乎透不上来。像素实测(窄屏 1256×2760, + * 同一 y=440 横剖):壁纸 rgb(180,191,204) → 面板 rgb(239,242,246) + * → 卡片 rgb(251,252,253)。 + * + * ── 根因:**玻璃套玻璃**(两层 0.78 相乘)── + * 反解 alpha: + * 面板层 (239-180)/(255-180) = **0.787** + * 卡片层 (251-239)/(255-239) = **0.750** + * 两层都是 `#C7FFFFFF`(= glassCardWall,framework 布局转储实测), + * 叠加后壁纸只剩 (1-0.78)² ≈ **4.8%**: + * 若单层(WebUI 做法): 255×0.78 + 180×0.22 = **238.5** + * 若双层(本处现状) : 255×0.78 + 239×0.22 = **251.5** + * 实测卡片内像素 = **251** ← 与"双层"吻合 + * + * ⇒ 上面那段注释说的是「**壁纸开着就透明**」,但当时写下的却是 + * `GlassCardModifier.of(this.bgActive)` —— 那个修饰符在 active 时铺的是 + * **0.78 白纱**,不是透明。**注释描述的是意图,代码做的是另一件事。** + * 于是活跃壁纸时:本列 0.78 + 卡片 0.78 = 两层。 + * + * ── 为什么透明才对(对齐 WebUI)── + * WebUI 宽屏 `.app-shell > *`(`index.css:1070`)只给 + * `border-radius` + `overflow` + `box-shadow` —— **没有背景**(上面那句 + * "半透玻璃"其实是误读:那一行不设 `background`)。真正铺玻璃的是**每一张卡** + * (`.glass-card`,0.78)。 + * 而列表容器更明确:`html[data-bg='on'] .comm-pane > .bg-white` + * `{ background-color: transparent }`(`index.css:1641`)—— 注释写着 + * 「面板退成透明,**每一项自己是一张玻璃卡**」。 + * 而"玻璃不许套玻璃"正是本仓 `cross-client-theme` C 条判的东西, + * `SettingsPage.ets:735` 也已经为同一个形状修过一次(`卡 > 列表`)。 + * + * ⇒ 本列是**框架**(面板的壳),壳不铺玻璃:壁纸开着 → 透明; + * 壁纸关着 → `surface`(与本次修改前的窄屏分支逐字相同)。 + * 这样每张卡只对壁纸合成**一次**,回到 238.5 那一档。 + */ + .backgroundColor(this.bgActive ? Color.Transparent : Theme.surface) /* * ★★ 2026-09-20 修(用户:「你一改了之后,我列表都没法滚动了」)。 * diff --git a/client/harmony/entry/src/main/ets/pages/PermissionTab.ets b/client/harmony/entry/src/main/ets/pages/PermissionTab.ets index 8b08801..37683f3 100644 --- a/client/harmony/entry/src/main/ets/pages/PermissionTab.ets +++ b/client/harmony/entry/src/main/ets/pages/PermissionTab.ets @@ -33,6 +33,7 @@ import { emptyTitle, emptyHint } from '../model/CommTabs'; import { LengthMetrics } from '@kit.ArkUI'; import { MailLike, PermissionGroup, groupPermissions, shortTimeOf } from '../model/MailGrouping'; import { INBOX_PAGE_SIZE } from '../common/MailStore'; +import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets'; /** WebUI 邮件列表统一使用 MM/DD HH:mm(与 `MainPage` 的同名函数一致)。 */ function compactMailTime(iso: string): string { @@ -75,6 +76,13 @@ export struct PermissionTab { * 对齐 WebUI `PermissionList.tsx:81` 的 `pick()` = `selectMail + showDetail`。 */ onOpenMail: (mailId: string, accountId: string) => void = (): void => {}; + /* + * 状态栏避让(窗口级事实,见 `model/WindowInsets.ts`)。 + * ★ 原先 `AppHeader({ topInsetPx: 0 })` 硬编码 0 ⇒ 授权页顶栏压进系统状态栏 + * (真机 MatePad Pro 实测 statusBar=38.58 读到了却被丢掉)。 + */ + @StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets(); + @State requests: PermissionRequest[] = []; /** * 已决策的**历史**,按会话分组(对齐 WebUI `PermissionList.tsx:182` 的「历史 {n}」)。 @@ -476,7 +484,7 @@ export struct PermissionTab { title: '授权', showBack: false, active: this.bgActive, - topInsetPx: 0 + topInsetPx: topInset(this.windowInsets) }) { this.PendingTrailing() } diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 7b1ea35..52fecb7 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -16,6 +16,12 @@ import { AuthApi } from '../api/AuthApi'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { common } from '@kit.AbilityKit'; import { LengthMetrics } from '@kit.ArkUI'; +/* + * ★ 2026-09-29 新增:官方 `Chip`(用户:「尽量使用官方组件而不是自绘」)。 + * 主题三选一原来是自绘的 `Text` + 手写配色/圆角 —— 与背景档、预设 + * 那两处各写一套,圆角和内边距互不相同(见 `AppearanceSection` 的注释)。 + */ +import { Chip, AccessibilitySelectedType } from '@kit.ArkUI'; import { AmIcon } from '../common/Icons'; import { LIST_FADE_LENGTH } from '../model/NavItems'; import { AccountManager, AccountInfo } from '../api/AccountManager'; @@ -32,6 +38,7 @@ import { BackgroundPicker } from '../common/BackgroundPicker'; import { PushService } from '../api/PushService'; import { performLogout } from '../api/Logout'; import { PushTokenList } from '../model/PushContract'; +import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets'; /** * 内容列宽度上限(vp)—— 对齐 WebUI `AccountPage.tsx:104` 的 `max-w-3xl`。 @@ -153,6 +160,13 @@ export struct SettingsPane { */ private readonly KEY_APPEARANCE_REVISION: string = 'agentmail.appearance.revision'; + /* + * 状态栏避让(窗口级事实,见 `model/WindowInsets.ts`)。 + * ★ 原先 `AppHeader({ topInsetPx: 0 })` 硬编码 0 ⇒ 设置页顶栏压进系统状态栏 + * (真机 MatePad Pro 实测 statusBar=38.58 读到了却被丢掉)。 + */ + @StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets(); + @State bgKind: string = 'none'; @State bgPresetId: string = 'aurora'; @State bgDim: number = 12; @@ -342,7 +356,7 @@ export struct SettingsPane { } } - /** 吹销一把密钥 */ + /** 吊销一把密钥 */ async revokeKey(keyId: string): Promise { const client: ApiClient | null = this.client; if (client === null) { @@ -354,7 +368,7 @@ export struct SettingsPane { await this.loadKeys(); } catch (e) { const ae = e as ApiError; - this.keysError = ae.message.length > 0 ? ae.message : '吹销失败'; + this.keysError = ae.message.length > 0 ? ae.message : '吊销失败'; } } @@ -670,7 +684,7 @@ export struct SettingsPane { title: '我的', showBack: false, active: this.bgActive, - topInsetPx: 0 + topInsetPx: topInset(this.windowInsets) }) { this.HeaderTrailing() } @@ -1030,6 +1044,38 @@ export struct SettingsPane { @Builder AppearanceSection() { Column() { + /* + * ★★ 2026-09-29 改(用户:「设置界面更是重灾区」「尽量使用官方组件」): + * + * ── 改前的三个形状问题(都在同一屏上,一眼可见)── + * + * ① **同一条状态印了两遍**:这张卡右上角一个「已同步」, + * 下面「背景」小标题右边又有一个「已同步」——两者永远是同一个 + * `statusLabel(this.appearanceStatus)`。用户对收件箱未读数提过 + * 「一个数两个指示器」,这里是同一个错。 + * ⇒ 状态只留在**卡片级**一处(它描述的是"外观整体",不是"主题"或"背景")。 + * 为此 `BackgroundPicker` 不再收 `statusText`(见那里的注释)。 + * + * ★ 与 WebUI 的**有意分歧**,记在这里免得下一个人以为是漏改: + * WebUI 把 `SyncBadge` 放在 `BackgroundPicker` 的标题行里 + * (`BackgroundPicker.tsx:69`,实现见 `:277`),主题那段没有。 + * 我们**上提到卡片级**,理由:服务端一条 `setAppearance` 推的是 + * 主题+背景+浓度+模糊**整组**(见 `AppearanceStore.ets` 的快照), + * 所以它描述的是整张卡而不是"背景"这一段;挂在子段里反而 + * 暗示"只有背景在同步"。位置分歧、**数量一致(都只有一处)**。 + * 另外 WebUI 那个 badge 是带底色的胶囊(`bg-green-50`), + * 这里保持纯文字 —— 底色会与玻璃卡片叠出第二层色块, + * 而状态已经有"仅本机时才变警示色"这条区分(见下 `warnFgFor`)。 + * + * ② **标题层级与 WebUI 不一致**:WebUI 里「主题」(`ThemePicker.tsx:54`) + * 与「背景」(`BackgroundPicker.tsx:68`) 是**平级**的两段 `

`, + * 而鸿蒙这边只有「背景」有标题,主题那三个 chip 是裸的 —— + * 读起来像"外观"下面只有一个子段。⇒ 补「主题」小标题。 + * + * ③ 「背景」的标题用 `Theme.fontBody`(14) 且**不加粗**,与父级 + * 「外观」(14 Bold) 几乎同重 ⇒ 父子分不开。子段一律降到 `fontTiny`(11) + * + `textMuted`,与 WebUI 的 `text-xs text-gray-500` 同量级。 + */ Row() { Text('外观').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) Blank() @@ -1039,23 +1085,74 @@ export struct SettingsPane { } .width('100%') - Row() { + /* 子段标题:与 `SectionTitle()` 同一套(11px muted),保证"小标题"全局一个样子 */ + Text('主题') + .fontSize(11).fontColor(Theme.textMuted) + .width('100%').margin({ top: 12 }) + + /* + * ★★ 2026-09-29 改:**自绘 chip → 官方 `Chip`**(用户:「尽量使用官方组件而不是自绘」)。 + * + * ── 改前(自绘)── + * `Text` + 手写三元配色 + 手写 `borderRadius`/`padding`/`margin`/`onClick`。 + * 屏幕上一共有 **三套**这种自绘 chip(主题 / 背景档 / 预设), + * 而三套的圆角与内边距**各不相同**(主题与背景档用 `radiusControl`+12/6, + * 预设用 `borderRadius(4)`+8/4)—— 这就是用户说的「杂乱」: + * 同类控件在不同段落长得不一样,而且没人知道哪个是"对的"。 + * + * ── 改后(官方)── + * `Chip` 是 ArkUI 的高级组件(`@ohos.arkui.advanced.Chip`,API 11+), + * 天然表达"可选中的标签":`activated` 管选中态、`label.activatedFontColor` + * 管选中态前景、`accessibilitySelectedType: SELECTED` 让读屏软件 + * 按**单选**播报(WebUI 那边正是 `role="radio"` + `role="radiogroup"`, + * 见 `ThemePicker.tsx:74-80`)。 + * 尺寸/圆角/内边距由组件按系统规范给,我们不再手写 —— + * 三处 chip 从此**同一套外观**,"串味"的根源被去掉。 + * + * 配色的两条约束仍然照旧(这是 2026-09-19 真撞过的坑): + * ① 选中态压在 `Theme.accent` 蓝底上,前景必须是**不翻转**的 + * `accentFg`,不能用 `surface` —— 浅色下碰巧对、深色下几乎看不见。 + * 设备扫描当时报的是「深色 2.93:1 ink rgb(32,34,36) bg rgb(34,96,228)」。 + * ② 未选中态用 `surfaceMuted` 底 + `textPrimary` 字,也是原样保留。 + */ + /* + * ★ 间距走容器的 `space`,**不**在 `Chip()` 上链 `.margin()`: + * `Chip` 是官方 `@Builder` 函数(`declare function Chip(...): void`), + * 返回 void ⇒ 链任何属性都会编不过(实测 `Property 'margin' does not + * exist on type 'void'`)。这是它和 `Text`/`Button` 这类组件最不一样的地方。 + */ + Row({ space: 8 }) { ForEach(['system', 'light', 'dark'], (t: string) => { - Text(t === 'system' ? '跟随系统' : (t === 'light' ? '浅色' : '深色')) - .fontSize(13) + Chip({ + label: { + text: t === 'system' ? '跟随系统' : (t === 'light' ? '浅色' : '深色'), + fontSize: Theme.fontSmall, + fontColor: Theme.textPrimary, + activatedFontColor: Theme.accentFg + }, + activated: this.appearanceTheme === t, + backgroundColor: Theme.surfaceMuted, + activatedBackgroundColor: Theme.accent, + borderRadius: Theme.radiusControl, /* - * ★ 2026-09-19 修:同上 —— 选中态压在 `Theme.accent` 蓝底上, - * 前景必须是**不翻转**的 `accentFg`,不能用 `surface`。 - * ★ 这处是".fontColor(三元)"形状,判据的第一版正则只认 - * `.fontColor(Theme.X)`(令牌是唯一实参)⇒ **漏掉了它**, - * 直到设备扫描报出「深色 2.93:1 ink rgb(32,34,36) bg rgb(34,96,228)」。 + * ★★ `allowClose: false` 是**必需的**,别删。 + * 官方 `Chip` 的 `allowClose` **默认 true**(文档原话 + * "Default value: **true**")—— 不显式关掉,每个 chip 右边 + * 都会长出一个 ✕ 删除图标(设备截图实测:`跟随系统 ✕`、 + * `浅色 ✕`、`深色 ✕`)。那个图标是给"可删除的标签"用的, + * 而这三个是**单选**:✕ 既无意义,又让人以为能关掉主题。 */ - .fontColor(this.appearanceTheme === t ? Theme.accentFg : Theme.textPrimary) - .backgroundColor(this.appearanceTheme === t ? Theme.accent : Theme.surfaceMuted) - .borderRadius(Theme.radiusControl) - .padding({ left: 12, right: 12, top: 6, bottom: 6 }) - .margin({ right: 8 }) - .onClick(() => { this.setTheme(t); }) + allowClose: false, + /* + * 读屏按**单选**播报(WebUI 那边是 `role="radio"` + + * `role="radiogroup"`,见 `ThemePicker.tsx:74-80`)。 + * 枚举成员叫 `SELECTED` 而**不是** `RADIO` —— + * 三个成员是 CLICKED / CHECKED / SELECTED, + * `SELECTED` 的文档注释原话就是 "Radio type"。 + */ + accessibilitySelectedType: AccessibilitySelectedType.SELECTED, + onClicked: () => { this.setTheme(t); } + }) }, (t: string) => t) } .width('100%').margin({ top: 10 }) @@ -1076,7 +1173,6 @@ export struct SettingsPane { bgPresetId: $bgPresetId, bgDim: $bgDim, bgBlur: $bgBlur, - statusText: statusLabel(this.appearanceStatus), onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => { this.setBackground(kind, presetId, dim, blur); }, @@ -1249,20 +1345,55 @@ export struct SettingsPane { @Builder KeyRow(k: UserKey) { Row() { + /* + * ★★ 2026-09-29 修:备注换行把行高撑成两倍(设备实测)。 + * + * ── 症状 ── + * 长备注那一行的 `Text` 折成 **2 行**:设备布局 dump 里 + * 该行内容框 `[139,787][895,877]` 高 **90px**,而短备注那行 + * `[139,565][352,610]` 只有 **45px** —— 同一个列表里行高不一致, + * 观感上就是"这一行肿了"。备注是用户自填的,长度不可控 + * (底下的实测样本就是 `verify-l2(2026-09-12 轮换:旧的曾硬编码进 git) · 长期`)。 + * + * ── WebUI 怎么做的(`KeyPanel.tsx:249-256`)── + * · 提示码:`` —— **定宽 96px**,不参与压缩 + * · 备注:`
` —— **强制一行**,超出打省略号 + * 定宽让两列对齐,truncate 让行高恒定。 + * + * ── 这里照做 ── + * · `token_hint` 定宽 `flexShrink(0)`(WebUI 的 `w-24 shrink-0`) + * · 备注行 `maxLines(1)` + 省略号(WebUI 的 `truncate`) + * 宽度取 76 而不是 96:`w-24` 是桌面 px,手机可用宽度只有 ~437vp, + * 96 会占掉约 22%。76vp 刚好放下 11 字符(`token_hint` 是"前 8 位 + …", + * 见 `models.go:313`)——**改这个数要看设备截图,别照抄桌面值**。 + */ Column() { Text(k.token_hint) .fontSize(11).fontColor(Theme.textPrimary) + .width(76).flexShrink(0) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) Text((k.label.length > 0 ? k.label : '(无备注)') + ' · ' + this.keyTypeLabel(k.key_type)) .fontSize(11).fontColor(Theme.textMuted).margin({ top: 2 }) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1).alignItems(HorizontalAlign.Start) Text(this.keyState(k)) .fontSize(11) .fontColor(k.status === 'active' ? Theme.approveFg : Theme.textSubtleFor()) + .flexShrink(0) .margin({ right: 12 }) - Text('吹销') + /* + * ★★ 2026-09-29 修错别字:「吹销」→「吊销」。 + * + * WebUI 那一侧的按钮 `title` 是 **「吊销」**(`KeyPanel.tsx:308`), + * 服务端动作也叫 `revoke`(撤销授权)。「吹销」不是任何地方的说法 —— + * 它是打错的,而且**印在用户界面上**(设备截图里一眼可见)。 + * + * 全仓 grep 过:没有任何判据/文档引用「吹销」,所以改它不影响既有判据。 + */ + Text('吊销') .fontSize(12).fontColor(Theme.dangerFor()) .onClick(() => { this.revokeKey(k.key_id); }) }