Files
FamilyBook_harmonyos/entry/src/main/ets/pages/calendar/FbCalendarPage.ets
T
laoyang 705a65ae2e v0.5.0 抽屉改「应用内左右滑动」触发 + 去掉汉堡按钮 + 统一区块边距
用户 2026-10-12 三条反馈。

## ① 抽屉改「应用内水平滑动」触发(关键更正)

用户实测指出:「左滑右滑不是从边上滑,而是在应用的内部从左往右滑或者从右往左滑。
如果从边上往里边滑的话,它是系统手势,我们是没有办法去控制它的。」

⇒ **这是之前手势完全没生效的根因**:屏幕边缘是系统手势区(返回/侧边栏),
  从边缘往里滑的事件根本轮不到应用。判据改为「在应用内部水平滑动」:
  · 向右滑 ⇒ 抽屉从左边出;· 向左滑 ⇒ 抽屉从右边出。
  (左手持机往右划、右手持机往左划,单手都顺。)

🔴 同时修掉一个真 bug:**`offsetX` 的单位是 vp**(SDK 原文 "in vp"),
   我之前又除以 density,把位移缩到 1/3 ⇒ 阈值永远达不到。
⚠️ 用 `parallelGesture`(不是 gesture):内容区还有竖向滚动与
   纪念日/财务/任务的横向滚动条,用 gesture 会把它们抢掉。
   阈值 80vp,轻扫横向列表不会误开。

## ② 去掉顶栏汉堡按钮
用户:「那个三个点、三个横线的按钮就不要了,图标靠左,显得更好看一些」。
⇒ TopBar 删掉按钮,`padding.left` 由 14 改为 `pagePad`,图标与下方内容左边线对齐。

## ③ 🔴 统一区块边距(修掉 15 处真 bug)
用户:「页面里的方框大小不一样,有的左右充满,有的有缝隙 —— 最好都留一点距离」。

实测界面树 + 像素采样找到根因:**卡片同时写了 `.width('100%')` 和
`.margin({left, right})` ⇒ width 先把宽度占满、margin 失效,卡片顶到屏幕两侧**。
(首页个人信息卡实测 x=0..1080 满宽,而别的区块是留边 17vp 的。)

全项目扫出 **15 处**同款写法(calendar 3 / days 2 / ledger 2 / movies 1 /
people 2 / settings 5),**全是卡片**(backgroundColor+borderRadius+margin)。
统一删掉那条 `.width('100%')`,宽度交给 margin 撑出。

### 验证(真机)
· 手势:向右滑 ⇒ 抽屉「家书」x=187(贴左);向左滑 ⇒ x=442(贴右)—— 双向 ✓
· 边距:像素级逐行采样,17 行里 15 行左右均 50px(17vp)完全一致;
  另 2 行是"标题文字起点"(文字有额外缩进),不是边距问题 ✓
· 14 页遍历 **14/14**;深验(每页特征文本)**12/12**
· 编译 **0 error / 0 warning**

### 工具链修复(都很关键)
· 🔴 `hdc file recv` 在 **Git Bash** 下会被路径转换改写:`/data/local/tmp/x`
  被当成 `C:/.../tmp/x` ⇒ **静默失败、界面树永远抓不到**。
  三个验证脚本已统一注入 `MSYS_NO_PATHCONV=1`。
· 探针不再点汉堡按钮开抽屉(按钮已删),改用「应用内向右滑」。
· 修探针一处误判:顶栏与抽屉**都有**「家书」,原来没区分 ⇒
  两种滑动都读到顶栏那个固定 x,误报"手势没生效"(其实生效了)。
2026-10-12 11:37:45 +08:00

457 lines
16 KiB
Plaintext

