跨端: 全屏是**窗口级**的 —— 光给 MainPage 让位,等于把黑边换成顶栏压字

上一提交(cac026e)把黑边消掉了,但**只给 `MainPage` 加了避让**。
实测截图硬证:写邮件页的「取消」与时钟「09:49」重叠、「发送」与 wifi/电量图标重叠。

## 根因:`setWindowLayoutFullScreen(true)` 不只作用于当前页

它是**窗口级**的:一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页
(写邮件/会话/收件箱/邮件详情/用户管理)都从 y=0 开始画。

所以上次那个错与更早那次(6861934 只删全屏不留避让)**同源**:
都是"同一件事只做了一半"。上次少的是**步骤**,这次少的是**页面**。

## 改法:把"消费避让"变成每个 @Entry 页都得做的事

- `model/WindowInsets.ts` 加 `topInset(insets)`:取 0 时(未全屏/取不到)
  表达式的值与旧代码**逐字相同** ⇒ 没全屏的环境行为不变,不会把谁顶下去。
- 五个页各按自己的形状让位:
  - 固定 56vp 顶栏(写邮件/会话/收件箱/用户管理):
    `height(56 + topInset(...))` **与** `padding(… top: topInset(...))` 一起加 ——
    只加 padding 会把固定的 56 切掉 39(按钮压扁),只加 height 则内容仍贴 y=0。
  - 满高容器(邮件详情):`padding({ top })` 加在 `@Entry` 包装层。
    ★ **不能加在 `MailDetailView` 里面**:它同时被 `MainPage` 的 Navigation 内嵌复用,
      而那层已经让过位了 —— 加在里面就变成让两次(39vp 变 78vp)。
      这类"同一组件两种入口"的坑与"悬浮加号要放在 Navigation 内部"同源:
      **让位的量取决于它被挂在哪一层**。

## 判据(harmony-window 8 → 9 条)

接线⑤ 枚举**所有** `@Entry` 页并要求它们消费避让 —— 口径是
"有人在窗口上开了全屏 ⇒ 每个 @Entry 页都得让",而不是"检查 MainPage 做了没有"。
后者在新增一个推上来的页时会静默逃掉,而"新增一个页"正是最常发生的事。

豁免要带**可机器复核**的理由(不再是"这个页先不管"):
- `Index.ets`:DevEco 模板欢迎页,不在 `main_pages.json` 流程里;
- `LoginPage.ets`:根容器 `.align(Alignment.Center)` ⇒ 结构上碰不到 y=0。
  但"居中"是可能被改掉的性质 ⇒ 判据**断言那个居中写法仍然存在**,
  谁把它改成贴顶,这条先红,逼他回来重新想这个页要不要避让。

**变异自检两个方向都跑过**:
- 把 ComposePage 避让整个拿掉(重演"只给 MainPage 加")⇒ 红 ✓
- 只加 padding 不加 height(会压扁按钮)⇒ 红 ✓

★ 期间还修掉两处"判据锚在当时的字符串上"(不是放宽,是它把"加一个正当的避让"
  与"真犯那个错"判得一模一样):
  - `harmony-nav` ④ 的留白断言、`harmony-widescreen` ④ 的 navReserve 断言,
    都改成剥注释后验**形状与不变量**,而不是写死字面表达式。改完变异仍咬得住。
This commit is contained in:
2026-09-18 10:34:00 +08:00
parent cac026e9e2
commit a87a88ea2a
8 changed files with 218 additions and 8 deletions

View File

