Files
FamilyBook_harmonyos/entry/src/main/ets/pages/calendar/FbCalendarPage.ets
T

1070 lines
36 KiB
Plaintext
Raw Normal View History

/**
* 家书 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<void> {
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<void> {
await loadModule<FbCalEvent[]>('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/<id>` 可拉 ⇒ 不请求
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;
}
}