/** * 家书 FamilyBook · 日程模块(鸿蒙端) * * 对应 Web 端 `templates/calendar/index.php`。 * * 🔴 v52(用户 2026-10-15):「我们家书的日程功能模块,包括它的界面设计和功能, * 跟 [SyncCalendar] 做得一样,就差不多。在新建的那个地方,右侧那加一个设置按钮, * 点进去之后实际上就是**对这个日程的设置**。然后也是一样的,**有几种不同的视图方式**, * 然后包括**下面日程的显示**啊什么之类的,就按照那个 SyncCalendar 这个模式去做就行了。」 * * ⇒ 照 SyncCalendar 的骨架重做(`D:\Yongquan\code\SyncCalendar`): * · **胶囊分段切视图**:月 / 周 / 列表(`FB_CAL_SETTINGS` 记住默认视图) * · **月视图**:月历格子(日号 + 农历/选中/今天高亮)+ 当日日程 * · **周视图**:7 列日期条(周一→周日)+ 选中日日程 * · **列表视图**:从今天往后、按天分组 * · **右下角悬浮 +** 新建日程 * · **右上角设置齿轮** → 日程设置(默认视图 / 显示方式 / 日历本) * · 点日程 → **详情浮层** → 「编辑日程」→ 编辑浮层 * * ⚠️ 与 SyncCalendar **功能对齐、形态不同**: * 它是"跳页面 + bindSheet",家书统一用**页面内浮层**(见 `FbEventSheet` 的说明)。 * ⚠️ 日历本清单走 `/calendar/books`(**真实 id**)—— * 旧代码从条目里臆造 id,导致新建日程根本落不到正确的本上(见 `FbCalRepo.fetchBooks`)。 */ import { FbColors, FbMetric, fbPalette } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbCalBook, FbCalEvent, isEditableEvent } from '../../model/Types'; import { parseCalendar } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo'; import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../../data/FbCalSettings'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; import { FbDot, FbEmptyBox, FbIconButton } from '../../components/Widgets'; import { FbEventSheet } from '../../components/FbEventSheet'; import { FbEventEditSheet } from '../../components/FbEventEditSheet'; import { FbCalSettingsSheet } from '../../components/FbCalSettingsSheet'; import { FbDayEventPanel } from '../../components/FbDayEventPanel'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; /** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */ const FB_FAINT: string = '#A8988A'; /** 月历格子(一个日期) */ interface FbCalCell { /** 日期数字 */ day: number; /** 该格的完整日期 `YYYY-MM-DD` */ iso: string; /** 是否属于当月 */ inMonth: boolean; /** 是否今天 */ isToday: boolean; /** 该日的条目数(0 不显示点) */ count: number; /** 该日第一个条目的色(画小点用) */ dotColor: string; /** 农历/节日的短文字(初一显示月名、节日显示节日名) */ lunar: string; } /** 列表视图里的"一天"(日期 + 该日日程) */ interface FbAgendaDay { iso: string; /** `10月15日 周三` */ label: string; events: FbCalEvent[]; } /** 周视图里的一格 */ interface FbWeekDay { iso: string; day: number; /** 一 二 三 … */ w: string; isToday: boolean; count: number; lunar: string; } @ComponentV2 export struct FbCalendarPage { /** * 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。 * 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。 */ @BuilderParam topBar: () => void; @Param app: FbAppState = fbAppState(); /** 模块主题色(日程 = 琥珀) */ private accent: string = '#B0763A'; /** 日历本(走 `/calendar/books`,**真实 id**) */ @Local books: FbCalBook[] = []; /** 全部条目(月历格子/列表都从这里查) */ @Local allEvents: FbCalEvent[] = []; /** 顶部提示 */ @Local syncNote: string = ''; /** 加载中 */ @Local loading: boolean = true; /** 当前视图(月 / 周 / 列表) */ @Local mode: FbCalView = 'month'; /** 日程显示方式(时间轴 / 列表)—— 来自日程设置 */ @Local style: FbCalStyle = 'timeline'; /** 当前显示的月份(月视图) */ @Local showYear: number = new Date().getFullYear(); @Local showMonth: number = new Date().getMonth() + 1; /** 选中的日期 `YYYY-MM-DD` */ @Local pickedIso: string = ''; // ---- 浮层状态 ---- /** 详情:正在看的条目(列表里那份) */ @Local detailBase: FbCalEvent | null = null; /** 详情:完整数据(异步拉) */ @Local detailFull: FbCalEventFull | null = null; @Local detailLoading: boolean = false; @Local detailError: string = ''; /** 编辑:null + 非新建 = 关闭 */ @Local editEvent: FbCalEventFull | null = null; @Local editing: boolean = false; /** 新建 */ @Local creating: boolean = false; /** 设置面板 */ @Local settingsOpen: boolean = false; private weekLabels: string[] = ['一', '二', '三', '四', '五', '六', '日']; /** 详情请求序号(防"连点两条,前一条的响应覆盖后一条") */ private detailToken: number = 0; aboutToAppear(): void { // 先按设置里的默认视图展示(用户上次选的) this.mode = FB_CAL_SETTINGS.getView(); this.style = FB_CAL_SETTINGS.getStyle(); this.pickedIso = this.todayIso(); this.load(); this.loadBooks(); } /** * 四个浮层(详情 / 编辑 / 新建 / 设置)任一开着 → 让底部悬浮快捷栏让位。 * * 🔴 为什么必须做:悬浮栏在更外层 `Index` 的 Stack 里,层级比本页高 ⇒ * 浮层底部的按钮会被它盖住、点击被抢走(名册页踩过这个坑)。 * ⚠️ 用 `@Monitor` **一处声明**,而不是在每个开合点手写 openSheet() —— * 手写必然漏(名册页当时有 9 处赋值点)。 * ⚠️ 直接赋 0/1 而不是 `openSheet()/closeSheet()`:本页不会同时开两个浮层, * 用计数法反而会在"页面重建但旧状态没归零"时把计数越堆越高。 */ @Monitor('detailBase', 'editEvent', 'creating', 'settingsOpen') onSheetChanged(): void { const app: FbAppState = fbAppState(); const open: boolean = this.detailBase !== null || this.editing || this.creating || this.settingsOpen; app.sheetCount = open ? 1 : 0; } /** 拉日历本(真实 id / 可写性) */ private async loadBooks(): Promise { const token: string = FB_TOKEN_STORE.get(); if (token === '') { return; } const list: FbCalBook[] = await FbCalRepo.inst().fetchBooks(token); if (list.length > 0) { this.books = list; } } /** * 拉 `/api/v1/calendar`(一次拿全量条目)。 * * ⚠️ 接口没有"按日期查询"的参数(实测整本返回 200+ 条) * ⇒ 拉一次全量、端上按日期筛;切换月份/视图**不再请求**。 */ private async load(): Promise { await loadModule('calendar', parseCalendar, (rows: FbCalEvent[]): void => { this.allEvents = rows; this.loading = false; }, (note: string): void => { this.syncNote = note; this.loading = false; }); } // ============================================================ // 日期 / 筛选 // ============================================================ private pad(n: number): string { return n < 10 ? ('0' + n.toString()) : n.toString(); } private todayIso(): string { const d: Date = new Date(); return d.getFullYear().toString() + '-' + this.pad(d.getMonth() + 1) + '-' + this.pad(d.getDate()); } private isoOf(y: number, m: number, d: number): string { const dt: Date = new Date(y, m - 1, d); return dt.getFullYear().toString() + '-' + this.pad(dt.getMonth() + 1) + '-' + this.pad(dt.getDate()); } /** 只看"可见的日历本"里的条目(设置里能逐本关掉) */ private visibleEvents(): FbCalEvent[] { if (this.books.length === 0) { return this.allEvents; } const out: FbCalEvent[] = []; for (const e of this.allEvents) { // calId=0 的衍生条目(农历/纪念日)不受"本"控制,照常显示 if (e.calId === 0 || FB_CAL_SETTINGS.isVisible(e.calId)) { out.push(e); } } return out; } /** 某一天的条目(已排序) */ private eventsOf(iso: string): FbCalEvent[] { const out: FbCalEvent[] = []; for (const e of this.visibleEvents()) { if (e.date === iso) { out.push(e); } } out.sort((a: FbCalEvent, c: FbCalEvent): number => this.byTime(a, c)); return out; } private byTime(a: FbCalEvent, c: FbCalEvent): number { if (a.allDay === c.allDay) { return a.time < c.time ? -1 : (a.time > c.time ? 1 : 0); } return a.allDay ? -1 : 1; } /** 选中日期的简短标签(`10月15日 周三`) */ private labelOf(iso: string): string { if (iso.length < 10) { return ''; } const y: number = parseInt(iso.substring(0, 4), 10); const m: number = parseInt(iso.substring(5, 7), 10); const d: number = parseInt(iso.substring(8, 10), 10); const w: string[] = ['日', '一', '二', '三', '四', '五', '六']; return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()]; } /** * 农历/节日的短文字。 * * ⚠️ 服务端的农历条目有两种形态(实测): * · 整月一条、`title` 是"农历",`date` 是**初一**那天(`kind=lunar`) * · 节日/节气各一条(`kind=holiday` / `solarterm` / `traditional`) * ⇒ 这里**不自己算农历**(那是 `LunarUtil` 的活,且容易算错), * 直接从条目里取:当天有节日就显示节日名,否则若是初一就显示"农历"当月的标记。 */ private lunarOf(iso: string): string { // 优先:传统节日 / 节气 / 节假日(这类名字短、信息量大) for (const e of this.allEvents) { if (e.date !== iso) { continue; } if (e.kind === 'traditional' || e.kind === 'solarterm' || e.kind === 'holiday') { // 标题可能带 emoji 前缀(如「🎂 婚礼纪念日」不属这类;这里只取纯节日名) return this.shortTitle(e.title); } } // 其次:初一(`kind=lunar` 的条目只在初一有) for (const e of this.allEvents) { if (e.date === iso && e.kind === 'lunar') { return '初一'; } } return ''; } /** 标题截短(农历/节日的名字有的很长,格子里放不下) */ private shortTitle(t: string): string { const s: string = t.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '').trim(); return s.length <= 3 ? s : s.substring(0, 3); } /** 本月条目数(不含农历/节气这类衍生条目,免得数字虚高) */ private monthEventCount(): number { let n: number = 0; const prefix: string = this.showYear.toString() + '-' + this.pad(this.showMonth); for (const e of this.visibleEvents()) { if (e.date.indexOf(prefix) === 0 && (e.kind === 'custom' || e.kind === 'anniv')) { n++; } } return n; } // ============================================================ // 月历格子 // ============================================================ 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 today: string = this.todayIso(); // 前置:上月的尾巴 for (let i = lead - 1; i >= 0; i--) { const d: number = prevDays - i; out.push(this.cellOf(y, m - 1, d, false, today)); } // 本月 for (let d = 1; d <= daysInMonth; d++) { out.push(this.cellOf(y, m, d, true, today)); } // 补齐到整周(补下月的头) const tail: number = (7 - (out.length % 7)) % 7; for (let d = 1; d <= tail; d++) { out.push(this.cellOf(y, m + 1, d, false, today)); } return out; } private cellOf(y: number, m: number, d: number, inMonth: boolean, today: string): FbCalCell { const iso: string = this.isoOf(y, m, d); const list: FbCalEvent[] = this.eventsOf(iso); // 只数"用户日程",不算农历/节气(否则每个格子都有一点) let n: number = 0; let color: string = FB_FAINT; for (const e of list) { if (e.kind === 'custom' || e.kind === 'anniv') { if (n === 0) { color = e.color; } n++; } } const c: FbCalCell = { day: d, iso: iso, inMonth: inMonth, isToday: iso === today, count: n, dotColor: color, lunar: inMonth ? this.lunarOf(iso) : '' }; return c; } /** 网格高度 = 行数 × 行高 */ private gridHeight(): number { const rows: number = Math.ceil(this.cells().length / 7); return rows * 48; } // ============================================================ // 周视图 // ============================================================ /** 本周(周一为首)的 7 天 */ private weekDays(): FbWeekDay[] { const picked: Date = this.pickedIso.length >= 10 ? new Date(parseInt(this.pickedIso.substring(0, 4), 10), parseInt(this.pickedIso.substring(5, 7), 10) - 1, parseInt(this.pickedIso.substring(8, 10), 10)) : new Date(); const lead: number = (picked.getDay() + 6) % 7; const monday: Date = new Date(picked.getFullYear(), picked.getMonth(), picked.getDate() - lead); const today: string = this.todayIso(); const out: FbWeekDay[] = []; for (let i = 0; i < 7; i++) { const dt: Date = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + i); const iso: string = this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); const list: FbCalEvent[] = this.eventsOf(iso); let n: number = 0; for (const e of list) { if (e.kind === 'custom' || e.kind === 'anniv') { n++; } } out.push({ iso: iso, day: dt.getDate(), w: this.weekLabels[i], isToday: iso === today, count: n, lunar: this.lunarOf(iso) }); } return out; } // ============================================================ // 列表视图 // ============================================================ /** 从今天往后(含过去 7 天)按天分组;空的日子不占位 */ private agendaDays(): FbAgendaDay[] { const out: FbAgendaDay[] = []; const now: Date = new Date(); // 往前 7 天到往后 60 天(够用且不至于太长) for (let i = -7; i <= 60; i++) { const dt: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i); const iso: string = this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); const list: FbCalEvent[] = this.eventsOf(iso); if (list.length > 0) { out.push({ iso: iso, label: this.labelOf(iso), events: list }); } } return out; } // ============================================================ // 浮层开合 // ============================================================ /** 点一条日程 → 开详情(先把列表那份垫上,再联网补全) */ private openEvent(e: FbCalEvent): void { this.detailBase = e; this.detailFull = null; this.detailError = ''; // 非 custom 的衍生条目(农历/节气)没有 `/events/` 可拉 ⇒ 不请求 if (!isEditableEvent(e)) { this.detailLoading = false; return; } const token: string = FB_TOKEN_STORE.get(); if (token === '') { this.detailLoading = false; return; } this.detailLoading = true; this.detailToken += 1; const my: number = this.detailToken; FbCalRepo.inst().fetchEvent(e.id, token).then((full: FbCalEventFull | null): void => { if (my !== this.detailToken) { return; } this.detailLoading = false; if (full === null) { this.detailError = '读不到详情'; return; } this.detailFull = full; }); } /** 关详情并开编辑 */ private editFromDetail(): void { if (this.detailFull !== null) { this.editEvent = this.detailFull; this.editing = true; } else if (this.detailBase !== null) { // 详情还没拉回来 ⇒ 先垫一个"只有列表字段"的壳 const b: FbCalEvent = this.detailBase; const shell: FbCalEventFull = { id: b.id, calendar_id: b.calId, title: b.title, location: b.place, starts_at: b.date + ' ' + (b.allDay ? '00:00:00' : (b.time + ':00')), ends_at: b.endTime !== '' ? (b.date + ' ' + b.endTime + ':00') : '', all_day: b.allDay, note: b.note, rrule: '', reminder_minutes: '', status: 'confirmed', transp: false, created_by: 0 }; this.editEvent = shell; this.editing = true; } this.detailBase = null; this.detailFull = null; } private closeDetail(): void { this.detailBase = null; this.detailFull = null; this.detailError = ''; this.detailToken += 1; // 丢弃在途请求 } /** 日程系列变了之后刷新数据 */ private refresh(): void { this.detailBase = null; this.detailFull = null; this.editing = false; this.editEvent = null; this.creating = false; this.load(); this.loadBooks(); } /** 详情是不是开着 */ private detailOpen(): boolean { return this.detailBase !== null; } // ============================================================ // 界面 // ============================================================ build() { Stack({ alignContent: Alignment.BottomEnd }) { // ---- 页面主体(可滚动)---- Scroll() { Column() { this.topBar() this.header() this.modeTabs() if (this.loading) { Row({ space: 8 }) { LoadingProgress().width(18).height(18).color(FbColors.faint) Text('正在读取日程…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } .width('100%') .padding({ top: 40, bottom: 40 }) .justifyContent(FlexAlign.Center) } else if (this.mode === 'month') { this.monthBody() } else if (this.mode === 'week') { this.weekBody() } else { this.agendaBody() } } .width('100%') // 底部悬浮快捷栏 + 这个页面的 + 按钮:留出足够余量,别压住最后一屏 .padding({ bottom: FB_DOCK_RESERVE + 64 }) } .width('100%') .height('100%') .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .onDidScroll(fbOnScroll) .onTouch(fbOnTouch) .align(Alignment.TopStart) // ---- 右下角悬浮 +(照 SyncCalendar)---- Button() { Text('+') .fontSize(26) .fontWeight(FontWeight.Medium) .fontColor(Color.White) } .width(56) .height(56) .borderRadius(28) .backgroundColor(this.accent) .shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: 2 }) // 底部要给悬浮快捷栏让位(它比这个 + 更靠下) .margin({ right: 20, bottom: FB_DOCK_RESERVE + 12 }) .onClick((): void => { this.creating = true; }) // ---- 浮层们 ---- if (this.detailOpen()) { Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.closeDetail(); }) FbEventSheet({ event: this.detailFull, loading: this.detailLoading, error: this.detailError, bookName: this.detailBase !== null ? this.detailBase.calName : '', bookColor: this.detailBase !== null ? this.detailBase.color : '#9AA0A6', canEdit: this.detailBase !== null && isEditableEvent(this.detailBase), onClose: (): void => { this.closeDetail(); }, onEdit: (): void => { this.editFromDetail(); } }) } if (this.editing && this.editEvent !== null) { Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.editing = false; this.editEvent = null; }) FbEventEditSheet({ event: this.editEvent, isCreate: false, books: this.books, onCancel: (): void => { this.editing = false; this.editEvent = null; }, onSaved: (): void => { this.refresh(); }, onDeleted: (): void => { this.refresh(); } }) } if (this.creating) { Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.creating = false; }) FbEventEditSheet({ event: null, isCreate: true, presetDate: this.pickedIso, books: this.books, onCancel: (): void => { this.creating = false; }, onSaved: (): void => { this.refresh(); }, onDeleted: (): void => { this.creating = false; } }) } if (this.settingsOpen) { Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.settingsOpen = false; }) FbCalSettingsSheet({ books: this.books, onClose: (): void => { this.settingsOpen = false; }, onChanged: (): void => { // 设置改了 ⇒ 立刻按新设置重画(换视图 / 换显示方式马上生效) this.mode = FB_CAL_SETTINGS.getView(); this.style = FB_CAL_SETTINGS.getStyle(); } }) } } .width('100%') .height('100%') } /** 页头:月份标题 + 「今天」 + 右侧设置齿轮 */ @Builder header() { Row({ space: 8 }) { Column({ space: 2 }) { if (this.mode === 'month') { Text(this.showYear.toString() + ' 年 ' + this.showMonth.toString() + ' 月') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) } else { Text(this.labelOf(this.pickedIso)) .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) // 「今天」(照 SyncCalendar 那颗:文字按钮、品牌色描边) Text('今天') .fontSize(FbMetric.fontSmall) .fontColor(this.accent) .padding({ left: 12, right: 12, top: 5, bottom: 5 }) .border({ width: 1, color: this.accent }) .borderRadius(FbMetric.radiusPill) .onClick((): void => { const now: Date = new Date(); this.showYear = now.getFullYear(); this.showMonth = now.getMonth() + 1; this.pickedIso = this.todayIso(); }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16: // 「所有页面的设置按钮全部都改成和网页版一致」。 // (网页版日程页的 `_settings_menu.php` 用的就是那个三横线 SVG。) FbIconButton({ icon: $r('app.media.fb_ic_menu'), tint: FbColors.muted, onTap: (): void => { this.settingsOpen = true; } }) } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) } /** 胶囊分段:月 / 周 / 列表(照 SyncCalendar 的 modeTabs) */ @Builder modeTabs() { Row({ space: 6 }) { this.modeTab('month', '月') this.modeTab('week', '周') this.modeTab('agenda', '列表') Blank() } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 }) .alignItems(VerticalAlign.Center) } @Builder modeTab(key: string, label: string) { Text(label) .fontSize(FbMetric.fontSmall) .fontColor(this.mode === key ? Color.White : FbColors.muted) .backgroundColor(this.mode === key ? this.accent : FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .padding({ left: 16, right: 16, top: 6, bottom: 6 }) .onClick((): void => { this.mode = key as FbCalView; // ⚠️ 手动切换**也**存成默认视图 —— 用户下次进来就是他最后用的那个 // (SyncCalendar 只存设置页里选的,用起来反而别扭) FB_CAL_SETTINGS.setView(this.mode); }) } /** 月视图:月历卡 + 选中日日程 */ @Builder monthBody() { Column({ space: 0 }) { // ---- 月历卡 ---- Column({ space: 0 }) { // 月份切换条 Row() { this.navBtn('上一月', -1) 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 列) // 🔴 key 里带上 `this.pickedIso`:格子的选中态是**状态派生**的, // 而 key 若只含日期,切选中日时 key 不变 ⇒ ArkUI 会复用旧子组件。 // (`dayCell` 内部确实读了 `this.pickedIso`,理论上能刷新; // 但把选中态并进 key 是"一定能重建"的稳妥做法, // 42 个格子的重建开销可忽略。) Grid() { ForEach(this.cells(), (c: FbCalCell) => { GridItem() { this.dayCell(c) } }, (c: FbCalCell): string => 'cell_' + c.iso + '_' + this.pickedIso) } .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 }) // ---- 选中日的日程 ---- this.dayPanel() } .width('100%') } /** * 选中日的日程面板(月/周视图共用)。 * * 🔴🔴 v53 修的真 bug(用户 2026-10-16):「你在日历上点击任何一个地方, * **下面的日程都不变**。」 * 根因:这里原来是 `@Builder dayPanel(iso: string)` —— * **`@Builder` 的值类型参数不参与状态观察** * (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」)。 * 关键在**渲染时"读了什么"**: * · 旧写法里这块只读了入参 `iso`(一个普通局部值), * 整棵子树的依赖里**没有 `this.pickedIso`** * ⇒ 点格子后 `pickedIso` 变了,这块**根本不被重新渲染**。 * · 对照 `dayCell(c)` 之所以正常:它在渲染时**直接读了 `this.pickedIso`** * (`c.iso === this.pickedIso`)⇒ 依赖被登记 ⇒ 选中高亮会跟。 * ⇒ 修法:**去掉那个参数**,让它在渲染时直接读 `this.pickedIso` * (这样依赖被登记);外面套的是 `@ComponentV2` 的 `FbDayEventPanel`, * `@Param` 一变就重建。本项目已在 `FbTagChip` 上踩过同一个坑。 * * ⚠️ 所以**不要**把它改回 `dayPanel(iso: string)` 再传 `this.pickedIso` —— * 那样又变成按值传递,症状会原样复发。 */ @Builder dayPanel() { FbDayEventPanel({ label: this.labelOf(this.pickedIso), events: this.eventsOf(this.pickedIso), style: this.style, isToday: this.pickedIso === this.todayIso(), nowMin: this.nowMinutes(), onPick: (e: FbCalEvent): void => { this.openEvent(e); } }) } /** 当前时刻(当天第几分钟)—— 时间轴画"当前时间红线"用 */ private nowMinutes(): number { const d: Date = new Date(); return d.getHours() * 60 + d.getMinutes(); } /** 周视图:7 列日期条 + 选中日日程 */ @Builder weekBody() { Column({ space: 0 }) { Column({ space: 0 }) { Row() { this.navBtn('上一周', -7) Blank() this.navBtn('下一周', 7) } .width('100%') .padding({ bottom: 10 }) // 7 列 Row({ space: 2 }) { ForEach(this.weekDays(), (d: FbWeekDay) => { Column({ space: 4 }) { Text(d.w) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(d.day.toString()) .fontSize(FbMetric.fontSmall) .fontWeight(d.isToday || d.iso === this.pickedIso ? FontWeight.Medium : FontWeight.Normal) .fontColor(d.isToday ? Color.White : (d.iso === this.pickedIso ? this.accent : FbColors.ink)) .width(30) .height(30) .textAlign(TextAlign.Center) .borderRadius(15) .backgroundColor(d.isToday ? this.accent : (d.iso === this.pickedIso ? fbPalette(this.accent).brand + '1F' : Color.Transparent)) // 该日条目数(小圆点,最多 3 个) Row({ space: 2 }) { if (d.count > 0) { ForEach(this.dots(d.count), (i: number) => { FbDot({ dia: 4, fillC: this.accent }) }, (i: number): string => 'wd_' + d.iso + '_' + i.toString()) } } .height(5) if (d.lunar !== '') { Text(d.lunar) .fontSize(8) .fontColor(FbColors.faint) .maxLines(1) } } .layoutWeight(1) .padding({ top: 4, bottom: 4 }) .onClick((): void => { this.pickedIso = d.iso; }) }, (d: FbWeekDay): string => 'wkd_' + d.iso) } .width('100%') .alignItems(VerticalAlign.Top) } .padding(FbMetric.cardPad) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad }) this.dayPanel() } .width('100%') } /** 列表视图:按天分组,从今天往后(每天一块,显示方式同样跟随设置) */ @Builder agendaBody() { Column({ space: 0 }) { if (this.agendaDays().length === 0) { Column() { FbEmptyBox({ title: '接下来还没有安排', hint: '点右下角的 + 加一条日程', icon: $r('app.media.fb_ic_calendar') }) } .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .padding(FbMetric.cardPad) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad }) } else { ForEach(this.agendaDays(), (g: FbAgendaDay) => { // 🔴 与月/周视图共用同一个面板组件(这样"显示方式"设置在三处都生效) FbDayEventPanel({ label: g.label, events: g.events, style: this.style, isToday: g.iso === this.todayIso(), nowMin: this.nowMinutes(), onPick: (e: FbCalEvent): void => { this.openEvent(e); } }) }, (g: FbAgendaDay): string => 'agd_' + g.iso) } } .width('100%') } /** 上一月 / 下一月(周视图里步进 7 天) */ @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(() => { if (this.mode === 'week') { // 周视图:按天挪(±7) const base: Date = this.pickedIso.length >= 10 ? new Date(parseInt(this.pickedIso.substring(0, 4), 10), parseInt(this.pickedIso.substring(5, 7), 10) - 1, parseInt(this.pickedIso.substring(8, 10), 10)) : new Date(); const dt: Date = new Date(base.getFullYear(), base.getMonth(), base.getDate() + delta); this.pickedIso = this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); return; } // 月视图:按月挪 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; }) } /** 一个日期格子(照 SyncCalendar:日号 + 农历,选中/今天高亮) */ @Builder dayCell(c: FbCalCell) { Column({ space: 2 }) { Text(c.day.toString()) .fontSize(FbMetric.fontSmall) .fontWeight(c.isToday || c.iso === this.pickedIso ? 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.iso === this.pickedIso && c.inMonth ? fbPalette(this.accent).brand + '1F' : Color.Transparent)) // 农历/节日(有才显示) if (c.lunar !== '') { Text(c.lunar) .fontSize(8) .fontColor(c.iso === this.pickedIso && c.inMonth ? FbColors.muted : FbColors.faint) .maxLines(1) } else { // 没有农历就留出等高的空,免得两行格子和一行格子高度不一 Row().height(10) } // 事件小点(最多 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.iso + '_' + i.toString()) } } .height(5) } .width('100%') .height(48) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .onClick(() => { this.pickedIso = c.iso; // 点到上/下月的格子 ⇒ 顺带把月历翻过去(SyncCalendar 也是这个行为) if (!c.inMonth && c.iso.length >= 10) { this.showYear = parseInt(c.iso.substring(0, 4), 10); this.showMonth = parseInt(c.iso.substring(5, 7), 10); } }) } /** * ⚠️ 日程条目行(列表模式)已经搬进 `FbDayEventPanel` 组件里 * (月/周/列表三处共用同一份 + 跟随"显示方式"设置)—— * 这里不再保留旧的 `@Builder eventRow`(那是 `@Builder` 值参数不刷新的老写法)。 */ /** 只列「显示着的」日历本 */ private visibleBooks(): FbCalBook[] { const out: FbCalBook[] = []; for (const b of this.books) { if (FB_CAL_SETTINGS.isVisible(b.id)) { out.push(b); } } return out; } /** 造「点」的索引数组(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; } }