@ -30,6 +30,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { prose } from './lib/read.mjs';
@ -165,6 +166,118 @@ test('★ 接线④:`.ets` 不许 import `WindowInsets` 的 SDK 类型(判
'WindowInsets.ts 必须保持纯净(无 SDK 依赖),否则判据连编都编不过');
});
test('★ 接线⑤:**每一个 `@Entry` 页**都要消费避让(全屏是窗口级的,不止 MainPage)', () => {
/*
* ── 这条为什么是全文件第二重要(2026-09-18 实测踩到)──
*
* `setWindowLayoutFullScreen(true)` 是**窗口级**的:一旦设上,窗口里**所有**
* 用 `router.pushUrl` 推上来的页都从 y=0 开始画,而不只是 `MainPage`。
*
* 我第一版只给 `MainPage` 加了避让 ⇒ 写邮件页实拍:「取消」与时钟「09:49」重叠、
* 「发送」与 wifi/电量图标重叠。**把黑边换成了另一种坏** —— 而且两个错同源:
* 都是"同一件事只做了一半"(上次少了避让,这次少了页面)。
*
* ★ 所以口径是「**有人在窗口上开了全屏 ⇒ 每个 `@Entry` 页都得让**」,
* 而不是「检查 MainPage 做了没有」。后者下新增一个推上来的页会静默逃掉 ——
* 而"新增一个页"正是最常发生的事。
*/
const pageDir = join(HARMONY, 'entry', 'src', 'main', 'ets', 'pages');
const entries = readdirSync(pageDir)
.filter(f => f.endsWith('.ets'))
.filter(f => /^@Entry\b/m.test(prose(join(pageDir, f))));
/*
* 自检:扫到的 `@Entry` 页数要 ≥ 5(`main_pages.json` 里登记 7 个)。
* 防的是"目录改名/遍历写错 ⇒ 这条变成空判据而依然全绿"——
* 与 `harmony-system-api` 里那条"扫到的文件数 ≥ N"同形。
*/
assert.ok(entries.length >= 5, `要扫到全部 @Entry 页(实际 ${entries.length}:${entries.join('、')})`);
/*
* 两种合法的"消费"写法:
* · `topInset(this.windowInsets)` —— 直白的(顶栏页用这个);
* · 直接读 `this.windowInsets.statusBar` —— MainPage 要的,它得把避让嵌进
* `isWide ? paneGap : statusBar` 这个条件表达式里,套不进 `topInset()`。
* 两者都算"把值用起来了";形状不同而要求相同(与 `harmony-nav` 那条同口径:
* 判的是"读到的东西有没有落到布局上",不是"用的哪个函数")。
*/
const consumes = (src) => /topInset\(\s*this\.windowInsets\s*\)/.test(src)
|| /this\.windowInsets\.statusBar/.test(src);
/*
* 豁免名单:每条都必须是**可机器复核的理由**,不是"这个页先不管"。
*
* `LoginPage` 的根容器是 `.align(Alignment.Center)` 的满高容器 —— 内容是**居中**的,
* 最顶上的那个像素也远在状态栏之下,结构上碰不到 y=0。所以它不需要避让。
*
* ★ 但"居中"是个可能被改掉的性质(哪天有人给它加个贴顶的标题栏,理由就没了)。
* 所以这里不只看一次理由字符串 —— 下面**断言那个居中写法仍然存在**:
* 谁把它改成贴顶,这条先红,逼他回来重新想这个页要不要避让。
* 这正是本仓那条"豁免得带可复核的理由"(`harmony-system-api` 的 ALLOW 同形)。
*/
const EXEMPT = new Map([
['Index.ets', { why: 'DevEco 模板欢迎页,不在 main_pages.json 的流程里,从不被路由到', check: null }],
['LoginPage.ets', {
why: '根容器 .align(Alignment.Center):内容居中,结构上碰不到 y=0',
check: (src) => /\.align\(Alignment\.Center\)/.test(stripped(src))
&& !/Alignment\.Top/.test(stripped(src))
}]
]);
const missing = [];
for (const f of entries) {
const src = prose(join(pageDir, f));
const exempt = EXEMPT.get(f);
if (exempt) {
if (exempt.check) {
assert.ok(exempt.check(src),
`${f} 被豁免的理由已不存在(原理由:${exempt.why})—— ` +
'要么恢复那个形状,要么把它当普通页消费避让,不要默默留着豁免');
}
continue;
}
if (!consumes(src)) missing.push(f);
}
assert.deepEqual(missing, [],
`这些 @Entry 页没有消费窗口避让:${missing.join('、')} —— ` +
'全屏是窗口级的,漏一个页就是一处「顶栏被时钟压住」(实测过:写邮件页的取消/发送压着 09:49 与电量)');
/*
* `\u2605` 顶栏那对"高度 + padding-top"只对**固定高度**的顶栏成立(`height(56)`)。
*
* `MailDetailPage` 是另一种形状:它的 `@Entry` 包装层是 `height('100%')` 的**满高列**,
* 避让用 `padding({ top: … })` 加在满高容器上(内容自然往下,没有"从 56 里切"的问题)——
* 这是满高容器的正确写法,不该被要求写成 `56 + topInset(...)`。
*
* 所以这里只对**真的写了固定 height(56) 顶栏**的页判那对同现,
* 其余页(满高容器)只要求"避让落到了布局上"(上面已经判过)。
*/
const halfDone = [];
for (const f of entries) {
if (EXEMPT.has(f)) continue;
const code_ = stripped(prose(join(pageDir, f)));
if (!/topInset\(\s*this\.windowInsets\s*\)/.test(code_)) continue; // 不是顶栏形状(MainPage)
/*
* 只看**同一个修饰符链**:包含 `padding(top: topInset(...))` 的那一段往前找它的 `.height(...)`。
*
* 不能满文件搜 `height(56)` —— `MailDetailPage` 里的悬浮回复球是 `.width(56).height(56)`,
* 与顶栏毫无关系(我第一版就是这么误报的:把一个 56×56 的圆球当成了"固定 56 顶栏")。
* 判据要锚在"这一段的形状"上,而不是"文件里有没有出现过这个数"。
*/
const pad = /\.padding\(\{[^}]*top:\s*topInset\(\s*this\.windowInsets\s*\)[^}]*\}\)/.exec(code_);
if (!pad) continue;
const chain = code_.slice(Math.max(0, pad.index - 300), pad.index + pad[0].length);
const hasFixed56 = /\.height\(\s*56\s*\)/.test(chain);
if (!hasFixed56) continue; // 满高容器形状(MailDetailPage):没有从 56 里切的问题
const heightGrew = /\.height\(56 \+ topInset\(\s*this\.windowInsets\s*\)\)/.test(chain);
if (!heightGrew) {
halfDone.push(f);
}
}
assert.deepEqual(halfDone, [],
`这些页的顶栏没把"高度 + padding-top"一起加:${halfDone.join('、')} —— ` +
'只加一个会把固定 56vp 切掉一截(按钮压扁)或让位没生效(内容仍贴 y=0)');
});
/* ───────────────────────── ③ 设备行为(有设备才跑)───────────────────────── */
/*

View File

@ -82,7 +82,7 @@ const SUITE = [
// `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。
// 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天);
// 只做后半 ⇒ 黑边照旧。**两半缺哪一半都要判红**,所以变异自检两个方向都跑过。
['test/harmony-window.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8],
['test/harmony-window.test.mjs', ['--experimental-strip-types', '--no-warnings'], 9],
// 外观契约:默认值去 Go 源码里读(服务端 DefaultAppearance 是权威)+ 缓存键按账号
['test/appearance-defaults.test.mjs', [], 4],
['test/build-stamp.test.mjs', [], 7],

View File

@ -120,3 +120,27 @@ export interface AvoidAreaLike {
topRect?: InsetsRect;
bottomRect?: InsetsRect;
}
/**
* 顶栏该往下让多少 —— **每一个 `@Entry` 页都要用这个值**。
*
* ── 为什么这不能只是 `MainPage` 的事(2026-09-18 实测踩到)──
*
* `setWindowLayoutFullScreen(true)` 是**窗口级**的:一旦设上,窗口里**所有**
* 用 `router.pushUrl`/`replaceUrl` 推上来的页(`ComposePage`/`SessionsPage`/
* `MailDetailPage`/`AdminUsersPage`)都从 y=0 开始画,而不只是 `MainPage`。
*
* 所以只给 `MainPage` 加避让 = **把黑边换成了另一种坏**:同一次实测截图里,
* 写邮件页的「取消」与时钟「09:49」重叠、「发送」与 wifi/电量图标重叠。
* 这正是上次退回的那种错(只做一半)—— 只是这次"一半"是**页面**而不是步骤。
*
* ★ 所以判定口径是:**只要有人在窗口上开了全屏,每个 `@Entry` 页就必须消费它**。
* 判据 `harmony-window.test.mjs` 按这个口径枚举所有 `@Entry` 页 ——
* 不是"检查 MainPage 做了没有"(那样新增一个推上来的页就静默逃掉)。
*
* ★ 取 0 时(未全屏/取不到避让)这个函数就是 0,页面表现与修之前完全一样 ——
* 即"没用上就退回到旧行为",不会把一个不知情的页顶下去。
*/
export function topInset(insets: Insets | undefined): number {
return insets !== undefined && insets.statusBar > 0 ? insets.statusBar : 0;
}