/**
* 家书 FamilyBook · 日程模块(鸿蒙端)
*
* 对应 Web 端 `templates/calendar/index.php`。Web 端是「月 / 周 / 天」三视图,
* 左侧的「我的日历本」那一栏已被用户要求撤掉、功能收进右侧设置 —— 端上照此:
* 日历占满宽度,日历本显隐 / 颜色 / 导入都在页头那个齿轮里。
*
* 端上保留了月视图(月历格子)与「选中日」的日程列表上下两块:
* 手机屏幕放不下「月历 + 当天时间轴」并排,上下排列最自然。
*
* ⚠️ 月历格子用 7 列 Grid,每格显示「日号 + 该日条目数的小点」。
* 真正的周/天时间轴视图(按半小时定位)建议放到第二轮做 —— 它需要
* 手势与拖动建日程,属于交互实现,不在本轮「先出界面」的范围。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbCalBook, FbCalEvent } from '../../model/Types';
import { FB_DEMO_CAL_BOOKS, FB_DEMO_EVENTS_SOON, FB_DEMO_EVENTS_TODAY } from '../../model/DemoData';
import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton } from '../../components/Widgets';
/** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */
const FB_FAINT: string = '#A8988A';
const FB_MUTED: string = '#7E6B5A';
/** 月历格子(一个日期) */
interface FbCalCell {
/** 日期数字 */
day: number;
/** 是否属于当月 */
inMonth: boolean;
/** 是否今天 */
isToday: boolean;
/** 该日的事件数(0 不显示点) */
count: number;
/** 该日第一个事件的色(画小点用) */
dotColor: string;
}
@ComponentV2
export struct FbCalendarPage {
/**
* 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。
* 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。
*/
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
/** 模块主题色(日程 = 琥珀) */
private accent: string = '#B0763A';
private books: FbCalBook[] = FB_DEMO_CAL_BOOKS;
private todayEvents: FbCalEvent[] = FB_DEMO_EVENTS_TODAY;
private soonEvents: FbCalEvent[] = FB_DEMO_EVENTS_SOON;
/** 当前显示的月份(首次进入取本月) */
@Local showYear: number = new Date().getFullYear();
@Local showMonth: number = new Date().getMonth() + 1;
/** 选中的日期(0 = 今天) */
@Local pickedDay: number = new Date().getDate();
private weekLabels: string[] = ['一', '二', '三', '四', '五', '六', '日'];
build() {
Scroll() {
Column() {
this.topBar()
// ---- 页头:月份 + 视图切换 + 新建 + 设置 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.showYear.toString() + ' 年 ' + this.showMonth.toString() + ' 月')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.visibleBooks().length.toString() + ' 本日历可见 · 本月 '
+ this.monthEventCount().toString() + ' 条')
.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 }) {
// ---- 月历卡 ----
Column({ space: 0 }) {
// 月份切换条
Row() {
this.navBtn('上一月', -1)
Blank()
Text('今天')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(fbPalette(this.accent).brand + '14')
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
const d: Date = new Date();
this.showYear = d.getFullYear();
this.showMonth = d.getMonth() + 1;
this.pickedDay = d.getDate();
})
Blank()
this.navBtn('下一月', 1)
}
.width('100%')
.padding({ bottom: 10 })
// 星期表头
Row() {
ForEach(this.weekLabels, (w: string) => {
Text(w)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.layoutWeight(1)
.textAlign(TextAlign.Center)
}, (w: string): string => 'wk_' + w)
}
.width('100%')
.padding({ bottom: 6 })
// 日期网格(7 列)
Grid() {
ForEach(this.cells(), (c: FbCalCell) => {
GridItem() {
this.dayCell(c)
}
}, (c: FbCalCell): string => 'cell_' + c.day.toString() + '_'
+ (c.inMonth ? '1' : '0'))
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
.rowsGap(2)
.width('100%')
.height(this.gridHeight())
.scrollBar(BarState.Off)
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad })
// ---- 日历本(可见的几本,横向排列当作图例)----
Column({ space: 10 }) {
Row({ space: 8 }) {
ForEach(this.visibleBooks(), (b: FbCalBook) => {
Row({ space: 5 }) {
FbDot({ dia: 7, fillC: b.color })
Text(b.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.padding({ right: 10 })
}, (b: FbCalBook): string => 'book_' + b.id.toString())
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 12 })
.alignItems(HorizontalAlign.Start)
// ---- 选中日的日程 ----
Column({ space: 0 }) {
Row() {
Text(this.pickedDay.toString() + ' 日')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.todayEvents.length.toString() + ' 条日程')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
Column({ space: 0 }) {
if (this.todayEvents.length === 0) {
FbEmptyBox({
title: '这天没有安排',
hint: '在月历里点一下某天,就能在那天加日程',
icon: $r('app.media.fb_ic_calendar')
})
} else {
ForEach(this.todayEvents, (ev: FbCalEvent, idx: number) => {
this.eventRow(ev, idx === this.todayEvents.length - 1)
}, (ev: FbCalEvent): string => 'cev_' + ev.id.toString())
}
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
}
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ---- 未来七天 ----
Column({ space: 0 }) {
Row() {
Text('未来七天')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text('共 ' + this.soonEvents.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
Column({ space: 0 }) {
ForEach(this.soonEvents, (ev: FbCalEvent, idx: number) => {
this.eventRow(ev, idx === this.soonEvents.length - 1)
}, (ev: FbCalEvent): string => 'sev_' + ev.id.toString())
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
}
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, 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
navBtn(label: string, delta: number) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
let m: number = this.showMonth + delta;
let y: number = this.showYear;
if (m < 1) {
m = 12;
y = y - 1;
} else if (m > 12) {
m = 1;
y = y + 1;
}
this.showMonth = m;
this.showYear = y;
this.pickedDay = 0;
})
}
/** 一个日期格子 */
@Builder
dayCell(c: FbCalCell) {
Column({ space: 3 }) {
Text(c.day.toString())
.fontSize(FbMetric.fontSmall)
.fontWeight(c.isToday || c.day === this.pickedDay ? FontWeight.Medium : FontWeight.Normal)
.fontColor(c.isToday
? Color.White
: (c.inMonth ? FbColors.ink : FbColors.faint))
.width(26)
.height(26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.backgroundColor(c.isToday
? this.accent
: (c.day === this.pickedDay && c.inMonth
? fbPalette(this.accent).brand + '1F'
: Color.Transparent))
// 事件小点(最多 3 个)
Row({ space: 2 }) {
if (c.count > 0) {
ForEach(this.dots(c.count), (i: number) => {
FbDot({ dia: 4, fillC: c.dotColor })
}, (i: number): string => 'dot_' + c.day.toString() + '_' + i.toString())
}
}
.height(5)
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.pickedDay = c.day;
})
}
/** 一条日程行:时间 + 色点 + 标题 + 日历本标签 */
@Builder
eventRow(ev: FbCalEvent, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(44)
FbDot({ dia: 8, fillC: ev.color })
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(ev.calName)
.fontSize(10)
.fontColor(Color.White)
.backgroundColor(ev.color)
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.maxLines(1)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 54 })
}
}
.width('100%')
}
/** 只列「显示着的」日历本(与 Web 端 Calendar::hiddenCalendars 的反面同口径) */
private visibleBooks(): FbCalBook[] {
return this.books.filter((b: FbCalBook): boolean => b.visible);
}
/** 本月事件数(演示数据里用未来七天 + 今天的条数代表示意) */
private monthEventCount(): number {
return this.todayEvents.length + this.soonEvents.length;
}
/** 造「点」的索引数组(ForEach 需要数组,不能直接循环次数) */
private dots(count: number): number[] {
const n: number = Math.min(3, count);
const out: number[] = [];
for (let i = 0; i < n; i++) {
out.push(i);
}
return out;
}
/** 网格高度 = 行数 × 行高(6 行时 6×46) */
private gridHeight(): number {
const rows: number = Math.ceil(this.cells().length / 7);
return rows * 46;
}
/**
* 生成当月月历格子。
*
* 周一起始(与 Web 端一致 —— 中文日历习惯周一为第一列)。
*/
private cells(): FbCalCell[] {
const out: FbCalCell[] = [];
const y: number = this.showYear;
const m: number = this.showMonth;
const first: Date = new Date(y, m - 1, 1);
// JS 的 getDay(): 0=周日 … 6=周六 ⇒ 换算成「周一为 0」
const lead: number = (first.getDay() + 6) % 7;
const daysInMonth: number = new Date(y, m, 0).getDate();
const prevDays: number = new Date(y, m - 1, 0).getDate();
const now: Date = new Date();
const isThisMonth: boolean = now.getFullYear() === y && (now.getMonth() + 1) === m;
// 前置:上月的尾巴
for (let i = lead - 1; i >= 0; i--) {
out.push({
day: prevDays - i,
inMonth: false,
isToday: false,
count: 0,
dotColor: FB_MUTED
});
}
// 本月
for (let d = 1; d <= daysInMonth; d++) {
const cnt: number = this.eventCountOf(d, isThisMonth);
out.push({
day: d,
inMonth: true,
isToday: isThisMonth && now.getDate() === d,
count: cnt,
dotColor: cnt > 0 ? this.accent : FB_FAINT
});
}
// 补齐到整周(补下月的头)
const tail: number = (7 - (out.length % 7)) % 7;
for (let d = 1; d <= tail; d++) {
out.push({
day: d,
inMonth: false,
isToday: false,
count: 0,
dotColor: FB_FAINT
});
}
return out;
}
/** 某天的日程条数(演示数据:今天取 todayEvents,其余从 soonEvents 的 'MM-DD' 匹配) */
private eventCountOf(day: number, isThisMonth: boolean): number {
if (!isThisMonth) {
return 0;
}
const now: Date = new Date();
if (now.getDate() === day) {
return this.todayEvents.length;
}
const mm: string = this.showMonth < 10
? '0' + this.showMonth.toString()
: this.showMonth.toString();
const dd: string = day < 10 ? '0' + day.toString() : day.toString();
const key: string = mm + '-' + dd;
let n: number = 0;
for (const ev of this.soonEvents) {
if (ev.date === key) {
n++;
}
}
return n;
}
}