fix(harmony): ★★ 三页 AppHeader 顶栏避让硬编码 0 ⇒ 顶栏压进系统状态栏(真机实测)

设备: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 起算,首项被画到容器上方。
This commit is contained in:
dsh
2026-10-01 18:49:06 +08:00
parent e76b78fa1d
commit 477479a370
4 changed files with 429 additions and 56 deletions

View File

@ -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 的写法)。');
});

View File

@ -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 修(用户:「你一改了之后,我列表都没法滚动了」)。
*

View File

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

View File

@ -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<void> {
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`) 是**平级**的两段 `<h3>`,
* 而鸿蒙这边只有「背景」有标题,主题那三个 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`)──
* · 提示码:`<code className="… w-24 shrink-0">` —— **定宽 96px**,不参与压缩
* · 备注:`<div className="… truncate">` —— **强制一行**,超出打省略号
* 定宽让两列对齐,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); })
}