Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbDayTimeline.ets
T
laoyang cdbf0a9593 v84: 首页「+ 新建」日程 + 红线去端点 + 新建表单两处可见性修复
用户 2026-10-23 原话:「日程,没有新建日程按钮,名册,也没有新增成员这个按钮。」
「首页上的日程,红色线不对,变短了左侧缺了一块。在日程模块中,那个红线左侧
是不是还有一个红点?如果有,去掉。」

① 首页日程区块加「+ 新建」(FbHomePage / Widgets.FbSectionTitle)
   - FbSectionTitle 扩两个可选参数 addText / onAdd,带默认值 ⇒ 老调用点一行不改
   - 按需懒拉 /calendar/books,缓存进 calBooks,同一会话只拉一次
   - todayIso() 用本地年月日拼,**不用 toISOString**(UTC 会让早 8 点前算成昨天)

② 时间红线两端统一成「一根满宽线、无端点装饰、无行首文字」(FbDayTimeline)
   - 老写法 `Row { Text(时间) width(34); 线; 圆点 }` ⇒ 行首 34vp 把线顶掉一块
     (与网页端 .ag-now「双重缩进」同一类病),行尾 6x6 圆点就是用户问的红点
   - 改成满高 Column + Blank 撑高 + 满宽 2px 线,与卡片端同一写法

③ 🔴 真机实测发现的两处可见性问题(FbEventEditSheet)
   - 「加入哪本日历」原来排在「提醒」之后 ⇒ 表单高于卡片(maxHeight 86%),
     它被推到滚动区下方,首屏只看到被底边切掉半行的标题(实测 bounds 高 7px)。
     它是必选项(没选就「请选一本日历」存不上)⇒ 挪到「全天」之后、日期时间之前。
   - 错误提示原来在滚动区**内部最底部** ⇒ 用户在顶部点「创建」时提示在几百 px
     之下看不见;就算滚到底也被 Scroll 底边切半行。现象是「点了创建什么都没发生」。
     ⇒ 挪到滚动区外面、按钮区上方(实测:Scroll 底 1953,提示 1984~2024,按钮 2074)。

真机验收(设备 5NC0225B27004718,线上 family.yangyq.net):
  - 静态守卫 1112/0;BUILD SUCCESSFUL;install bundle successfully
  - 「+ 新建」bounds [868,931][1018,1045],点击真的弹出「新建日程」
  - 诊断确认 books=15 canWrite=6(数据层完全正常,之前的「选不了日历本」是可见性问题)
  - 日历本挪位后首屏可见:标题高 40px(原 7px),10 本 chip 从 838 排到 1358
  - 错误提示挪位后不滚动即可见:命中「日程得有个标题」
  - **端到端建卡成功**:线上库确认 id=2411 cal=5 20:00~21:00,首页 1 条→2 条;
    测试数据已按 id 精确删除(remaining_test_rows=0),重启后恢复 1 条
2026-10-24 19:14:18 +08:00

