diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index 9340212..2b79895 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -62,19 +62,30 @@ const AGENDA_BACK_DAYS: number = 7; const AGENDA_FWD_DAYS: number = 60; /** - * 全量同步的请求范围(`load()` 用)。 + * 全量同步的请求范围:**今年 ±5 年**。 * - * 🔴 2026-10-30 用户:「全部拉取,全量同步」。 - * 起因:切到下个月再点某一天,端上没数据(即使服务端有)。 - * 根因是范围写死成「今天-7 ~ +60」且切月不重拉 ⇒ 窗口外的日子永远空。 - * ⇒ 改成一次性拉全量,端上按日期筛,翻到哪个月都有。 + * 🔴 2026-11-02 修:原为写死的 `1970-01-01 ~ 2100-12-31`(47846 天)。 + * 服务端要为每一天生成农历条目 ⇒ 每次进页面要传上万条(约 6MB), + * 而用户实际只看眼前几年的日历。 * - * ⚠️ 这两个值取得很宽(1970~2100),是因为服务端会按范围**展开重复日程**: - * 本账号实测 28628 条 / 9.3MB / 1.3s。宁可一次多拉,也不要再出现"翻月没数据"。 - * 流量成本由 `module_cache` 兜住(缓存键带窗口标识,见 `load()`)。 + * 为什么是 ±5 年: + * · 过去的日程不会早于用户开始用这个 App 的时间 + * · 未来的日程不会排到 5 年以后(长期事项是"提醒",不占日程) + * · 农历算法本身只支持 1900~2100,再宽的区间有一部分是白算 + * + * ⚠️ **动态计算,不写死年份**:写死会随时间失效(2030 年时 2025 就成了远古)。 + * 跨年后范围整体平移,缓存键(含 from/to)随之变化 ⇒ 旧缓存自然失效, + * 不会出现"拿去年的数据当今年"。 */ -const CAL_FULL_FROM: string = '1970-01-01'; -const CAL_FULL_TO: string = '2100-12-31'; +const CAL_SPAN_YEARS: number = 5; + +/** 今天所在年份往前/后各 5 年的 1 月 1 日 / 12 月 31 日 */ +function calFullRange(): [string, string] { + const y: number = new Date().getFullYear(); + const from: string = (y - CAL_SPAN_YEARS).toString() + '-01-01'; + const to: string = (y + CAL_SPAN_YEARS).toString() + '-12-31'; + return [from, to]; +} /** * 全量请求的超时(毫秒)。 @@ -140,6 +151,26 @@ export struct FbCalendarPage { @Local books: FbCalBook[] = []; /** 全部条目(月历格子/列表都从这里查) */ @Local allEvents: FbCalEvent[] = []; + + // ---------------- 派生索引(性能)---------------- + // + // 🔴 2026-11-02 用户:「点悬浮按钮进日程,加载得好几秒」。 + // 根因:`lunarOf()` 与 `visibleEvents()` 每次调用都全表遍历 28628 条, + // 而它们都在渲染路径上(月视图 42 格 × 2 遍 ⇒ 约 240 万次比较)。 + // ⇒ 预建索引,把 O(n²) 降到 O(n)。 + // + // ⚠️ 这两个字段**不是状态**(不加 @Local): + // 它们是纯派生缓存,改了不需要触发重渲染 —— 重渲染由 allEvents/evtVer 驱动。 + // 加 @Local 反而会在每次重建索引时多触发一轮 build。 + + /** `iso → 农历/节日文字`。节日优先于农历日名(与旧 lunarOf 的优先级一致) */ + private lunarIdx: Map = new Map(); + /** `iso → 当天条目`,供 eventsOf 直接取用 */ + private dayIdx: Map = new Map(); + /** 索引对应的数据版本;与 evtVer 不一致就重建 */ + private idxVer: number = -1; + /** 数据/筛选条件版本号:allEvents、books、日历本可见性任一变化都 ++ */ + private evtVer: number = 0; /** 顶部提示 */ @Local syncNote: string = ''; /** 加载中 */ @@ -384,6 +415,8 @@ export struct FbCalendarPage { const list: FbCalBook[] = await FbCalRepo.inst().fetchBooks(token); if (list.length > 0) { this.books = list; + // books 变了会影响 visibleEvents(books 为空时直接返回 allEvents) + this.evtVer++; } } @@ -414,14 +447,17 @@ export struct FbCalendarPage { * 端上就把"只含 10 月~12 月的数据"当成全量显示 —— 那正是这个 bug 的帮凶。 */ private async load(): Promise { - const from: string = CAL_FULL_FROM; - const to: string = CAL_FULL_TO; + const range5: [string, string] = calFullRange(); + const from: string = range5[0]; + const to: string = range5[1]; const range: string = '/api/v1/calendar?from=' + from + '&to=' + to; /* 缓存键带上窗口:窗口一变就是另一条缓存,不会读到别的窗口的旧数据 */ const cacheKey: string = 'calendar:' + from + ':' + to; await loadModule(cacheKey, parseCalendar, (rows: FbCalEvent[]): void => { this.allEvents = rows; + // 数据换了 ⇒ 派生索引必须重建 + this.evtVer++; this.loading = false; }, (note: string): void => { @@ -457,7 +493,90 @@ export struct FbCalendarPage { return this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); } - /** 只看"可见的日历本"里的条目(设置里能逐本关掉) */ + /** + * 重建派生索引(`lunarIdx` + `dayIdx`)。**一次遍历搞定**。 + * + * ⚠️ 只在数据/筛选条件变化后调用(由 `evtVer` 判定),不要在渲染里直接调。 + * + * 农历文字的优先级必须与旧 `lunarOf()` 完全一致,否则会出现 + * 「初一」盖掉「中秋」这类回退: + * 1. `traditional` / `solarterm` / `holiday` → 用它的标题 + * 2. 都没有时,才用 `kind === 'lunar'` 的标题 + */ + private ensureIndex(): void { + if (this.idxVer === this.evtVer) { + return; + } + + const lunarIdx: Map = new Map(); + const dayIdx: Map = new Map(); + + // 两级缓冲:先收"节日类",再收"农历日名"。 + // 用两个 Map 分开存,最后按"节日优先"合并 —— 等价于旧实现的两次遍历, + // 但只扫一遍源数据。 + const fest: Map = new Map(); + const lunar: Map = new Map(); + + for (const e of this.allEvents) { + // ---- 农历/节日索引 ---- + if (e.kind === 'traditional' || e.kind === 'solarterm' || e.kind === 'holiday') { + if (!fest.has(e.date)) { + const t: string = this.shortTitle(e.title); + if (t !== '') { + fest.set(e.date, t); + } + } + } else if (e.kind === 'lunar') { + if (!lunar.has(e.date)) { + const t: string = this.shortTitle(e.title); + if (t !== '') { + lunar.set(e.date, t); + } + } + } + + // ---- 当天条目索引(含可见性过滤)---- + // calId=0 的衍生条目(农历/纪念日)不受"本"控制,照常显示 + const vis: boolean = this.books.length === 0 + || e.calId === 0 + || FB_CAL_SETTINGS.isVisible(e.calId); + if (vis) { + const bucket: FbCalEvent[] | undefined = dayIdx.get(e.date); + if (bucket === undefined) { + dayIdx.set(e.date, [e]); + } else { + bucket.push(e); + } + } + } + + // 节日优先,其次农历日名 + fest.forEach((v: string, k: string): void => { + lunarIdx.set(k, v); + }); + lunar.forEach((v: string, k: string): void => { + if (!lunarIdx.has(k)) { + lunarIdx.set(k, v); + } + }); + + // 每个桶内按时间排序(原本 eventsOf 每次都排,这里一次排好) + dayIdx.forEach((arr: FbCalEvent[]): void => { + arr.sort((a: FbCalEvent, c: FbCalEvent): number => this.byTime(a, c)); + }); + + this.lunarIdx = lunarIdx; + this.dayIdx = dayIdx; + this.idxVer = this.evtVer; + } + + /** + * 只看"可见的日历本"里的条目(设置里能逐本关掉)。 + * + * ⚠️ 保留这个函数是为了兼容既有调用方(`monthEventCount` 等)。 + * 新的渲染路径请用 `eventsOf()` —— 它直接查 `dayIdx`,不再全表扫。 + * 这里仍然会遍历,但**只在下游确实需要"全量可见列表"时才走**。 + */ private visibleEvents(): FbCalEvent[] { if (this.books.length === 0) { return this.allEvents; @@ -472,16 +591,12 @@ export struct FbCalendarPage { return out; } - /** 某一天的条目(已排序) */ + /** 某一天的条目(已排序,O(1) 命中索引) */ 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; + this.ensureIndex(); + const bucket: FbCalEvent[] | undefined = this.dayIdx.get(iso); + // ⚠️ 返回副本:调用方可能会 sort/push,别让他们改到索引里的数组 + return bucket === undefined ? [] : bucket.slice(); } private byTime(a: FbCalEvent, c: FbCalEvent): number { @@ -512,36 +627,17 @@ export struct FbCalendarPage { * ⇒ 这里**不自己算农历**(那是 `LunarUtil` 的活,且容易算错), * 直接从条目里取:当天有节日就显示节日名,否则若是初一就显示"农历"当月的标记。 */ + /** + * 某天的农历/节日文字(O(1))。 + * + * 🔴 2026-11-02:原来是每次调用都全表遍历 28628 条、而且扫两遍 + * (先找节日、再找农历)。月视图 42 格 ⇒ 约 240 万次比较,全部在渲染路径上。 + * 现改为查预建索引(见 `ensureIndex`),并保持**节日优先于农历日名**的语义。 + */ 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); - } - } - // 其次:农历日名 - // - // 🔴🔴 2026-10-18 修的真 bug(用户:「农历全部都是初一,说明是错误的数据」): - // 这里原来**硬编码返回 `'初一'`**,注释还写着"`kind=lunar` 的条目只在初一有" —— - // 而事实相反:服务端 `Calendar::month()` 里 `Holiday::lunarRange()` 是 - // **每一天都出一条**(`title` = `Lunar::monthName()`(初一显示月名) - // 或 `Lunar::dayName()`),端上把这个真值丢掉了,于是**整月都显示"初一"**。 - // ⇒ 直接取服务端给的 title(它本来就是"这一天的农历文字")。 - // ⚠️ title 可能带节日后缀(`八月十五 · 中秋`)—— `shortTitle` 截前 3 字, - // 而这张格子里只有 8sp 的位置,本来就该显示短的。 - for (const e of this.allEvents) { - if (e.date === iso && e.kind === 'lunar') { - const t: string = this.shortTitle(e.title); - if (t !== '') { - return t; - } - } - } - return ''; + this.ensureIndex(); + const v: string | undefined = this.lunarIdx.get(iso); + return v === undefined ? '' : v; } /** 标题截短(农历/节日的名字有的很长,格子里放不下) */