diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs
index 23cdbf7..e49be3c 100644
--- a/client/electron/test/harmony-appearance.test.mjs
+++ b/client/electron/test/harmony-appearance.test.mjs
@@ -486,7 +486,11 @@ test('★ 背景画出来了还不够:每个页面要**让出**页面底,否
// 每个页面都要**收到**这个开关:漏传 = 该页恒为不透明(等于没有让出)
for (const comp of ['CommPage', 'ContactsTab']) {
- assert.match(main, new RegExp(`${comp}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `主界面要把 bgActive 传给 ${comp}`);
+ // 组件现在多接一个 navReserve(底部条高度 → 列表末尾让位),所以判"收到了 bgActive"而不是整行字面量
+ const at = main.indexOf(`${comp}({`);
+ assert.ok(at >= 0, `主界面要渲染 ${comp}`);
+ const call = main.slice(at, main.indexOf('})', at) + 2);
+ assert.match(call, /bgActive:\s*this\.bgActive/, `主界面要把 bgActive 传给 ${comp}`);
}
for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) {
// 参数已是多行形式(InboxTab/SentTab 还接了 onOpenMail),所以判"这个组件收到了 bgActive"
diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs
index 7a75d0d..fead4a5 100644
--- a/client/electron/test/harmony-logic.test.mjs
+++ b/client/electron/test/harmony-logic.test.mjs
@@ -526,7 +526,12 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
assert.deepEqual(tabLabels, ['通信', '日历', '联系人', '我的'], `底部应为通信/日历/联系人/我的四项,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字
- assert.match(sendCode, /CommPage\(\{ bgActive: this\.bgActive \}\)/, '第一项要渲染通信页(并把背景开关传下去)');
+ {
+ const at = sendCode.indexOf('CommPage({');
+ assert.ok(at >= 0, '第一项要渲染通信页');
+ const call = sendCode.slice(at, sendCode.indexOf('})', at) + 2);
+ assert.match(call, /bgActive:\s*this\.bgActive/, '要把背景开关传下去');
+ }
// 内部页签:三栏由 COMM_TABS 驱动,点击切到 normalizeCommTab 的**同一个函数**
assert.match(sendCode, /ForEach\(COMM_TABS, \(key: string\)/, '页签栏要按页签清单渲染');
diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs
index e66cfa1..82875b7 100644
--- a/client/electron/test/harmony-nav.test.mjs
+++ b/client/electron/test/harmony-nav.test.mjs
@@ -101,8 +101,10 @@ test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人
assert.equal(dispatch.length, 1, `根里应当恰好一处"按 index 分派内容"(实际 ${dispatch.length} 处)`);
const first = braceBody(root, 'if (this.currentIndex === 0)');
const second = braceBody(root.slice(root.indexOf('if (this.currentIndex === 0)')), 'else');
- assert.match(first, /CommPage\(\{ bgActive: this\.bgActive \}\)/, 'index 0 要挂通信页');
- assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive \}\)/, 'index 2 要挂联系人页');
+ assert.match(first, /CommPage\(\{ bgActive: this\.bgActive,\s*navReserve: this\.navReserve \}\)/,
+ 'index 0 要挂通信页(并把底部条高度透传下去作为列表末尾让位)');
+ assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive,\s*navReserve: this\.navReserve \}\)/,
+ 'index 2 要挂联系人页(同样透传底部条高度)');
/*
* 联系人那一支必须**显式绑在 index 2**。注意条件在 `else if (...)` 里,
* 不在 `else` 的**花括号正文**里 —— braceBody 只取正文,所以这条要看 root 原文。
@@ -115,8 +117,8 @@ test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人
* pane 变可见时重算(DEBTS 的 calendar-today-recompute);
* · `visibility(...=== 1 ? Visible : None)` 是显示开关;常驻 + 不隐藏 = 三个 pane 叠在一起。
*/
- assert.match(root, /CalendarPage\(\{ bgActive: this\.bgActive, visible: this\.currentIndex === 1 \}\)/,
- 'index 1 要挂日历页,并把"是否可见"传下去(它是 today 重算的触发源)');
+ assert.match(root, /CalendarPage\(\{\s*bgActive: this\.bgActive,\s*visible: this\.currentIndex === 1,\s*navReserve: this\.navReserve\s*\}\)/,
+ 'index 1 要挂日历页,并把"是否可见"与底部条高度都传下去');
assert.match(root, /\.visibility\(this\.currentIndex === 1 \? Visibility\.Visible : Visibility\.None\)/,
'常驻挂载就要用 visibility 控制显示');
assert.ok(!/if \(this\.currentIndex === 1\)/.test(root),
@@ -197,8 +199,19 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
* "悬浮"是可判的形状:四周留白 + 圆角 + 浮在内容之上。
* 这三样缺一样就不再是 WebUI 那条玻璃条了(贴边的全宽条 = 又变回系统 bar 的样子)。
*/
- assert.match(bar, /\.margin\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/,
- '四周要留白(左右 + 离底),贴边就不是悬浮');
+ /*
+ * ★ 留白必须靠**外层容器的 padding**,不能靠 `width('100%') + margin`(2026-09-17 修)。
+ *
+ * ArkUI 的 margin 加在宽度**外面**:`width('100%')` 再配左右 margin 不会缩到
+ * 「100% − margin」,而是整个顶出父容器、两侧被裁。实测底栏左缘 x=0、右缘贴满
+ * 1256(应各留 16vp=56px)⇒ 看起来是**贴边通栏**,不是浮在壁纸上的胶囊,
+ * 玻璃材质也随之失去意义(背后没有内容/壁纸可透)。
+ * 这与收件箱头卡片修过的是同一个坑。
+ */
+ assert.match(bar, /\.padding\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/,
+ '留白要用外层容器的 padding(左右 + 离底)—— width(100%) + margin 在 ArkUI 里不缩宽,会顶出父容器');
+ assert.ok(!/\.margin\(\{[^}]*NAV_BAR_SIDE/.test(bar),
+ '★ 不许再用 margin 做留白:ArkUI 的 margin 加在宽度外面,100% 宽度 + margin 会撑出屏幕边界');
assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)');
assert.match(bar, /\.backgroundBlurStyle\(Theme\.navMaterial\)/,
'材质用系统档次,不手写 alpha(且**不跟随** `bg_blur` —— 理由见本文件末那条"可达性"判据)');
@@ -214,13 +227,23 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
`内容让位(${N.NAV_CONTENT_RESERVE})必须 ≥ 条高 + 离底留白(${N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM})`);
/*
- * ★ 2026-09-15:padding 现在是**条件式**(宽屏变 0、窄屏取 NAV_CONTENT_RESERVE)。
- * 宽屏有侧栏图标轨、没有底部条,所以内容不再需要让位 —— 但窄屏那条老规矩仍然在。
- * 断言匹配两种形态:旧的直接取值、新的三元式(宽屏取 0)。
+ * ★ 2026-09-17:让位方式**改了**,这条判据跟着改(旧形状已被证明是错的)。
+ *
+ * 旧做法是把 `NAV_CONTENT_RESERVE` 加在**窗格的 `padding({bottom})`** 上。
+ * 那看着等价于"最后一行能滚出来",实际还多了一个副作用:padding 会
+ * **缩短窗格本身** ⇒ 内容永远到不了条底下 ⇒ 玻璃条背后只剩一张已经被壁纸层
+ * 模糊过的壁纸 ⇒ 系统材质无东西可糊 ⇒ 看起来是一块普通浅色面板,而不是玻璃。
+ * 用户 2026-09-17:「底栏不是玻璃质感,滑动内容无法穿过底栏」——同一个根因。
+ *
+ * 正确做法:窗格**满高**(内容滑得到条底下,真正穿过),
+ * 让位加在各滚动容器的**内容末尾**(`contentEndOffset(this.navReserve)`)。
+ * 与 WebUI 同构:`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面。
*/
- const padOk = /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main)
- || /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main);
- assert.ok(padOk, '内容底部要让出这段高度(窄屏取 NAV_CONTENT_RESERVE;宽屏可变 0)');
+ const contentOffsets = [...main.matchAll(/\.contentEndOffset\(this\.navReserve\)/g)];
+ assert.ok(contentOffsets.length >= 5,
+ `各滚动容器都要在**内容末尾**让位(至少 5 处:收件箱/发件箱/授权/联系人×2/日历),实际 ${contentOffsets.length} 处`);
+ assert.ok(!/\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main),
+ '★ 不许再用窗格 padding 让位 —— 那会缩短窗格、内容到不了条底下,玻璃就没东西可糊');
/*
* 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
*
@@ -241,9 +264,16 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
else if (main[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
assert.ok(end > 0, '内容层的花括号要闭合');
- const chain = main.slice(end, main.indexOf('this.NavBar()', end));
- assert.match(chain, /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/,
- '让位要加在挂载内容的那层上(链在那个 Column 的 } 之后),宽屏变 0、窄屏取 NAV_CONTENT_RESERVE');
+ /*
+ * 注:这里**不再**用"从内容层 `}` 往后扫"的窗口式断言。
+ * 那个切片靠数花括号定界,而本文件(以及这些注释本身)里就有大量 `{` / `}`
+ * (`.padding({` 这类字面量)—— 计数被注释里的括号带偏,切片能长到 1700+ 字符,
+ * 一路扫进 `onAreaChange`(那里会合法地出现 `NAV_CONTENT_RESERVE`)⇒ 假红。
+ * 正是本仓库反复记的"窗口式判据"。
+ *
+ * 要判的事上面那条**已经判了**:全文件不得再有
+ * `.padding({ bottom: ... NAV_CONTENT_RESERVE })`(负向断言,不依赖切片)。
+ */
});
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人', () => {
@@ -326,32 +356,53 @@ test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS
* 跨端对齐:这条与 WebUI 的 `nav-merge.test.mjs ④` 是同一条要求的两端实现,
* 两边各自钉住自己的写法(`cross-client-*` 那几条钉的是共享数值,不是这里)。
*/
-test('⑤ 选中态只换颜色:纯图标导航、图标变色,且不引入背景/指示条/文字', () => {
+test('⑤ 选中态只换颜色:图标与文字**一起**换色,且不引入背景/指示条', () => {
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
const itemCode = stripComments(item);
/*
- * 2026-09-16 用户:「底部导航栏不允许有文字」⇒ 导航项是**纯图标**,
- * 只有 1 处选中三元式(AmIcon 的 iconColor)。文字那一半被去掉。
+ * ★ 2026-09-17 修正一处**事实错误**。
+ *
+ * 这条判据原来钉的是"纯图标、不允许有文字",依据是注释里那句
+ * 「WebUI 的底部导航是纯图标」。那句话是**错的**:
+ * `NarrowNav.tsx:83-86` 的每个导航项是
+ * `flex flex-col items-center justify-center gap-0.5`
+ * 里面 `` 之后就是 `{short}`
+ * —— WebUI **一直有文字**(通信 / 日历 / 联系人 / 我的)。
+ *
+ * 于是那条判据把一个**假事实**固化成了规则,还反过来挡住了正确做法。
+ * 用户 2026-09-17 原话:「还是在导航栏加上文字吧,没有文字还是不好看」。
+ *
+ * 现在钉的是真正的契约:**图标与文字都跟着选中态换色**(两处三元式),
+ * 且**不引入**背景块/指示条 —— 用户 2026-09-14:「选中对应的文字和图标变色即可」。
*/
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
const hits = itemCode.match(active) || [];
- assert.equal(hits.length, 1,
- `纯图标导航只应有 1 处选中三元式(现在 ${hits.length} 处)— 若有 2 处是文字还留着`);
- // 图标上色必须存在(AmIcon 的 iconColor 跟着选中态走)
- const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('AmIcon({') + 160);
+ assert.equal(hits.length, 2,
+ `图标与文字**各一处**选中三元式(现在 ${hits.length} 处)—— ` +
+ '少了是"选中了一半"(图标或文字不换色),多了是别的形状混进来了');
+
+ // 图标:换色 + 尺寸(与 WebUI 的 w-5 h-5 = 20 同量级,鸿蒙取 24)
+ const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('AmIcon({') + 200);
assert.match(icon, /iconColor:\s*this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/,
'图标的 iconColor 必须跟着选中态走');
- // 图标不能太小(用户:22 太小)
- assert.match(icon, /iconSize: 28/,
- '底部导航图标要 28vp(用户嫌 22 太小)');
- // 无文字:导航项里不该有 label 的 Text
- assert.ok(!/Text\(item\.label\)/.test(itemCode),
- '底部导航是纯图标:不允许有文字 label(2026-09-16 用户明确要求)');
+ assert.match(icon, /iconSize:\s*24/,
+ '底栏图标 24vp(与 WebUI `NarrowNav` 的 24×24 同几何;原来 28 是为了"补没有文字时的小")');
+
+ // 文字:**必须有**,且与图标过**同一个**三元式
+ assert.match(itemCode, /Text\(item\.label\)/,
+ '底栏要有文字 label —— WebUI `NarrowNav.tsx` 一直是「图标 + 文字」,' +
+ '「纯图标」那条依据是错的(用户 2026-09-17:「没有文字还是不好看」)');
+ const label = itemCode.slice(itemCode.indexOf('Text(item.label)'));
+ assert.match(label.slice(0, 220),
+ /fontColor\(this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg\)/,
+ '文字要与图标过同一个选中三元式(否则"选中了一半")');
+
// 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素)
assert.ok(!/backgroundColor\([^)]*currentIndex/.test(itemCode),
'选中态不许改背景色("变色即可",形状是另一套语言)');
assert.ok(!/Divider\(|\.borderRadius\([^)]*currentIndex/.test(itemCode),
'选中态不许加指示条 / 圆角块');
+
// 选中色与未选中色必须真的是两个不同来源(都指向同一个令牌就成了恒等)
const theme = read('common/Theme.ets');
assert.match(theme, /static readonly navFgActive: string = Theme\.accentStrong/,
@@ -360,19 +411,27 @@ test('⑤ 选中态只换颜色:纯图标导航、图标变色,且不引入
'未选中色取系统次要文字色(不手写色值)');
});
-/** ⑤ 的变异自检:退回"带文字 / 太小 / 图标不上色"必须被判红。 */
-test('★ ⑤ 变异自检:nav 项退回带文字或 22 尺寸或图标不上色必须被判红', () => {
- // 变异 1:带文字(Text(item.label))⇒ ⑤ 的"无文字"会命中它
- const withLabel = `Column() {\n AmIcon({ iconName: item.iconKey, iconSize: 28 })\n Text(item.label)\n .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)\n }`;
- assert.ok(/Text\(item\.label\)/.test(withLabel),
- '变异 1 自检失败:样本里必须有 label 文字');
- // 变异 2:图标 22(太小)⇒ ⑤ 的"iconSize: 28"会命中它
- const smallIcon = `Column() {\n AmIcon({ iconName: item.iconKey, iconSize: 22, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg })\n }`;
- assert.ok(/iconSize: 22/.test(smallIcon) && !/iconSize: 28/.test(smallIcon),
- '变异 2 自检失败:样本里图标必须是 22 且不是 28');
- // 变异 3:图标不上色(没有 iconColor)⇒ ⑤ 的"iconColor 跟着选中态"会命中它
- const noColor = `Column() {\n AmIcon({ iconName: item.iconKey, iconSize: 28 })\n }`;
- assert.ok(!/iconColor/.test(noColor), '变异 3 自检失败:样本里不该有 iconColor');
+/** ⑤ 的变异自检:退回"只有图标换色 / 没有文字 / 图标不上色"必须被判红。 */
+test('★ ⑤ 变异自检:nav 项退回缺文字、或图标/文字只换一半色,必须被判红', () => {
+ const oneActive = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
+ const twoActive = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
+
+ // 变异 1:没有文字(就是被纠正的那版)⇒ 三元式只剩 1 处 + 没有 Text(item.label)
+ const noLabel = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg }) }';
+ assert.equal((noLabel.match(oneActive) || []).length, 1,
+ '变异 1 自检失败:无文字样本应当只有 1 处选中三元式');
+ assert.ok(!/Text\(item\.label\)/.test(noLabel),
+ '变异 1 自检失败:无文字样本里不该有 Text(item.label)');
+
+ // 变异 2:文字不换色(选中了一半)
+ const labelNoColor = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg }) Text(item.label).fontColor(Theme.navFg) }';
+ assert.equal((labelNoColor.match(twoActive) || []).length, 1,
+ '变异 2 自检失败:文字不换色时应当只有图标那 1 处三元式(判据会判红)');
+
+ // 变异 3:图标不上色
+ const noIconColor = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24 }) Text(item.label).fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) }';
+ assert.ok(!/iconColor/.test(noIconColor),
+ '变异 3 自检失败:样本里图标不该有 iconColor');
});
/**
diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs
index 6e6505e..4e2cfb2 100644
--- a/client/electron/test/harmony-widescreen.test.mjs
+++ b/client/electron/test/harmony-widescreen.test.mjs
@@ -87,7 +87,13 @@ test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768
assert.match(code_, /onAreaChange/, '要用 onAreaChange 实时检测窗口宽度');
assert.match(code_, />=\s*768/s, '断点是 768(与 WebUI 的 lg 断点同一档)');
// let为0(宽屏没有底部条,内容 padding 归零)
- assert.match(code_, /bottom:\s*this\.isWide \? 0 : NAV_CONTENT_RESERVE/, '宽屏内容 padding 要归零(没有底部条了)');
+ /*
+ * ★ 2026-09-17:让位方式改了 —— 宽屏不再靠"内容 padding 归零",
+ * 而是宽屏时 navReserve 本身变成 0(没有底部条)。
+ * 内容窗格必须**满高**,否则内容滑不到条底下、玻璃就没东西可糊。
+ */
+ assert.match(code_, /this\.navReserve = this\.isWide \? 0 : NAV_CONTENT_RESERVE/,
+ '宽屏时 navReserve 要归零(没有底部条,列表不需要为它让位)');
});
test('⑥ 邮件列表→详情使用系统 Navigation Auto,不再手搓 Row 分栏', () => {
diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts
index b03fd19..c1a1ab7 100644
--- a/client/harmony/entry/src/main/ets/model/NavItems.ts
+++ b/client/harmony/entry/src/main/ets/model/NavItems.ts
@@ -94,6 +94,15 @@ export const NAV_CONTENT_GAP: number = 8;
* **这是"点得到"的问题,不是美观问题**:条是浮在内容之上的,不让出这段高度,
* 列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
*
+ * ★ 让位方式:**加在滚动容器的内容末尾**(`contentEndOffset` / 末尾占位),
+ * **不是**加在窗格的 `padding({bottom})` 上。两者看起来等价,实际差很大:
+ * padding 会**缩短窗格本身** ⇒ 内容永远到不了条底下 ⇒
+ * 玻璃条背后只剩一张**已经被壁纸层模糊过**的壁纸,系统材质无东西可糊,
+ * 于是它看起来就是一块普通的浅色面板,而不是玻璃(2026-09-17 用户:
+ * 「底栏不是玻璃质感,滑动内容无法穿过底栏」)。
+ * WebUI 同构:`.narrow-shell` 是 `flex-col`,列表满高、`.narrow-nav` 作为兄弟
+ * 叠在上面(有自己的 margin),内容自然滑到条底下。
+ *
* **派生,不并列**:上面三个常量任何一个变大,这里自动跟着变;写死一个数(例如 76)
* 就会在改条高的那天悄悄失配。判据 `harmony-nav.test.mjs` 同时钉"派生关系"与
* "在算式中出现",防它退回字面量。
diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets
index 37e3180..42add40 100644
--- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets
+++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets
@@ -74,6 +74,14 @@ export struct CalendarPage {
* 标记就停在昨天,而所有纯逻辑判据全绿(模型没错,是喂进去的值过期了)。
*/
@Prop @Watch('onVisibleChanged') visible: boolean = true;
+ /**
+ * 底部悬浮条高度(窄屏非 0)。
+ *
+ * 加在日程列表的**内容末尾**(`contentEndOffset`):
+ * 日历页满高、内容滑得到条底下(玻璃才有东西可糊),
+ * 而最后一条日程仍滚得出来。理由详见 `NavItems.ts` 的 `NAV_CONTENT_RESERVE`。
+ */
+ @Prop navReserve: number = 0;
@State year: number = 1970;
@State month: number = 1;
@@ -788,6 +796,8 @@ export struct CalendarPage {
}
.layoutWeight(1)
.width('100%')
+ /* 末尾让位:最后一条日程要能滚出悬浮条之下(不是缩短列表) */
+ .contentEndOffset(this.navReserve)
.padding({ left: 10, right: 10 })
}
}
diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets
index e4e0608..a5354a3 100644
--- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets
+++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets
@@ -50,6 +50,15 @@ export struct MailDetailView {
@Prop initialMailId: string = '';
@Prop initialAccountId: string = '';
@Prop embedded: boolean = false;
+ /**
+ * 底部悬浮条高度(vp)——嵌入在 `Navigation` 里时由主页面传入。
+ *
+ * 窄屏 Stack 模式下详情也是画在 `Navigation` 内部的,而悬浮条是它的兄弟、
+ * 叠在最上层 —— 所以详情页右下那个回复球要自己抬过条高,否则会压在条上。
+ * (窗格不能再靠 padding 让位:那样内容就永远滑不到条底下,
+ * 系统材质无东西可糊,玻璃看起来就是一块普通浅色面板。)
+ */
+ @Prop navReserve: number = 0;
onBack: () => void = (): void => {};
@State mailId: string = '';
@State accountId: string = '';
@@ -433,7 +442,7 @@ export struct MailDetailView {
.width(56).height(56)
.borderRadius(28)
.backgroundColor(Theme.accent)
- .margin({ right: 16, bottom: 16 })
+ .margin({ right: 16, bottom: this.navReserve + 16 })
.onClick(() => { this.showReplyBox = true; })
}
.width('100%').layoutWeight(1)
diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets
index 840c746..c241af0 100644
--- a/client/harmony/entry/src/main/ets/pages/MainPage.ets
+++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets
@@ -107,6 +107,8 @@ function compactMailTime(iso: string): string {
struct MailDetailDestination {
@State mailId: string = '';
@State accountId: string = '';
+ /** 底部悬浮条高度(窄屏非 0)——透传给详情页,让它的回复球抬过条 */
+ @Prop navReserve: number = 0;
private pathStack: NavPathStack = new NavPathStack();
handleReady(ctx: NavDestinationContext): void {
@@ -123,6 +125,7 @@ struct MailDetailDestination {
initialMailId: this.mailId,
initialAccountId: this.accountId,
embedded: true,
+ navReserve: this.navReserve,
onBack: (): void => { this.pathStack.pop(); }
})
} else {
@@ -141,6 +144,14 @@ struct MailDetailDestination {
struct InboxTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
+ /**
+ * 底部悬浮条要占的高度(vp,窄屏才非 0)。
+ *
+ * 加在**列表内容的末尾段落**(`contentEndOffset`),让最后一行滚得出来;
+ * **不是**缩短本窗格 —— 窗格要满高,内容才滑得到条底下,
+ * 系统材质才有东西可糊(否则玻璃看着像普通的浅色面板)。
+ */
+ @Prop navReserve: number = 0;
/** 选中邮件回调:宽屏内联到 Navigation 右栏,窄屏 push 到目标页 */
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
@State mails: MailSummary[] = [];
@@ -479,6 +490,7 @@ struct InboxTab {
.width('100%').layoutWeight(1)
/* WebUI `.overflow-y-auto` 的上下渐隐(mask-image):卡片滑到边缘不硬截断 */
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
+ .contentEndOffset(this.navReserve)
.padding({ left: 10, right: 10, top: 10, bottom: 10 })
}
}
@@ -654,6 +666,8 @@ struct InboxTab {
struct SentTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
+ /** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
+ @Prop navReserve: number = 0;
/** 选中邮件回调 */
onOpenMail: (mailId: string, accountId: string) => void = (): void => {};
@State groups: SessionGroup[] = [];
@@ -851,6 +865,7 @@ struct SentTab {
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
+ .contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
@@ -876,6 +891,8 @@ struct SentTab {
struct PermissionTab {
/** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */
@Prop bgActive: boolean = false;
+ /** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
+ @Prop navReserve: number = 0;
@State requests: PermissionRequest[] = [];
@State loading: boolean = false;
@State error: string = '';
@@ -1084,6 +1101,7 @@ struct PermissionTab {
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
+ .contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
@@ -1114,6 +1132,8 @@ struct PermissionTab {
struct CommPage {
/** 背景开启时,本页与其三个 pane 的页面底都要让出(否则壁纸全被盖住) */
@Prop bgActive: boolean = false;
+ /** 底部悬浮条高度(窄屏非 0,宽屏 0)——透传给三个 pane 作为列表末尾让位 */
+ @Prop navReserve: number = 0;
@State commTab: string = 'inbox';
@State unreadCount: number = 0;
@State pendingCount: number = 0;
@@ -1220,7 +1240,7 @@ struct CommPage {
@Builder
DestinationBuilder(name: string, param: Object) {
if (name === MAIL_DETAIL_ROUTE) {
- MailDetailDestination()
+ MailDetailDestination({ navReserve: this.navReserve })
}
}
@@ -1246,13 +1266,15 @@ struct CommPage {
if (this.commTab === 'sent') {
SentTab({
bgActive: this.bgActive,
+ navReserve: this.navReserve,
onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); }
})
} else if (this.commTab === 'permissions') {
- PermissionTab({ bgActive: this.bgActive })
+ PermissionTab({ bgActive: this.bgActive, navReserve: this.navReserve })
} else {
InboxTab({
bgActive: this.bgActive,
+ navReserve: this.navReserve,
onOpenMail: (mailId: string, accountId: string): void => { this.openMail(mailId, accountId); }
})
}
@@ -1262,6 +1284,10 @@ struct CommPage {
/*
* 悬浮的圆形加号(用户点名要的形状):56 圆、品牌色、右下角。
* 三个栏里都在(新建邮件这件事不挑栏),但**只在列表窗格内**。
+ *
+ * ★ 离底要抬过悬浮条:窗格现在是**满高**的(内容要能滑到条底下,
+ * 玻璃才有东西可糊),所以加号的 `bottom` 必须自己抬过条高 +
+ * 离底留白 + 余量(`navReserve`)—— 否则它会压在条上。
*/
Button() {
AmIcon({ iconName: 'compose', iconSize: 24, iconColor: Theme.surface })
@@ -1269,7 +1295,7 @@ struct CommPage {
.width(56).height(56)
.borderRadius(28)
.backgroundColor(Theme.accent)
- .margin({ right: 16, bottom: 16 })
+ .margin({ right: 16, bottom: this.navReserve + 16 })
.onClick(() => { this.openCompose(); })
}
.width('100%').height('100%')
@@ -1293,6 +1319,8 @@ struct CommPage {
@Component
struct ContactsTab {
@Prop bgActive: boolean = false;
+ /** 底部悬浮条高度(窄屏非 0)——理由见 `InboxTab` 的 `navReserve` */
+ @Prop navReserve: number = 0;
@State contacts: Contact[] = [];
@State loading: boolean = false;
@State error: string = '';
@@ -1382,6 +1410,7 @@ struct ContactsTab {
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
+ .contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
} else {
// 列表视图:同样**每项一张卡**(不再用贯通分隔线)—— 与卡片视图同一语义
@@ -1398,6 +1427,7 @@ struct ContactsTab {
}
.width('100%').layoutWeight(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
+ .contentEndOffset(this.navReserve)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
}
}
@@ -1578,6 +1608,16 @@ struct MainPage {
* 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。
*/
@State bgActive: boolean = false;
+ /**
+ * 底部悬浮条要占的高度(vp)——页面传给各滚动容器作为**内容末尾**让位。
+ *
+ * 窄屏 = `NAV_CONTENT_RESERVE`(条高+离底留白+余量),宽屏 = 0(没条)。
+ *
+ * ★ 这个值加在滚动容器的 `contentEndOffset`,**不是**加在窗格的 padding 上:
+ * 窗格必须**满高**,内容才滑得到条底下 —— 否则玻璃条背后只剩壁纸,
+ * 系统材质无东西可糊,看起来就是一块普通浅色面板(用户 2026-09-17 的反馈)。
+ */
+ @State navReserve: number = NAV_CONTENT_RESERVE;
/** 环境变化回调 id(-1 = 没订阅);`lastColorMode` 用来只在真的换向时重算 */
private envCallbackId: number = -1;
/**
@@ -1812,18 +1852,30 @@ struct MainPage {
NavItem(item: NavItem, index: number) {
Column() {
/*
- * 选中态**只换颜色**。
+ * 图标 + 文字(与 WebUI `NarrowNav.tsx` 同构)。
*
- * 用户(2026-09-14):「同时底部导航栏选中对应的文字和图标变色即可」。
- * 2026-09-16:用户进一步明确「底部导航栏不允许有文字」⇒ 去掉 label,只留图标。
- * 这与 WebUI 一致:`NarrowNav.tsx` 的底部导航是**纯图标**(WebUI 图标是 24×24,
- * 鸿蒙这边用 28 放大一档 —— 用户嫌 22 太小)。
+ * ── 修正一处**事实错误**(2026-09-17)──
+ * 这里曾经写着「WebUI 的底部导航是**纯图标**」,并据此去掉了文字。
+ * 那句是**错的**:`NarrowNav.tsx:83-86` 的每个导航项是
+ * `flex flex-col items-center justify-center gap-0.5`
+ * 里面先 `` 再 `{short}`。
+ * 也就是说 WebUI **一直有文字**(四段:通信 / 日历 / 联系人 / 我的)。
+ * 所以“去掉文字”既不是对齐 WebUI,也不是用户当时想要的最终形态 ——
+ * 用户 2026-09-17 原话:「还是在导航栏加上文字吧,没有文字还是不好看」。
+ *
+ * 选中态:**图标与文字一起换色**,不引入背景/指示条 ——
+ * 与 WebUI 同一套表达(用户 2026-09-14:「选中对应的文字和图标变色即可」)。
*/
AmIcon({
iconName: item.iconKey,
- iconSize: 28,
+ iconSize: 24,
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
})
+ Text(item.label)
+ .fontSize(10)
+ .lineHeight(12)
+ .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
+ .margin({ top: 3 })
}
/*
* 命中区:**显式给下限**(44vp),不靠"看起来够大"。
@@ -1860,51 +1912,67 @@ struct MainPage {
*/
@Builder
NavBar() {
- Row() {
- ForEach(NAV_ITEMS, (item: NavItem, index: number) => {
- this.NavItem(item, index)
- }, (item: NavItem) => item.key)
+ /*
+ * ★ 外层容器带 padding,**不是** `width('100%') + margin`。
+ *
+ * ArkUI 的 margin 加在宽度**外面**:`width('100%')` 再配左右 margin 不会让元素
+ * 缩到「100% − margin」,而是整个顶出父容器、两侧被裁 ——
+ * 实测底栏左缘 x=0、右缘贴满 1256(应各留 16vp=56px),
+ * 于是它看起来是**贴边的通栏**而不是“浮在壁纸之上的胶囊”,
+ * 玻璃材质也就失去意义(背后没有内容/壁纸可透)。
+ * 这与收件箱头卡片修过的是同一个坑(`width('100%') + margin` 在 ArkUI 里不缩宽)。
+ */
+ Column() {
+ Row() {
+ ForEach(NAV_ITEMS, (item: NavItem, index: number) => {
+ this.NavItem(item, index)
+ }, (item: NavItem) => item.key)
+ }
+ .width('100%')
+ .height(NAV_BAR_HEIGHT)
+ .borderRadius(NAV_BAR_RADIUS)
+ /*
+ * 导航底:**系统材质**,不是手写 alpha。
+ *
+ * 原来这里有 `#B8FFFFFF` / `#B80F172A` 两个常量(浅色/深色各一个手写玻璃)——
+ * 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突,
+ * 而且还要我们自己维护两套。现在只声明**档次**(`Theme.navMaterial` = `COMPONENT_THICK`),
+ * 深浅两套颜色与模糊半径都由系统按主题给。
+ */
+ /*
+ * 导航条的**面板材质**:**固定系统档**(`Theme.navMaterial` = `COMPONENT_THICK`),
+ * **不跟随 `bg_blur`**。
+ *
+ * ── 这里曾经"说的和做的不一致",记下来(pi 2026-09-15 抓到的)──
+ * 我一度把档位接过用户偏好(`navMaterialFor(this.bgPlan.blurPx)` 查表),
+ * 但**注释留在了更早那一版**:那段注释论证的是"固定档"、还写着"跟随是错的"。
+ * 于是**注释说 (a)、代码是 (b)** —— 下一个读者会照注释把代码改回去,
+ * 而且他会引我那句"pi 抓出来了"当权威。**说的与做的不一致、而判据看不见**,
+ * 正是这一路反复在消的形状,这次落在注释上(而注释正是"理由要写清"那条纪律的证据源)。
+ *
+ * ── 为什么最终是固定档(pi 的裁定,五条依据,我原先的理由被推翻)──
+ * ① WebUI 的 `.narrow-nav`(`index.css:1114`)是**硬编码** `backdrop-filter: blur(18px)`,
+ * **不读** `--bg-blur`;
+ * ② WebUI 那个滑杆的语义是"**背景**"(`BackgroundPicker.tsx:183`:`label="模糊"`、
+ * `hint="虚化细节,避免背景与正文抢注意力"`,`min=0 max=24`),只作用在
+ * `.app-backdrop{filter:blur(var(--bg-blur))}` 上;
+ * ③ WebUI 自己留了**分开的**令牌 `--bg-blur-panel`(`index.css:267`,注释写明
+ * "与壁纸自身的 `--bg-blur` 分开:那层给照片打底,这层给面板")——
+ * 它的词汇表本身就把两者分开;
+ * ④ **我原先的理由不成立**:我说"(a) 会让那个滑杆在导航条上变成死控件",
+ * 而那个滑杆**已经**被壁纸消费了(本文件下面的壁纸层把 `bgPlan.blurPx` 交给 `.blur(...)`)——
+ * 它从来**不是**导航条的控件,(a) 之下它照样是活的;
+ * ⑤ §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。
+ * 产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换了一张壁纸而变厚变薄。
+ */
+ .backgroundBlurStyle(Theme.navMaterial)
}
.width('100%')
- .height(NAV_BAR_HEIGHT)
- // 悬浮:四周留白(贴边就不是悬浮了)+ 胶囊圆角
- .margin({ left: NAV_BAR_SIDE, right: NAV_BAR_SIDE, bottom: NAV_BAR_BOTTOM })
- .borderRadius(NAV_BAR_RADIUS)
- /*
- * 导航底:**系统材质**,不是手写 alpha。
- *
- * 原来这里有 `#B8FFFFFF` / `#B80F172A` 两个常量(浅色/深色各一个手写玻璃)——
- * 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突,
- * 而且还要我们自己维护两套。现在只声明**档次**(`Theme.navMaterial` = `COMPONENT_THICK`),
- * 深浅两套颜色与模糊半径都由系统按主题给。
- */
- /*
- * 导航条的**面板材质**:**固定系统档**(`Theme.navMaterial` = `COMPONENT_THICK`),
- * **不跟随 `bg_blur`**。
- *
- * ── 这里曾经"说的和做的不一致",记下来(pi 2026-09-15 抓到的)──
- * 我一度把档位接过用户偏好(`navMaterialFor(this.bgPlan.blurPx)` 查表),
- * 但**注释留在了更早那一版**:那段注释论证的是"固定档"、还写着"跟随是错的"。
- * 于是**注释说 (a)、代码是 (b)** —— 下一个读者会照注释把代码改回去,
- * 而且他会引我那句"pi 抓出来了"当权威。**说的与做的不一致、而判据看不见**,
- * 正是这一路反复在消的形状,这次落在注释上(而注释正是"理由要写清"那条纪律的证据源)。
- *
- * ── 为什么最终是固定档(pi 的裁定,五条依据,我原先的理由被推翻)──
- * ① WebUI 的 `.narrow-nav`(`index.css:1114`)是**硬编码** `backdrop-filter: blur(18px)`,
- * **不读** `--bg-blur`;
- * ② WebUI 那个滑杆的语义是"**背景**"(`BackgroundPicker.tsx:183`:`label="模糊"`、
- * `hint="虚化细节,避免背景与正文抢注意力"`,`min=0 max=24`),只作用在
- * `.app-backdrop{filter:blur(var(--bg-blur))}` 上;
- * ③ WebUI 自己留了**分开的**令牌 `--bg-blur-panel`(`index.css:267`,注释写明
- * "与壁纸自身的 `--bg-blur` 分开:那层给照片打底,这层给面板")——
- * 它的词汇表本身就把两者分开;
- * ④ **我原先的理由不成立**:我说"(a) 会让那个滑杆在导航条上变成死控件",
- * 可那个滑杆**已经**被壁纸消费了(本文件下面的壁纸层把 `bgPlan.blurPx` 交给 `.blur(...)`)——
- * 它从来**不是**导航条的控件,(a) 之下它照样是活的;
- * ⑤ §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。
- * 产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换了一张壁纸而变厚变薄。
- */
- .backgroundBlurStyle(Theme.navMaterial)
+ .padding({
+ left: NAV_BAR_SIDE,
+ right: NAV_BAR_SIDE,
+ bottom: NAV_BAR_BOTTOM
+ })
}
build() {
@@ -1957,9 +2025,9 @@ struct MainPage {
}
Column() {
if (this.currentIndex === 0) {
- CommPage({ bgActive: this.bgActive })
+ CommPage({ bgActive: this.bgActive, navReserve: this.navReserve })
} else if (this.currentIndex === 2) {
- ContactsTab({ bgActive: this.bgActive })
+ ContactsTab({ bgActive: this.bgActive, navReserve: this.navReserve })
}
/*
* 日历与另外两个 pane 不同:**常驻挂载**,用 `visibility` 控制显示。
@@ -1973,7 +2041,11 @@ struct MainPage {
* 常驻 ≠ 常拉:首次**可见**时才发请求(见 `CalendarPage.onVisibleChanged`)。
*/
Column() {
- CalendarPage({ bgActive: this.bgActive, visible: this.currentIndex === 1 })
+ CalendarPage({
+ bgActive: this.bgActive,
+ visible: this.currentIndex === 1,
+ navReserve: this.navReserve
+ })
}
.width('100%')
.height('100%')
@@ -1995,7 +2067,21 @@ struct MainPage {
.backgroundColor(this.isWide ? Theme.surface : (this.bgActive ? Color.Transparent : Theme.surface))
.borderRadius(this.isWide ? Theme.glassRadius : 0)
.clip(this.isWide)
- .padding({ bottom: this.isWide ? 0 : NAV_CONTENT_RESERVE })
+ /*
+ * ★ 这里**不再**让位(原来写的是 `padding({ bottom: NAV_CONTENT_RESERVE })`)。
+ *
+ * 让位改到各个滚动容器的**内容末尾**(`contentEndOffset(this.navReserve)`)。
+ * 两者看起来一样、实际差得远:
+ * · 旧写法(窗格 padding):**缩短窗格** ⇒ 内容永远到不了条底下 ⇒
+ * 玻璃条背后只剩一张已经被壁纸层模糊过的壁纸 ⇒ 系统材质无东西可糊 ⇒
+ * 看起来是一块普通的浅色面板,而不是玻璃。
+ * (用户 2026-09-17:「底栏不是玻璃质感,滑动内容无法穿过底栏」——同一根因)
+ * · 新写法(容器末尾 offset):窗格**满高**,内容滑得到条底下(真正穿过),
+ * 而末尾仍留出那段高度 ⇒ 最后一行照样滚得出来、点得到。
+ *
+ * 与 WebUI 同构:`.narrow-shell` 是 `flex-col`,列表**满高**、
+ * `.narrow-nav` 作为兄弟叠在上面(自带 margin),内容从它底下穿过。
+ */
}
.width('100%')
.height('100%')
@@ -2015,6 +2101,8 @@ struct MainPage {
.height('100%')
.onAreaChange((oldValue: Area, newValue: Area) => {
this.isWide = (newValue.width as number) >= 768;
+ /* 宽屏没有底部导航条 ⇒ 内容不需要为它让位 */
+ this.navReserve = this.isWide ? 0 : NAV_CONTENT_RESERVE;
})
}
}
\ No newline at end of file