493 lines
18 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 · 日程「时间轴」视图(鸿蒙端)
*
* 🔴 用户 2026-10-16:「**没有时间轴展示**……你这不是说让你把 SyncCalendar
* 里面的功能搬过来吗?」
* ⇒ 照 SyncCalendar 的 `DayTimelineView`(在 `Index.ets` 里)移植:
* 左侧小时刻度 + 右侧**三层堆叠**渲染。
*
* 为什么是"三层堆叠"(照 SyncCalendar 的设计,别改):
* · 第 1 层 **整点网格**:每小时一格、格底一条灰线;
* · 第 2 层 **日程色块**:按冲突组分列、纵向用 `Blank` 定位;
* · 第 3 层 **当前时间红线**:浮在最上面。
* ⚠️ 三层尺寸与分格完全一致、上层透明 ⇒
* **色块不会被整点线或红线"切开"**,红线也永远压在最上面。
* 如果图省事把线直接画进日程层,一条日程就会被切成两段。
*
* 🔴 为什么是"组件"而不是 `@Builder`:
* `@Builder` 的**值类型参数不参与状态观察**(见 `FbTagChip.ets` 的说明、
* SDK 规则 `r/quick-rules/05-state.md`)—— 本项目的日程页正是因此
* 出现"点日历下面的日程不变"。这里用 `@ComponentV2` + `@Param`,
* `timeline` 一变整棵子树就重建。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEvent } from '../model/Types';
import {
FbTimelineData, FbTimeBlock, FbTimeGroup, FbTimeRow, FbTimelineUtil, FbViewRange
} from '../common/FbTimeline';
/** 「当前时间红线」的颜色(照 SyncCalendar:系统红) */
const FB_NOW_RED: string = '#FF3B30';
@ComponentV2
export struct FbDayTimeline {
/** 一天的布局(**必须由父组件按选中日期算好传进来**) */
@Param timeline: FbTimelineData = new FbTimelineData();
/** 是不是"今天"(决定要不要画红线) */
@Param isToday: boolean = false;
/** 当前时刻(当天第几分钟) */
@Param nowMin: number = 0;
/** 点一条日程 */
@Event onPick: (e: FbCalEvent) => void = (e: FbCalEvent): void => {
};
/** 1 小时的高度(vp) */
private hourUnit: number = FbTimelineUtil.HOUR_UNIT;
/** 极短色块的可读高度下限(一行"标题 + 时间") */
private minBlockVp: number = 16;
/** 能放下两行的高度门槛 */
private twoLineVp: number = 30;
/** 能放下三行的高度门槛 */
private threeLineVp: number = 46;
// ---- 视窗 ----
private range(): FbViewRange {
return FbTimelineUtil.viewRange(this.timeline.blocks);
}
private viewTop(): number {
return this.range().startHour / 24;
}
private viewBot(): number {
return this.range().endHour / 24;
}
/** 视窗总高(vp) */
private viewH(): number {
const r: FbViewRange = this.range();
return (r.endHour - r.startHour) * this.hourUnit;
}
/** 整日高度(vp) */
private totalH(): number {
return this.hourUnit * 24;
}
/** 视窗内的小时刻度 */
private hourLabels(): number[] {
const r: FbViewRange = this.range();
const out: number[] = [];
for (let h = r.startHour; h < r.endHour; h++) {
out.push(h);
}
return out;
}
private hourText(h: number): string {
return h < 10 ? ('0' + h.toString() + ':00') : (h.toString() + ':00');
}
/** 当前小时是否高亮(仅今天) */
private isNowHour(h: number): boolean {
return this.isToday && Math.floor(FbTimelineUtil.nowRatio(this.nowMin) * 24) === h;
}
/** 红线是否可见(今天 + 落在视窗内) */
private nowVisible(): boolean {
if (!this.isToday) {
return false;
}
const r: number = FbTimelineUtil.nowRatio(this.nowMin);
return r >= this.viewTop() - 0.0001 && r <= this.viewBot() + 0.0001;
}
/** 红线距层顶的偏移(vp) */
private nowOffsetVp(): number {
const y: number = (FbTimelineUtil.nowRatio(this.nowMin) - this.viewTop()) * this.totalH() - 1;
return y < 0 ? 0 : y;
}
// ---- 行序列(空隙 + 冲突组)----
private groups(): FbTimeGroup[] {
return FbTimelineUtil.groupsOf(this.timeline.blocks);
}
/**
* 纵向序列:只看视窗内的部分。
* **本层只有两样东西:空隙(Blank)与冲突组行** ——
* 整点线与红线交给另外两层,所以每条日程永远是完整连续的色块。
*/
private buildRows(): FbTimeRow[] {
const rows: FbTimeRow[] = [];
const gs: FbTimeGroup[] = this.groups();
const top: number = this.viewTop();
const bot: number = this.viewBot();
const dk: string = this.range().startHour.toString() + '_' + this.range().endHour.toString();
let cursor: number = top;
for (const g of gs) {
const gsx: number = g.startRatio > top ? g.startRatio : top;
const gex: number = g.endRatio < bot ? g.endRatio : bot;
if (gex - gsx <= 0.0002) {
continue; // 整组都在视窗外
}
this.pushRow(rows, cursor, gsx, null, dk);
this.pushRow(rows, gsx, gex, g, dk);
cursor = gex;
}
this.pushRow(rows, cursor, bot, null, dk);
return rows;
}
private pushRow(rows: FbTimeRow[], from: number, to: number,
g: FbTimeGroup | null, dk: string): void {
if (to - from <= 0.0002) {
return;
}
const r: FbTimeRow = new FbTimeRow();
r.topRatio = from;
r.botRatio = to;
if (g === null) {
r.kind = 0;
r.key = 'g_' + dk + '_' + rows.length.toString() + '_'
+ Math.round((to - from) * 100000).toString();
} else {
r.kind = 1;
r.group = g;
let sig: string = '';
for (const b of g.blocks) {
sig = sig + '_' + b.key;
}
r.key = 'b_' + dk + '_' + rows.length.toString() + sig;
}
rows.push(r);
}
/** 某一列中、与本行窗口有交集的色块 */
private laneBlocks(r: FbTimeRow, lane: FbTimeBlock[]): FbTimeBlock[] {
const out: FbTimeBlock[] = [];
for (const b of lane) {
const be: number = b.topRatio + b.heightRatio;
if (be > r.topRatio + 0.0001 && b.topRatio < r.botRatio - 0.0001) {
out.push(b);
}
}
return out;
}
/** 列内第 index 个色块之前要空出的高度(vp) */
private lanePadH(r: FbTimeRow, lane: FbTimeBlock[], index: number): number {
const arr: FbTimeBlock[] = this.laneBlocks(r, lane);
let prevEnd: number = r.topRatio;
if (index > 0) {
const p: FbTimeBlock = arr[index - 1];
const pe: number = p.topRatio + p.heightRatio;
prevEnd = pe > r.topRatio ? pe : r.topRatio;
}
const b: FbTimeBlock = arr[index];
const top: number = b.topRatio > r.topRatio ? b.topRatio : r.topRatio;
const d: number = (top - prevEnd) * this.totalH();
return d < 0 ? 0 : d;
}
/** 色块实际渲染高度(vp):极短的尽量垫到可读高度,但不越过同列下一块 */
private blockVp(r: FbTimeRow, lane: FbTimeBlock[], index: number): number {
const arr: FbTimeBlock[] = this.laneBlocks(r, lane);
const b: FbTimeBlock = arr[index];
const real: number = b.heightRatio * this.totalH();
if (real >= this.minBlockVp) {
return real;
}
let room: number = 0;
if (index + 1 < arr.length) {
room = this.lanePadH(r, lane, index + 1);
} else {
const tail: number = (r.botRatio - (b.topRatio + b.heightRatio)) * this.totalH();
room = tail > 0 ? tail : 0;
}
const h: number = real + room;
return h > this.minBlockVp ? this.minBlockVp : h;
}
/** 色块信息行数(按渲染高度判定) */
private blockLines(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): number {
const h: number = this.blockVp(r, lane, index);
if (h >= this.threeLineVp && b.place !== '') {
return 3;
}
if (h >= this.twoLineVp) {
return 2;
}
return 1;
}
/** 两行色块的第二行:`09:00 - 10:30 · 地点` */
private blockMeta(b: FbTimeBlock): string {
if (b.place !== '') {
return b.timeText + ' · ' + b.place;
}
return b.timeText;
}
/** 色块 key 的内容签名(内容变了必须重建,否则 ArkUI 复用旧子组件) */
private blockSig(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): string {
return b.title.length.toString() + '_' + b.place.length.toString() + '_'
+ Math.round(this.blockVp(r, lane, index)).toString();
}
// ============================================================
// 三层
// ============================================================
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@Builder
gridLayer() {
Column() {
ForEach(this.hourLabels(), (h: number) => {
Column()
.width('100%')
.height(this.hourUnit)
.border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } })
.hitTestBehavior(HitTestMode.None)
}, (h: number): string => 'gd_' + h.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None) // 装饰层:点击穿透到色块
}
/** 第 2 层:日程色块(冲突组分行 + 组内分列,纵向用 Blank 定位) */
@Builder
eventLayer() {
Column() {
ForEach(this.buildRows(), (r: FbTimeRow) => {
if (r.kind === 1 && r.group !== null) {
Row() {
ForEach(r.group!.lanes, (lane: FbTimeBlock[], li: number) => {
Column() {
ForEach(this.laneBlocks(r, lane), (b: FbTimeBlock, index: number) => {
Blank().height(this.lanePadH(r, lane, index))
Column({ space: 1 }) {
if (this.blockLines(r, lane, index, b) === 1) {
// 一行:标题 · 时间(地点放不下)
Row({ space: 4 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '55%' })
}
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
if (this.blockLines(r, lane, index, b) >= 3) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.place)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
Text(this.blockMeta(b))
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Start)
.padding({ left: 6, right: 3, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor(b.color)
.clip(true)
.width('100%')
.height(this.blockVp(r, lane, index))
.onClick((): void => {
if (b.ev !== null) {
this.onPick(b.ev);
}
})
}, (b: FbTimeBlock, index: number): string =>
'blk_' + r.key + '_' + b.key + '_' + this.blockSig(r, lane, index, b))
}
.layoutWeight(1)
.height('100%')
.padding({ right: 2 })
.clip(true)
}, (lane: FbTimeBlock[], li: number): string => 'ln_' + r.key + '_' + li.toString())
}
.width('100%')
.height((r.botRatio - r.topRatio) * this.totalH())
.alignItems(VerticalAlign.Top)
} else {
// 空隙:纯占位
Blank().height((r.botRatio - r.topRatio) * this.totalH())
}
}, (r: FbTimeRow): string => r.key)
}
.width('100%')
.height('100%')
}
/**
* 第 3 层:当前时间红线(浮在最上层)
*
* 🔴🔴 v84(用户 2026-10-23,同一条消息里两处):首页的这条线「红色线不对,
* 变短了左侧缺了一块」,日程页那条「红线左侧是不是还有一个红点?如果有,去掉。」
*
* 两个抱怨其实是同一段代码的两个缺陷,都在这里:
* ① 老写法是 `Row { Text('07:02') width(34); 线(layoutWeight 1); 圆点(6) }`
* —— **行首的 34vp 时间文字**把线往右顶掉一整块,用户看到的就是
* "左侧缺一块"(与网页端 `.ag-now` 那次「双重缩进」是同一类病:
* 线的左沿被别的东西占了)。
* ② 行尾那个 `6×6 borderRadius(3)` 就是用户问的**红点**。
*
* ⇒ 两端统一成**一根满宽的红线,没有任何端点装饰、没有文字**。
* 这正是用户 v44 定下的口径:「直接把红点去掉就行了,直接一根红线就可以了」,
* 也是网页端 `.ag-now { left: 0; right: 0; border-top: 2px }` 的等价写法。
*
* ⚠️ 别把时间文字"加回来":只要 Row 里有任何子元素,线就一定短一截。
* 想知道现在几点,看左侧刻度列——那里 `isNowHour` 的那个小时文字**已经是红的**
* (见 `hourLabels()` 那段的 `fontColor`),信息并没有丢。
* ⚠️ 别用 `Row { 线(layoutWeight 1) }` 的写法包一层 —— 能跑,但多一层无谓嵌套,
* 且下次有人往这个 Row 里塞东西,缺一块的 bug 就又回来了。
* ⚠️ `hitTestBehavior(HitTestMode.None)` 必须留:装饰层不能挡住点色块。
*/
@Builder
nowLayer() {
/* ⚠️ 外层必须是**满高的 Column**:`Stack` 的几个子层是「叠」不是「排」,
直接给线写 `.margin({top})` 在部分版本上会因对齐基准不同而漂移。
用 `Blank` 撑开 = 与卡片端 `FbCard4x4.nowLayer()` **同一个写法**,
两端行为一致,改一处就知道另一处该怎么改。 */
Column() {
if (this.nowVisible()) {
Blank().height(this.nowOffsetVp()).hitTestBehavior(HitTestMode.None)
Column()
.width('100%')
.height(2)
.borderRadius(1)
.backgroundColor(FB_NOW_RED)
}
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
build() {
Column({ space: 0 }) {
// ---- 全天 / 跨天:画在 0 点之前,用色块展示 ----
if (this.timeline.allDay.length > 0) {
Column({ space: 4 }) {
ForEach(this.timeline.allDay, (e: FbCalEvent) => {
Row({ space: 6 }) {
Text('全天')
.fontSize(9)
.fontColor(Color.White)
.backgroundColor('#26000000')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
Text(e.title !== '' ? e.title
: (e.calName !== '' ? e.calName : '未命名日程'))
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (e.place !== '') {
Text(e.place)
.fontSize(10)
.fontColor('#D9FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 110 })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.height(26)
.padding({ left: 8, right: 8 })
.borderRadius(8)
.backgroundColor(e.color)
.onClick((): void => {
this.onPick(e);
})
}, (e: FbCalEvent): string => 'al_' + FbTimelineUtil.keyOf(e))
}
.width('100%')
.padding({ bottom: 6 })
}
// ---- 时间轴主体 ----
Row() {
// 左侧小时刻度
Column() {
ForEach(this.hourLabels(), (h: number) => {
Text(this.hourText(h))
.fontSize(10)
.fontColor(this.isNowHour(h) ? FB_NOW_RED : FbColors.faint)
.width(36)
.height(this.hourUnit)
.textAlign(TextAlign.End)
.padding({ right: 4, top: 2 })
.border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } })
}, (h: number): string => 'h' + h.toString())
}
.width(36)
// 右侧内容区:三层堆叠
Stack() {
this.gridLayer()
this.eventLayer()
this.nowLayer()
}
.layoutWeight(1)
.height(this.viewH())
.alignContent(Alignment.TopStart)
.border({ width: { left: 0.5 }, color: { left: FbColors.line2 } })
}
.width('100%')
.alignItems(VerticalAlign.Top)
// ---- 当天没有带时间的日程 ----
if (!this.timeline.hasTimed && this.timeline.allDay.length === 0) {
Text('这天没有安排')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 20, bottom: 20 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}