/** * 家书 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 层:当前时间红线(浮在最上层) */ @Builder nowLayer() { if (this.nowVisible()) { Row() { // 左侧时间文字(红线那头) Text(FbTimelineUtil.hhmm(this.nowMin)) .fontSize(9) .fontColor(FB_NOW_RED) .fontWeight(FontWeight.Medium) .textAlign(TextAlign.End) .width(34) Row() .layoutWeight(1) .height(2) .backgroundColor(FB_NOW_RED) Row() .width(6) .height(6) .borderRadius(3) .backgroundColor(FB_NOW_RED) } .width('100%') .alignItems(VerticalAlign.Center) .margin({ top: this.nowOffsetVp() }) .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) } }