View File

@ -36,11 +36,23 @@ import { toggled, lastLoginLabel, messageOfApiError, isAdminRole, isRestricted }
import { AmIcon } from '../common/Icons';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { LengthMetrics } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
@Entry
@Component
struct AdminUsersPage {
@State users: AdminUser[] = [];
/**
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
*
* ★ 推上来的页**也要**消费它:`setWindowLayoutFullScreen(true)` 是**窗口级**的,
* 一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页都从 y=0 开始画。
* 本页顶栏原来贴 y=0 ⇒ 全屏后「取消/发送」会与时钟、wifi/电量图标重叠
* (2026-09-18 实测截图硬证)。只给 `MainPage` 加避让就是把"黑边"换成
* "顶栏被状态栏压住",同属只做一半。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@State scopes: AdminScopes = new AdminScopes();
@State loading: boolean = false;
@State errorText: string = '';
@ -337,7 +349,8 @@ struct AdminUsersPage {
AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accent }).width(40).height(40)
.onClick(() => { this.load(); })
}
.width('100%').height(56).padding({ left: 8, right: 8 })
/* 高度与 padding-top 一起加避让 —— 理由见 `ComposePage` 顶栏(同形状) */
.width('100%').height(56 + topInset(this.windowInsets)).padding({ left: 8, right: 8, top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
}

View File

@ -12,11 +12,23 @@ import { ComposeParams } from '../model/RouteParams';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { picker } from '@kit.CoreFileKit';
import { AmIcon } from '../common/Icons';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
@Entry
@Component
struct ComposePage {
@State to: string = '';
/**
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
*
* ★ 推上来的页**也要**消费它:`setWindowLayoutFullScreen(true)` 是**窗口级**的,
* 一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页都从 y=0 开始画。
* 本页顶栏原来贴 y=0 ⇒ 全屏后「取消/发送」会与时钟、wifi/电量图标重叠
* (2026-09-18 实测截图硬证)。只给 `MainPage` 加避让就是把"黑边"换成
* "顶栏被状态栏压住",同属只做一半。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@State subject: string = '';
@State body: string = '';
@State replyTo: string = '';
@ -198,8 +210,15 @@ struct ComposePage {
.enabled(!this.sending && !this.uploading)
.onClick(() => { this.doSend(); })
}
.width('100%').height(56)
.padding({ left: 12, right: 12 })
/*
* ★ 高度与 padding-top 一起加避让:只加 padding 会把 56 里切掉 39,
* 按钮被压扁;只加 height 内容还是贴着状态栏。
* 背景照常铺到 y=0(`backgroundColor` 画在 border box 上,含 padding 区),
* 所以黑边不会回来 —— 「背景铺满、内容让开」正是 `MainPage` 的同一口径。
* 取 0 时(未全屏)表达式的值与旧代码**逐字相同**:height=56、无 top。
*/
.width('100%').height(56 + topInset(this.windowInsets))
.padding({ left: 12, right: 12, top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
Divider().color(Theme.border)

View File

@ -10,11 +10,23 @@ import { router } from '@kit.ArkUI';
import { AmIcon } from '../common/Icons';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { LengthMetrics } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
@Entry
@Component
struct InboxPage {
@State mails: MailSummary[] = [];
/**
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
*
* ★ 推上来的页**也要**消费它:`setWindowLayoutFullScreen(true)` 是**窗口级**的,
* 一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页都从 y=0 开始画。
* 本页顶栏原来贴 y=0 ⇒ 全屏后「取消/发送」会与时钟、wifi/电量图标重叠
* (2026-09-18 实测截图硬证)。只给 `MainPage` 加避让就是把"黑边"换成
* "顶栏被状态栏压住",同属只做一半。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@State loading: boolean = false;
@State limit: number = 20;
@State total: number = 0;
@ -78,9 +90,10 @@ struct InboxPage {
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
}
/* 高度与 padding-top 一起加避让 —— 理由见 `ComposePage` 顶栏(同形状) */
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.height(56 + topInset(this.windowInsets))
.padding({ left: 16, right: 16, top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
// 列表

View File

@ -20,6 +20,7 @@ import {
mailReplyTarget,
formatAddress
} from '../model/ReplyTarget';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
/**
* 本地时间:与 WebUI `new Date(x).toLocaleString('zh-CN')` 同一口径。
@ -553,10 +554,24 @@ export struct MailDetailView {
@Entry
@Component
struct MailDetailPage {
/**
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
*
* ★ 避让加在**这个 `@Entry` 包装层**,**不在** `MailDetailView` 里面:
* 同一个 `MailDetailView` 还被 `MainPage` 的 `Navigation` 内嵌复用
* (`MailDetailDestination`),而 `MainPage` 已经给内容层加过避让了 ——
* 加在里面就变成**让两次**(39vp 变 78vp,白白多一条空档)。
* 这类"同一个组件两种入口"的坑与 `harmony-nav` 里那条"悬浮加号要放在
* Navigation 内部、不能当它的兄弟"同源:**让位的量取决于它被挂在哪一层**。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
build() {
Column() {
MailDetailView()
}
.width('100%').height('100%')
.padding({ top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
}
}

View File

@ -8,11 +8,23 @@ import { MailApi } from '../api/MailApi';
import { Session } from '../model/Models';
import { LIST_FADE_LENGTH } from '../model/NavItems';
import { LengthMetrics } from '@kit.ArkUI';
import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
@Entry
@Component
struct SessionsPage {
@State sessions: Session[] = [];
/**
* 窗口避让区 —— 由 `EntryAbility.setupFullScreenWindow` 写进 AppStorage。
*
* ★ 推上来的页**也要**消费它:`setWindowLayoutFullScreen(true)` 是**窗口级**的,
* 一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页都从 y=0 开始画。
* 本页顶栏原来贴 y=0 ⇒ 全屏后「取消/发送」会与时钟、wifi/电量图标重叠
* (2026-09-18 实测截图硬证)。只给 `MainPage` 加避让就是把"黑边"换成
* "顶栏被状态栏压住",同属只做一半。
*/
@StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets();
@State loading: boolean = false;
@State error: string = '';
@ -54,8 +66,9 @@ struct SessionsPage {
Text('会话')
.fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
}
.width('100%').height(56)
.padding({ left: 16 })
/* 高度与 padding-top 一起加避让 —— 理由见 `ComposePage` 顶栏(同形状) */
.width('100%').height(56 + topInset(this.windowInsets))
.padding({ left: 16, top: topInset(this.windowInsets) })
.backgroundColor(Theme.surface)
if (this.loading) {