diff --git a/entry/src/main/ets/data/FbCalStore.ets b/entry/src/main/ets/data/FbCalStore.ets new file mode 100644 index 0000000..3ed2886 --- /dev/null +++ b/entry/src/main/ets/data/FbCalStore.ets @@ -0,0 +1,286 @@ +/** + * 岁月家 FamilyBook · 日程本地库(鸿蒙端) + * + * 🔴 2026-11-03 新建。起因是用户的一句话点破了架构错误: + * 「这个数据量很大,不是应该每次同步完了之后解包放在一个数据库里吗? + * 然后点开日程,不就是把今天的日程从数据库里查出来吗? + * 这个应该速度非常快才对呀!怎么能每次打开都要去读所有的数据呢?」 + * + * 他说得完全对。改之前的样子: + * 日程 → `module_cache` 表(存整个接口 JSON 的**原样文本**,9.3MB) + * 每次进页面:读 9.3MB 字符串 → JSON.parse 28628 个对象 → 全塞进内存数组 + * ⇒ 那"好几秒"就花在这里。 + * + * `module_cache` 的设计注释本来写着「解析一遍毫秒级,可接受」—— + * 那个判断建立在"数据量小"上,日程涨到 2.8 万条之后假设就破了。 + * + * 改之后: + * 同步 → 批量写入 `cal_events` 实体表 + * 页面 → `WHERE date = '2026-11-03'`(走索引,毫秒级) + * + * 这就是用户说的「把今天的查出来」。 + */ + +import { relationalStore } from '@kit.ArkData'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { FbDb, FbRow } from './FbDb'; +import { FbCalEvent } from '../model/Types'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbCalStore'; + +/** + * 每批写入的行数。 + * + * ⚠️ 取 20 是照 `FbMovieRepo` 的**实测**结论: + * 一次塞 200 行时 `batchInsert` 卡死不返回,分批到 20 能正常完成。 + * 日程有 2.8 万条,更必须分批 —— 别随手改大。 + */ +const BATCH_SIZE: number = 20; + +/** `cal_events` 表的列(建表语句与此处必须一致,见 FbSchema) */ +const COLS: string = 'uid, ev_id, cal_id, title, date, time, end_time, ' + + 'cal_name, color, all_day, place, note, kind'; + +/** + * 日程仓库。 + * + * 与 `FbMovieRepo` 同一定位:**UI 只读这里,不直接读接口**。 + * 有网没网读的都是同一份本地数据,于是"断网可用""零等待"是天然成立的。 + */ +export class FbCalStore { + private static _inst: FbCalStore | null = null; + + static inst(): FbCalStore { + if (FbCalStore._inst === null) { + FbCalStore._inst = new FbCalStore(); + } + return FbCalStore._inst; + } + + private db: FbDb = FbDb.inst(); + + // ============================================================ + // 写入 + // ============================================================ + + /** + * 把一批日程**整体替换**进本地库。 + * + * @param rows 解析好的日程(`parseCalendar` 的输出) + * @param stamp 本批数据的拉取时刻(秒)。用于区分"这一批"和"更早的残留" + * + * ⚠️ 先清空再整批写入,而不是逐条 upsert: + * 服务端每次给的是**这个时间窗口的全部数据**, + * 逐条 upsert 会让"服务端已删除的条目"永远留在本地。 + * 整批替换才是对的语义。 + * + * ⚠️ 全程在一个事务里:中途失败就整批回滚, + * 否则会出现"删了旧的、新的没写完"——空日历,比慢更糟。 + */ + async replaceAll(rows: FbCalEvent[], stamp: number): Promise { + if (!this.db.isReady()) { + hilog.warn(DOMAIN, TAG, '库未就绪,跳过写日程'); + return; + } + + const t0: number = Date.now(); + + await this.db.transaction(async (tx: relationalStore.Transaction): Promise => { + // ① 清空旧数据 + await tx.execute('DELETE FROM cal_events'); + + // ② 分批插入 + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const chunk: FbCalEvent[] = rows.slice(i, i + BATCH_SIZE); + const buckets: relationalStore.ValuesBucket[] = []; + for (const e of chunk) { + const vb: relationalStore.ValuesBucket = { + 'uid': this.uidOf(e), + 'ev_id': e.id, + 'cal_id': e.calId, + 'title': e.title, + 'date': e.date, + 'time': e.time, + 'end_time': e.endTime, + 'cal_name': e.calName, + 'color': e.color, + 'all_day': e.allDay ? 1 : 0, + 'place': e.place, + 'note': e.note, + 'kind': e.kind, + 'fetched_at': stamp + }; + buckets.push(vb); + } + // ⚠️ 用 tx.insert + 冲突替換:`(cal_id, uid)` 唯一索引会挡住重复 + for (const vb of buckets) { + await tx.insert('cal_events', vb, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE); + } + } + }); + + hilog.info(DOMAIN, TAG, '日程入库 %{public}d 条,耗时 %{public}dms', + rows.length, Date.now() - t0); + } + + /** + * 给一条日程算稳定 UID。 + * + * 为什么要自己造:服务端的 `ref_id` 对衍生条目(农历/节气/纪念日)**恒为 0**, + * 几万条全是 0,没法当唯一键。 + * + * 组成:`kind|calId|evId|date|time|title的短哈希` + * · 自定义日程:evId 是真 id,天然唯一 + * · 衍生条目:靠 kind + date 区分(农历每天一条,节气每年 24 条) + * · 再挂 title 的哈希,防止同一天同 kind 的多条(如两个节日)互相覆盖 + */ + private uidOf(e: FbCalEvent): string { + const base: string = e.kind + '|' + e.calId + '|' + e.id + '|' + e.date + '|' + e.time; + // 简易 32 位哈希(FNV-1a):够用,不追求密码学强度 + let h: number = 0x811c9dc5; + const src: string = base + '|' + e.title; + for (let i = 0; i < src.length; i++) { + h = h ^ src.charCodeAt(i); + h = (h * 0x01000193) >>> 0; + } + return base + '|' + h.toString(16); + } + + // ============================================================ + // 读取(页面真正用的就是这几个) + // ============================================================ + + /** 本地有没有数据(判断要不要等网络) */ + async count(): Promise { + if (!this.db.isReady()) { + return 0; + } + return await this.db.scalar('SELECT COUNT(*) FROM cal_events'); + } + + /** 库里的数据是什么时候拉的(用于显示"数据可能不新") */ + async fetchedAt(): Promise { + if (!this.db.isReady()) { + return 0; + } + return await this.db.scalar('SELECT COALESCE(MAX(fetched_at), 0) FROM cal_events'); + } + + /** + * 取某个日期区间的日程(含衍生条目)。 + * + * `WHERE date BETWEEN ? AND ?` 走 `idx_cal_date`,几万条里取一个月是毫秒级。 + * + * @param from 'YYYY-MM-DD'(含) + * @param to 'YYYY-MM-DD'(含) + */ + async between(from: string, to: string): Promise { + if (!this.db.isReady()) { + return []; + } + const rows: FbRow[] = await this.db.queryRows( + 'SELECT ' + COLS + ' FROM cal_events WHERE date >= ? AND date <= ? ORDER BY date, time', + [from, to]); + return this.toModels(rows); + } + + /** 取某一天的全部条目 */ + async ofDay(iso: string): Promise { + if (!this.db.isReady()) { + return []; + } + const rows: FbRow[] = await this.db.queryRows( + 'SELECT ' + COLS + ' FROM cal_events WHERE date = ? ORDER BY all_day DESC, time', + [iso]); + return this.toModels(rows); + } + + /** + * 取某个日期区间的**农历/节日文字**(月视图每个格子要的那一小段)。 + * + * ⚠️ 只要 `kind` 是那几类的,不要整个月所有条目 —— + * 月视图 42 个格子只需要"这天是什么农历/节日", + * 真日程由 `between()` 另取。分开查比取全量再过滤快得多。 + */ + async lunarBetween(from: string, to: string): Promise { + if (!this.db.isReady()) { + return []; + } + const rows: FbRow[] = await this.db.queryRows( + `SELECT ${COLS} FROM cal_events + WHERE date >= ? AND date <= ? + AND kind IN ('lunar', 'traditional', 'solarterm', 'holiday') + ORDER BY date`, + [from, to]); + return this.toModels(rows); + } + + /** 某天的真日程(排除农历/节气这类衍生条目) */ + async eventsOfDay(iso: string): Promise { + if (!this.db.isReady()) { + return []; + } + const rows: FbRow[] = await this.db.queryRows( + `SELECT ${COLS} FROM cal_events + WHERE date = ? + AND kind NOT IN ('lunar', 'traditional', 'solarterm', 'holiday') + ORDER BY all_day DESC, time`, + [iso]); + return this.toModels(rows); + } + + /** 库里最早的日期(决定能往前翻到哪) */ + async minDate(): Promise { + if (!this.db.isReady()) { + return ''; + } + const r: FbRow | null = await this.db.queryOne( + 'SELECT MIN(date) AS d FROM cal_events WHERE date <> \'\'', []); + return r === null ? '' : r.strOr('d', ''); + } + + /** 库里最晚的日期 */ + async maxDate(): Promise { + if (!this.db.isReady()) { + return ''; + } + const r: FbRow | null = await this.db.queryOne( + 'SELECT MAX(date) AS d FROM cal_events WHERE date <> \'\'', []); + return r === null ? '' : r.strOr('d', ''); + } + + /** 清空("重装/清缓存"时用) */ + async clear(): Promise { + if (!this.db.isReady()) { + return; + } + await this.db.exec('DELETE FROM cal_events'); + } + + // ============================================================ + // 行 → 模型 + // ============================================================ + + private toModels(rows: FbRow[]): FbCalEvent[] { + const out: FbCalEvent[] = []; + for (const r of rows) { + const e: FbCalEvent = { + id: r.num('ev_id', 0), + calId: r.num('cal_id', 0), + title: r.strOr('title', ''), + date: r.strOr('date', ''), + time: r.strOr('time', ''), + endTime: r.strOr('end_time', ''), + calName: r.strOr('cal_name', ''), + color: r.strOr('color', '') !== '' ? r.strOr('color', '') : '#A84A28', + allDay: r.num('all_day', 0) === 1, + place: r.strOr('place', ''), + note: r.strOr('note', ''), + kind: r.strOr('kind', '') + }; + out.push(e); + } + return out; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbDb.ets b/entry/src/main/ets/data/FbDb.ets index 40e03e4..5bdc54d 100644 --- a/entry/src/main/ets/data/FbDb.ets +++ b/entry/src/main/ets/data/FbDb.ets @@ -362,6 +362,45 @@ export class FbDb { } } + + // ---- v7 → v8:日程实体表(cal_events)---- + // 🔴 2026-11-03:日程从 module_cache(存整包 JSON)改为实体表。 + // 老库升级时这张表是空的;第一次同步会全量灌进去。 + // ⚠️ 建表语句必须与 FbSchema 里**完全一致** —— 只改一处就是 + // "新装有、老库没有"或者反过来(这个坑代码里已记 5 次)。 + if (from < 8) { + const calDdl: string[] = [ + `CREATE TABLE IF NOT EXISTS cal_events ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT NOT NULL DEFAULT '', + ev_id INTEGER NOT NULL DEFAULT 0, + cal_id INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL DEFAULT '', + time TEXT NOT NULL DEFAULT '', + end_time TEXT NOT NULL DEFAULT '', + cal_name TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + all_day INTEGER NOT NULL DEFAULT 0, + place TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT '', + fetched_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS idx_cal_date ON cal_events (date)`, + `CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`, + ]; + for (const sql of calDdl) { + try { + await store.executeSql(sql); + } catch (err) { + // 已经建过就跳过(迁移中途失败后重跑的场景) + hilog.warn(DOMAIN, TAG, '建 cal_events 跳过:%{public}s', JSON.stringify(err)); + } + } + } + store.version = FB_DB_VERSION; } diff --git a/entry/src/main/ets/data/FbSchema.ets b/entry/src/main/ets/data/FbSchema.ets index b26a740..7e78a33 100644 --- a/entry/src/main/ets/data/FbSchema.ets +++ b/entry/src/main/ets/data/FbSchema.ets @@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db'; * 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** —— * 那是拿用户的手机当试验田。必须按版本逐步迁移。 */ -export const FB_DB_VERSION: number = 7; +export const FB_DB_VERSION: number = 8; /** * 建表语句。 @@ -315,6 +315,46 @@ export const FB_SCHEMA_SQL: string[] = [ payload TEXT NOT NULL DEFAULT '', fetched_at INTEGER NOT NULL DEFAULT 0 )`, + + // ---------- 日程实体表(v8 新增)---------- + // + // 用户 2026-11-03 指出:「数据量这么大,不是应该同步完放进数据库, + // 点开日程就把今天的查出来吗?怎么能每次都去读全部数据?」 + // + // 他说得对。原来日程走 `module_cache`(存 9.3MB JSON 原样文本), + // 每次进页面都要读 9.3MB + JSON.parse 28628 个对象 + 全塞内存。 + // 那张表的注释本来说「解析一遍毫秒级,可接受」—— 那是按小数据量假设写的, + // 日程 2.8 万条之后这个假设就破了。 + // + // ⇒ 日程单独建实体表(跟 events 表给观影用的思路一致), + // 同步时批量入库,页面按日期范围查。 + // + // ⚠️ 为什么主键不用 id: + // 服务端 `ref_id` 对衍生条目(农历/节气/纪念日)恒为 0, + // 几万条全是 0 会撞主键。用自增 rowid + (cal_id, uid) 唯一索引。 + `CREATE TABLE IF NOT EXISTS cal_events ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT NOT NULL DEFAULT '' COMMENT '服务端条目稳定标识', + ev_id INTEGER NOT NULL DEFAULT 0 COMMENT '服务端 ref_id(自定义日程才是真 id)', + cal_id INTEGER NOT NULL DEFAULT 0 COMMENT '所属日历本 id;0=衍生条目', + title TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL DEFAULT '' COMMENT 'YYYY-MM-DD', + time TEXT NOT NULL DEFAULT '' COMMENT 'HH:mm;全天为空', + end_time TEXT NOT NULL DEFAULT '', + cal_name TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + all_day INTEGER NOT NULL DEFAULT 0, + place TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT '' COMMENT 'custom / lunar / holiday / anniv …', + fetched_at INTEGER NOT NULL DEFAULT 0 COMMENT '这批数据的拉取时刻(全量替换时用来防并发)' + )`, + // 按日期查是最高频的(「今天的日程」「本月」),必须索引 + `CREATE INDEX IF NOT EXISTS idx_cal_date ON cal_events (date)`, + // 农历/节日按 kind 过滤(渲染日历时区分「真日程」和「衍生条目」) + `CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`, + // 同步时按 uid 去重/更新 + `CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`, ]; /** 建表语句分组说明(供日志/排查用) */ @@ -339,6 +379,10 @@ export const FB_TABLE_GROUPS: FbTableGroup[] = [ group: '名册', tables: ['person_roster', 'person_tags'] }, + { + group: '日程', + tables: ['cal_events'] + }, { group: '同步元数据(本地专有)', tables: ['sync_state', 'pending_ops', 'module_sync', 'attach_cache'] diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index 2b79895..697baa2 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -32,6 +32,7 @@ import { FbCalBook, FbCalEvent, isEditableEvent, isEventWritable } from '../../m import { parseCalendar } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo'; +import { FbCalStore } from '../../data/FbCalStore'; import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../../data/FbCalSettings'; import { FB_CARD_FORMS } from '../../data/FbCardForms'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; @@ -149,8 +150,17 @@ export struct FbCalendarPage { /** 日历本(走 `/calendar/books`,**真实 id**) */ @Local books: FbCalBook[] = []; - /** 全部条目(月历格子/列表都从这里查) */ - @Local allEvents: FbCalEvent[] = []; + /** + * **当前显示范围**内的条目(不是全量!)。 + * + * 🔴 2026-11-03 从 `allEvents` 改名。原来这里装的是全量 28628 条, + * 每次进页面都要读 9.3MB JSON + 解析 + 塞内存。 + * 现在只装当前视图需要的那一段(月视图约 3 个月、列表视图约 2 个月), + * 翻月时按需重查 —— 数据在 SQLite 里,`WHERE date BETWEEN` 走索引。 + * + * ⚠️ **不要再往这里塞全量数据**。名字里的 range 就是提醒。 + */ + @Local rangeEvents: FbCalEvent[] = []; // ---------------- 派生索引(性能)---------------- // @@ -160,7 +170,7 @@ export struct FbCalendarPage { // ⇒ 预建索引,把 O(n²) 降到 O(n)。 // // ⚠️ 这两个字段**不是状态**(不加 @Local): - // 它们是纯派生缓存,改了不需要触发重渲染 —— 重渲染由 allEvents/evtVer 驱动。 + // 它们是纯派生缓存,改了不需要触发重渲染 —— 重渲染由 rangeEvents/evtVer 驱动。 // 加 @Local 反而会在每次重建索引时多触发一轮 build。 /** `iso → 农历/节日文字`。节日优先于农历日名(与旧 lunarOf 的优先级一致) */ @@ -169,8 +179,17 @@ export struct FbCalendarPage { private dayIdx: Map = new Map(); /** 索引对应的数据版本;与 evtVer 不一致就重建 */ private idxVer: number = -1; - /** 数据/筛选条件版本号:allEvents、books、日历本可见性任一变化都 ++ */ + /** 数据/筛选条件版本号:rangeEvents、books、日历本可见性任一变化都 ++ */ private evtVer: number = 0; + + /** + * 范围重查的序号(防抖)。 + * + * 翻月时 `showYear` 与 `showMonth` 可能同时变 ⇒ `@Monitor` 触发多次。 + * 每次重查前记下当前序号,查完对比:序号变了说明有更新的请求, + * 丢弃这次结果(避免旧结果覆盖新结果)。 + */ + private rangeSeq: number = 0; /** 顶部提示 */ @Local syncNote: string = ''; /** 加载中 */ @@ -446,30 +465,162 @@ export struct FbCalendarPage { * 如果某次请求失败,`FbModuleRepo` 会把**旧范围**的缓存又 sink 回来, * 端上就把"只含 10 月~12 月的数据"当成全量显示 —— 那正是这个 bug 的帮凶。 */ + /** + * 当前视图需要显示的日期区间。 + * + * 🔴 2026-11-03:这是本页性能改造的核心。 + * 原来页面加载**全量 28628 条**(读 9.3MB JSON + 解析 + 塞内存), + * 用户点进日程要等好几秒。现在只查「当前要显示的那一段」。 + * + * 月视图:当月前后各 1 个月(月历格子会显示上/下月的尾巴,多留一月够用) + * 周视图:当周前后各 1 周 + * 列表:今天 -7 ~ +90(与 agendaDays 的口径一致) + */ + private rangeOfView(): [string, string] { + const today: Date = new Date(); + if (this.mode === 'month') { + const first: Date = new Date(this.showYear, this.showMonth - 2, 1); + const last: Date = new Date(this.showYear, this.showMonth + 1, 0); + return [this.isoOfDate(first), this.isoOfDate(last)]; + } + if (this.mode === 'week') { + 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)) + : today; + const from: Date = new Date(picked.getFullYear(), picked.getMonth(), picked.getDate() - 14); + const to: Date = new Date(picked.getFullYear(), picked.getMonth(), picked.getDate() + 21); + return [this.isoOfDate(from), this.isoOfDate(to)]; + } + // 列表视图 + const from: Date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - AGENDA_BACK_DAYS); + const to: Date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 90); + return [this.isoOfDate(from), this.isoOfDate(to)]; + } + + /** + * 从本地库读当前范围,立刻渲染。 + * + * ⚠️ 这是"用户点进去就能看到东西"的那一步。 + * 走 `idx_cal_date` 索引,几万条里取三个月是毫秒级。 + * + * @returns 库里有没有数据(false = 首次使用,得等网络) + */ + private async loadFromLocal(): Promise { + const r: [string, string] = this.rangeOfView(); + const rows: FbCalEvent[] = await FbCalStore.inst().between(r[0], r[1]); + if (rows.length === 0) { + // 还要看一眼库里总共有没有数据:没有=首次使用;有=这个月确实空 + const total: number = await FbCalStore.inst().count(); + return total > 0; + } + this.rangeEvents = rows; + this.evtVer++; + this.loading = false; + return true; + } + + /** + * 加载日程。 + * + * 🔴 2026-11-03 重构(用户指出架构错误): + * 「数据量这么大,不是应该同步完放进数据库,点开就把今天的查出来吗?」 + * + * 现在的顺序: + * ① **先读本地库** → 立刻渲染(毫秒级)。老用户进页面几乎无等待。 + * ② 后台拉接口全量 → 入库 → 重查当前范围(界面跟上最新数据)。 + * + * 首次使用(库里空):①没东西,等②;此时和原来一样慢一次,此后都快。 + */ private async load(): Promise { + // ---- ① 本地优先:有数据就先画出来 ---- + const hasLocal: boolean = await this.loadFromLocal(); + if (hasLocal) { + this.loading = false; + } + + // ---- ② 后台同步 ---- 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; + const stamp: number = Math.floor(Date.now() / 1000); + await loadModule(cacheKey, parseCalendar, (rows: FbCalEvent[]): void => { - this.allEvents = rows; - // 数据换了 ⇒ 派生索引必须重建 - this.evtVer++; - this.loading = false; + /* 入库是异步的、可能比较慢(2.8 万条要分批写), + 不能让它挡住界面 —— 先重查当前范围(读的是刚写进去的数据)。 */ + this.persistAndRefresh(rows, stamp); }, (note: string): void => { this.syncNote = note; this.loading = false; }, range, - /* 全量响应约 9.3MB,默认 15 秒在移动网络下会超时; - 超时后会退回旧缓存,表现成"翻月没数据"。给到 60 秒。 */ CAL_FULL_TIMEOUT_MS); } + /** + * 把拉回来的全量数据写库,然后重查当前范围。 + * + * ⚠️ 刻意**不 await 入库**再刷新界面: + * 入库 2.8 万条要几百毫秒到数秒,等它会让界面白等。 + * 但也不能"入库没完就查"(查到一半的数据)—— + * 所以顺序是:入库完成 → 查 → 更新 rangeEvents。 + */ + private persistAndRefresh(rows: FbCalEvent[], stamp: number): void { + FbCalStore.inst().replaceAll(rows, stamp) + .then(async (): Promise => { + const r: [string, string] = this.rangeOfView(); + const fresh: FbCalEvent[] = await FbCalStore.inst().between(r[0], r[1]); + this.rangeEvents = fresh; + this.evtVer++; + this.loading = false; + hilog.info(0x0000, 'FbCalendarPage', + '本地库已更新:全量 %{public}d 条,当前范围 %{public}d 条', + rows.length, fresh.length); + }) + .catch((err: Error): void => { + hilog.error(0x0000, 'FbCalendarPage', '入库失败:%{public}s', JSON.stringify(err)); + this.loading = false; + }); + } + + /** + * 翻月 / 切视图时按需重查。 + * + * 🔴 这是"只查需要的"的关键:翻到 8 月就只查 8 月附近那一段, + * 不碰 9 月之后的数据。 + */ + private async reloadRange(): Promise { + const seq: number = ++this.rangeSeq; + const r: [string, string] = this.rangeOfView(); + const rows: FbCalEvent[] = await FbCalStore.inst().between(r[0], r[1]); + // 期间又翻页了 ⇒ 这次结果已过期,丢掉(否则旧数据会盖掉新数据) + if (seq !== this.rangeSeq) { + return; + } + this.rangeEvents = rows; + this.evtVer++; + } + + /** + * 翻月 / 切视图 → 按需重查当前范围。 + * + * 🔴 2026-11-03:这是"只查需要的"的关键。翻到 8 月就只查 8 月附近, + * 不碰 9 月之后的数据(数据在 SQLite,`WHERE date BETWEEN` 走索引)。 + * + * ⚠️ 用 `@Monitor` **一处声明**覆盖所有翻月路径: + * 页面里有 5 处会改 `showMonth`(周条滑动跨月、月历翻页、 + * 卡片跳转日期…),逐个手写必然漏,漏一处就是"翻到这个月没数据"。 + */ + @Monitor('showYear', 'showMonth', 'mode') + onRangeKeyChanged(): void { + this.reloadRange(); + } + // ============================================================ // 日期 / 筛选 // ============================================================ @@ -517,7 +668,7 @@ export struct FbCalendarPage { const fest: Map = new Map(); const lunar: Map = new Map(); - for (const e of this.allEvents) { + for (const e of this.rangeEvents) { // ---- 农历/节日索引 ---- if (e.kind === 'traditional' || e.kind === 'solarterm' || e.kind === 'holiday') { if (!fest.has(e.date)) { @@ -579,10 +730,10 @@ export struct FbCalendarPage { */ private visibleEvents(): FbCalEvent[] { if (this.books.length === 0) { - return this.allEvents; + return this.rangeEvents; } const out: FbCalEvent[] = []; - for (const e of this.allEvents) { + for (const e of this.rangeEvents) { // calId=0 的衍生条目(农历/纪念日)不受"本"控制,照常显示 if (e.calId === 0 || FB_CAL_SETTINGS.isVisible(e.calId)) { out.push(e);