Files
FamilyBook_harmonyos/entry/src/main/ets/pages/days/FbDaysPage.ets
T
laoyang 7eaa641437 v0.6.0 图标改为开抽屉入口 + 新增底部悬浮快捷栏(照鸿蒙 6 悬浮导航规范)
用户 2026-10-12 三条要求。

## ① 点顶栏图标 = 开抽屉
用户:「把那个图标改成这个功能吧,就是当用户点击左上角那个图标的时候,把抽屉列出来」。
⇒ `TopBar` 的 logo 加 `.onClick { drawerOpen = true }`。
⚠️ 点击直接挂在 Image 上、**不套 Button** —— Button 自带最小尺寸与内边距,
会把图标挤离左边线(用户要"图标靠左")。
⇒ 开抽屉现有三个入口:点图标 / 应用内水平滑动 / 底部悬浮栏。

## ② 底部悬浮快捷栏 FbFloatDock(新增)
用户:「页面正下方设置几个浮动按钮…比如日程、名册、任务和笔记…
        这样用户不用拉抽屉,就可以直接点击使用。
        但一定要注意,使用浮动的方式,**不要固定在下面**,而是**浮动着的**。」

🔴 **形态照鸿蒙 6 官方设计规范的「悬浮导航(Float Navigation)」**:
「导航栏脱离底部边界,以**四周留白、圆角卡片**形态悬浮于内容之上」。
参考实现参数直接用上:`height(56) + margin({left:16,right:16,bottom:16})
+ borderRadius(28)`,材质用系统沉浸材质
`uiMaterial.ImmersiveMaterial({ style: ULTRA_THIN })`(设计规范的"超薄"档)。
⚠️ 用了 systemMaterial 就不再叠加 backgroundBlurStyle/backgroundColor ——
   材质自带模糊+边框+阴影,叠加会双重模糊发灰。

⚠️ **摆法关键**:`Index` 的根容器从 `Column` 改成
`Stack({ alignContent: Bottom })` —— 写在 Column 里它就是"占一行的固定底栏"
(老样式);用 Stack 覆盖 ⇒ 内容从它下面穿过、它浮在上面。
(实测印证:栏体 y=2132..2307,而"我的家人"标题在 y=2336 仍可见 ⇒ 内容穿过去了。)

⚠️ 内容底部加 `FB_DOCK_RESERVE`(88vp) 预留,否则最后一屏内容会被悬浮栏压住。
12 个页面统一处理。
⚠️ 宽屏不显示(抽屉常驻,且屏宽用不上);抽屉展开时也不显示(避免浮层叠浮层)。
⚠️ 选中项用模块主题色高亮,用户一眼知道"我在哪"。

## 验证(真机,全部通过)
· 点顶栏图标 ⇒ 抽屉打开 ✓
· 点悬浮栏「任务」⇒ 切到任务页 ✓;点「日程」⇒ 切到日程页 ✓
· 悬浮栏几何:栏体 x=21..1060、y=2132..2307(高 58vp),
  栏外 x=0..20 为纸色 ⇒ **四周留白** 符合规范 ✓
