Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbDayTimeline.ets
T
laoyang 853b0e0b20 v61:顶栏标题=模块名 / 抽屉瘦身 + 头像进个人设置 / 删除按钮红底 / 浮层动画 / 日程卡片与手势
用户这一批九条,逐条对应:

1)「页面上左上角的标题,不再是家书了,而是切换到那个模块,就显示哪个模块的标题。
   这样在页面内容部,就不再出现模块的名字标题了。」
   → TopBar 主标题改读 moduleName()(通知/设置/个人设置三个非模块路由也有名字);
     观影/纪念日/读书/笔记/通知五处页内大标题删掉。

2)「按钮,也需要和主题色保持一致,出现文字的时候,例如观影模块,
   在左上角出现的时候,颜色也需要和主题色保持一致。」
   → 标题色走 fbModuleColor(app.activeModule)(一处算,11 个页面不用各写)。

3)「弹框的外圈颜色,跟模块的主题色保持一致。」
   → 日程详情原来传 bookColor(日历本色)⇒ 改 accentColor();
     7 个模块设置浮层补传 accent: this.accent。

4)「删除按钮全部用红色背景。」
   → 日程编辑/人员编辑从红描边改红底白字;详情「删除联系人」从浅红底改红底白字;
     两处确认框统一 FbColors.red。

5)「做几个动画,例如,点击名册列表,应该有一个过渡动画出现弹框,
   点击详情,框变大了,也应该有一个过渡动画,点编辑,也有动画。」
   → 新建 common/FbAnim.ets(fbMaskTransition / fbSheetTransition /
     fbSheetGrowTransition / fbViewTransition),9 个页面共 17 遮罩 + 16 浮层挂上;
     简要卡→详情用 0.96→1 的"继续长大"(0.94 会先缩一下)。

6)「左侧抽屉,把通知消息、个人设置、修改密码、系统设置,都去掉,
   放在个人设置页,就是在点击右侧头像那里。消息通知不用放进去,
   直接在头像旁边那里就可以了。」
   → 抽屉底部那四行整块删除(抽屉只剩品牌区 + 模块列表);
     新建 pages/account/FbAccountPage.ets(整页,__account 分发),
     点头像 switchModule('__account');改密码真打服务端新接口。

7)「日程……日期下面:12本日历可见,本月124条,这句话去掉。
   点击日程弹出的框,编辑按钮也颜色也要修改。
   全天日程,例如农历,上面显示无标题,读不到这条日程,这个有问题。」
   → 删统计行;编辑按钮从写死名册青 #4A7C82 改模块主题色;
     真根因:衍生条目(农历/节气)的 detailFull **一直是 null**
     (它们没有 /events/<id> 可拉,原来直接 return)⇒ 垫 shellOf(),
     并把"整卡报错"改成"照常画内容 + 一行小字";
     空标题兜底从「(无标题)」改成日历本名。

8)「再做几个卡片,先做日程的,这个参考 synccalendar。」
   → FbDayEventPanel.eventRow 照 SyncCalendar:左侧 4vp 圆头色条 + 标题 +
     时间/全天 + 「↻ 重复」徽标 + 右端日历本名(限宽 30%、同色);
     一行一张独立圆角卡(12)+ 描边,卡间 space 8,不再用 Divider 连成一片。

9)「日程,有很多手势,你参考 synccalendar。」
   → 月历上滑→周视图、周条下划→月视图、周条左右滑→换周。
     真机实测两次踩空后定下写法:**竖向必须 parallelGesture(PanGesture)**
     (外层是 Scroll,SwipeGesture 的"快速划动"会被它的内置滚动先吃掉,
      gesture/priorityGesture 都不行);**横滑用 gesture(PanGesture)**
     (同一节点两条方向不同的 parallelGesture 会互相压制);
     方向按累计位移判、并要求 |dy| > |dx|。切视图走 animateTo + fbViewTransition。

验证:
· 编译 BUILD SUCCESSFUL
· ArkTS 守卫 493/0;Schema 列守卫 12/0;反向验证 39/0(新增 22 条注入逐条确认能变红)
· 服务端接口 e2e 20/0(独立副本 + 真 MariaDB,5 种错误路径 + 原令牌仍有效)
· 真机 _fb_v61_verify.py 21/0(6 组全过);像素采样确认浮层主题色环实测存在
2026-10-19 12:19:54 +08:00

477 lines
17 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 层:当前时间红线(浮在最上层) */
@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)
}
}