/** * 家书 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; } }