· 栏内呈半透材质色(#EFEBE2) ⇒ `ImmersiveMaterial` 生效 ✓
· 14 页遍历 **14/14**;深验 **12/12**;编译 **0 error / 0 warning**

## 过程中的一个坑
给 12 个页面补 `FB_DOCK_RESERVE` 的 import 时,脚本统一写成
`'../components/FloatDock'` —— 但**页面在 `pages/<模块>/` 下,需要 `../../`**
(`Index.ets` 在 `pages/` 下才是 `../`)。批量改 import 路径要按**文件深度**区分。
2026-10-12 12:06:00 +08:00

326 lines
10 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 纪念日模块(鸿蒙端)
*
* 对应 Web 端 `templates/days/index.php`。
*
* Web 端的结构:**「即将到来」横条在顶**(30 天内的纪念日,含名册里每个人的生日),
* 下面是全部纪念日的列表(可按类别筛)。
*
* 端上照此,但把「即将到来」做成**大数字卡片**(还剩几天用大字),
* 因为「还有几天」是这个模块唯一真正要一眼看到的信息。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbAnniversaryItem, FbKinChip } from '../../model/Types';
import { FB_DEMO_ANNIVERSARIES } from '../../model/DemoData';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
@ComponentV2
export struct FbDaysPage {
/**
* 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。
* 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。
*/
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
/** 模块主题色(纪念日 = 陶土橙) */
private accent: string = '#B4633C';
private all: FbAnniversaryItem[] = FB_DEMO_ANNIVERSARIES;
/** 类别筛选('' = 全部) */
@Local kindFilter: string = '';
build() {
Scroll() {
Column() {
this.topBar()
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('纪念日')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('节日 / 纪念日 / 生日,都在这一条时间线上')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
// ---- 即将到来(30 天内)----
Column({ space: 0 }) {
Row({ space: 8 }) {
Text('即将到来')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text('30 天内')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, bottom: 10 })
if (this.upcoming().length === 0) {
Text('三十天内没有纪念日')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} else {
// 横向排列的「大数字」卡
Scroll() {
Row({ space: 10 }) {
ForEach(this.upcoming(), (a: FbAnniversaryItem) => {
this.upcomingCard(a)
}, (a: FbAnniversaryItem): string => 'up_' + a.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad })
.alignItems(HorizontalAlign.Start)
// ---- 全部纪念日 ----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('生日', 'birthday')
this.kindChip('纪念日', 'anniversary')
this.kindChip('节日', 'holiday')
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 14, bottom: 8 })
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里还没有纪念日',
hint: '名册里填上生日,它就会自动出现在这儿',
icon: $r('app.media.fb_ic_days')
})
} else {
Column({ space: 0 }) {
ForEach(this.filtered(), (a: FbAnniversaryItem, idx: number) => {
this.annRow(a, idx === this.filtered().length - 1)
}, (a: FbAnniversaryItem): string => 'ann_' + a.id.toString())
}
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 8 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 28 })
}
}
.width('100%')
}
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 即将到来的大数字卡 */
@Builder
upcomingCard(a: FbAnniversaryItem) {
Column({ space: 4 }) {
if (a.daysLeft === 0) {
Text('今天')
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
} else {
Row({ space: 1 }) {
Text(a.daysLeft.toString())
.fontSize(26)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
Text('天')
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFFCC')
.margin({ bottom: 4 })
}
.alignItems(VerticalAlign.Bottom)
}
Text(a.title)
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(a.date)
.fontSize(10)
.fontColor('#FFFFFFB3')
}
.width(96)
.padding(14)
.backgroundColor(this.accent)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
/** 一行纪念日:日期 + 标题 + 相关人 + 天数 */
@Builder
annRow(a: FbAnniversaryItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 12 }) {
// 日期块
Column({ space: 0 }) {
Text(this.monthDay(a.date))
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.yearText(a.date))
.fontSize(9)
.fontColor(FbColors.faint)
}
.width(52)
.alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(a.title)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
FbTag({ text: this.kindName(a.kind), tone: this.kindTone(a.kind) })
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 相关人(生日才有)
if (a.people.length > 0) {
Row({ space: 6 }) {
ForEach(a.people, (p: FbKinChip) => {
Row({ space: 3 }) {
FbAvatar({ name: p.shown, color: p.avatarColor, dia: 16, fontSizeVp: 9 })
Text(p.shown)
.fontSize(10)
.fontColor(FbColors.muted)
}
.alignItems(VerticalAlign.Center)
}, (p: FbKinChip): string => 'ap_' + a.id.toString() + '_' + p.id.toString())
}
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 剩余天数
Text(a.daysLeft === 0 ? '今天' : a.daysLeft.toString() + ' 天')
.fontSize(FbMetric.fontTiny)
.fontWeight(a.daysLeft <= 7 ? FontWeight.Medium : FontWeight.Normal)
.fontColor(a.daysLeft <= 7 ? this.accent : FbColors.faint)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider().color(FbColors.line2).margin({ left: 64 })
}
}
.width('100%')
}
@Builder
kindChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.kindFilter === key ? Color.White : FbColors.muted)
.backgroundColor(this.kindFilter === key ? this.accent : FbColors.inputBg)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.kindFilter = key;
})
}
/** 'MM-DD' → '10-17';'YYYY-MM-DD' → '11-08' */
private monthDay(date: string): string {
const parts: string[] = date.split('-');
if (parts.length === 3) {
return parts[1] + '-' + parts[2];
}
return date;
}
/** 带年份的日期显示年份(如 '2019-11-08' → '2019'),不带则显示「每年」 */
private yearText(date: string): string {
const parts: string[] = date.split('-');
if (parts.length === 3) {
return parts[0];
}
return '每年';
}
private kindName(kind: string): string {
if (kind === 'birthday') {
return '生日';
}
if (kind === 'holiday') {
return '节日';
}
if (kind === 'solar_term') {
return '节气';
}
return '纪念日';
}
private kindTone(kind: string): string {
if (kind === 'birthday') {
return 'brand';
}
if (kind === 'holiday') {
return 'red';
}
if (kind === 'solar_term') {
return 'green';
}
return 'blue';
}
private upcoming(): FbAnniversaryItem[] {
return this.all.filter((a: FbAnniversaryItem): boolean => a.daysLeft <= 30);
}
private filtered(): FbAnniversaryItem[] {
if (this.kindFilter.length === 0) {
return this.all;
}
return this.all.filter((a: FbAnniversaryItem): boolean => a.kind === this.kindFilter);
}
}