@ -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`
* 里面先 `<Icon />` 再 `<span className="text-3xs leading-none">{short}</span>`。
* 也就是说 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;
})
}
}