Files
FamilyBook_harmonyos/entry/src/main/ets/pages/calendar/FbCalendarPage.ets
T
laoyang 7858de27e3 v0.8.4 简要卡观感 / 分享 / 名册搜索 / 全局返回手势(v59e)
八条需求的落地与真因:

1. 浮层与白底"糊在一起"、看不清 —— 根因不是配色,是**遮罩从来没画出来过**:
   ArkUI 的八位色值是 #AARRGGBB(alpha 在前),而全应用写的是 '#00000066'
   (本意"黑 40%")⇒ alpha = 0x00 ⇒ 全透明。同型还有 brand + '1A'(拼到了蓝通道)
   与 shadow('#00000033')。⇒ 17 个文件统一改走 fbAlpha() / FB_MASK / FB_SHADOW /
   FB_HAIRLINE;真机像素采样复验:遮罩 (148,146,140)、卡片白、浅底 (237,242,243)。
2. 简要卡外圈加**主题色描边环**(外层铺色 + padding,不是 border —— border 会被
   内层圆角切出白缝)。
3. 简要卡**去掉删除按钮**,删除挪到完整详情卡最下面(Divider + 红字块)。
   顺手修一处静默失败:confirmDelete() 原来只取 quickSource(),
   从家谱直接开详情时 quickPerson 为 null ⇒ 点删除毫无反应。
4. 信息行间距 10 → 16(每行再各加 3vp 上下内边距,实测行距 134/118px)。
5. 主电话整行可点**直接拨号**(call.makeCall,落到系统拨号盘)。
6. 按钮改为 详情 / 编辑 / 分享 / 关闭;分享走系统分享面板,
   内容为纯文本「姓名 / 电话 / 邮箱」三项(用户点名的就是这三项,
   不多带单位),面板拉不起来时兜底复制到剪贴板并提示。
7. 名册搜索"完全不能用"有两个独立真因:
   ① 搜索框是 List 的第一个 item,无结果时整个 List 被空态替换 ⇒ 输入框被卸载、
      焦点丢失 ⇒ 输不进第二个字。改:List 无条件渲染,空态自身做成 ListItem。
   ② rowToPerson 把 initials 写死成空串,服务端下发的拼音首字母从未落库 ⇒
      拼音搜索恒 0 结果。改:FB_DB_VERSION 6→7 + PRAGMA 探测补列 + 同步时落库;
      匹配同时遍历全部电话(原来只比主号)。
   另加搜索框「清空」(24×24 命中区)。
8. 全局返回手势:应用是单页 + 模块分发、没有路由栈 ⇒ 自建 FB_BACK 登记表
   (倒序询问)+ moduleHistory + applyModule()(不记历史,否则 A/B 两页来回弹)。
   9 个页面登记/注销;Index.onBackPress 先关浮层 → 回上一模块 → 才 return false
   交给系统退桌面(onBackPress 必须声明 boolean,返回 void 会被当成没接管)。

验证:
- 静态守卫 417 / 0(新增 ⑤阴影·描边·色环 ⑥透明度色 ⑦返回手势 ⑧搜索 ⑨分享)
- 反向验证 10 / 0(9 条注入全部能让守卫变红;其中"遮罩换纯黑"那条暴露了
  一条存在型假绿,已改成数个数 + 禁止 Color.Black)
- 建表/迁移列一致性守卫 12 / 0
- 真机全模块遍历 14 / 14;真机逐项复核 12 / 0 与 7 / 0

工具侧顺带修好两个过期的探针:
- 遍历探针仍用"应用内横滑"打开抽屉,而该手势早已按用户要求去掉、入口改为顶栏图标
  ⇒ 一次全量遍历里 12 个模块全报"抽屉里没找到"(像应用坏了,其实是探针过期)。
- 资源/笔记两页写死了早已不存在的条目(户口本/房产证/阳台改造清单)⇒ 改成
  结构性文案(副标题 + 常驻筛选胶囊),不随用户数据变化而恒红。
2026-10-18 23:12:57 +08:00

1147 lines
42 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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, FbTint, FB_MASK } 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 { 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';
import { FB_BACK } from '../../common/FbBack';
/** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */
/**
* 列表(和月历)要覆盖的日期范围:今天 -7 ~ 今天 +60 天。
*
* 🔴 这两个数**必须**同时用于两处(改一处就得改另一处):
* ① `load()` 里发给服务端的 `from`/`to`(不传的话服务端只给本月 ⇒ 跨月缺数据);
* ② `agendaDays()` 的循环边界(页面实际渲染哪些天)。
* 分开写死就会变成"请求了 A 段、显示 B 段"——那正是本 bug 最容易复发的形态。
*/
const AGENDA_BACK_DAYS: number = 7;
const AGENDA_FWD_DAYS: number = 60;
/** 月历格子(一个日期) */
interface FbCalCell {
/** 日期数字 */
day: number;
/** 该格的完整日期 `YYYY-MM-DD` */
iso: string;
/** 是否属于当月 */
inMonth: boolean;
/** 是否今天 */
isToday: boolean;
/* ⚠️ v59:原来的 `count` / `dotColor` 已删除 —— 它们只服务于"日期下面那排
小圆点",而用户 2026-10-18 明确要求**把那排点全部去掉**。
留着这两个字段会让人以为"哪里还在画点",也会让 `cellOf` 白算一遍。 */
/** 农历/节日的短文字(初一显示月名、节日显示节日名) */
lunar: string;
}
/** 列表视图里的"一天"(日期 + 该日日程) */
interface FbAgendaDay {
iso: string;
/** `10月15日 周三` */
label: string;
events: FbCalEvent[];
}
/** 周视图里的一格 */
interface FbWeekDay {
iso: string;
day: number;
/** 一 二 三 … */
w: string;
isToday: boolean;
/** ⚠️ v59:原来的 `count`(画小点用)已删 —— 用户要求去掉日期下那排点。 */
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;
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 只关**最上面一层**浮层。
*
* 四个浮层:编辑 / 新建 / 设置 / 详情。
* ⚠️ 「详情」放最后问:编辑是从详情里点出去的(此时详情已经关了),
* 所以编辑一定比详情更"上"。
*/
aboutToDisappear(): void {
FB_BACK.unregister('calendar');
}
private backOnce(): boolean {
if (this.editing) {
// 与 `FbEventEditSheet` 的 onCancel 一致
this.editing = false;
this.editEvent = null;
return true;
}
if (this.creating) {
this.creating = false;
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
if (this.detailOpen()) {
this.closeDetail();
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('calendar', (): boolean => this.backOnce());
// 先按设置里的默认视图展示(用户上次选的)
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> {
/* 🔴🔴 v59(用户 2026-10-18):「列表这个视图,今天的数据缺一条。」
真根因:服务端 `/calendar` 的 from/to **默认只给本月**
(`date('Y-m-01')` ~ `date('Y-m-t')`),而列表视图要显示 `-7 ~ +60` 天。
跨月那一段服务端压根没返回 ⇒ 落在下月的日程(含"今天往后几天")
在列表里凭空少掉;月视图因为本来就只看本月,所以看不出来。
⇒ 这里**显式传范围**,覆盖列表要用的那一整段(前后各留够余量)。
⚠️ 不要只传"本月",也不要依赖服务端默认值 —— 列表与月视图用的
`allEvents` 是同一份,范围必须**取两者所需的并集**(否则修了列表
又让月视图翻页时缺数据)。
⚠️ 往前 7 天 / 往后 60 天这两个数与 `agendaDays()` 是**同一套口径**,
改一处必须改另一处(分散成两个魔法数字必然漂移)。 */
const range: string = '/api/v1/calendar?from=' + this.agendaFrom() + '&to=' + this.agendaTo();
await loadModule<FbCalEvent[]>('calendar', parseCalendar,
(rows: FbCalEvent[]): void => {
this.allEvents = rows;
this.loading = false;
},
(note: string): void => {
this.syncNote = note;
this.loading = false;
},
range);
}
/** 列表/月历所需范围的起点(今天 - 7 天,`YYYY-MM-DD`) */
private agendaFrom(): string {
return this.isoOfDate(new Date(new Date().getFullYear(), new Date().getMonth(),
new Date().getDate() - AGENDA_BACK_DAYS));
}
/** 列表/月历所需范围的终点(今天 + 60 天,`YYYY-MM-DD`) */
private agendaTo(): string {
return this.isoOfDate(new Date(new Date().getFullYear(), new Date().getMonth(),
new Date().getDate() + AGENDA_FWD_DAYS));
}
// ============================================================
// 日期 / 筛选
// ============================================================
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());
}
/** `Date` → `YYYY-MM-DD`(服务端 from/to 用的格式) */
private isoOfDate(dt: Date): string {
return this.isoOf(dt.getFullYear(), dt.getMonth() + 1, 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);
}
}
// 其次:农历日名
//
// 🔴🔴 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 '';
}
/** 标题截短(农历/节日的名字有的很长,格子里放不下) */
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);
// ⚠️ v59:这里原来还要遍历一遍 events 去数"有几条"(为画小点用)——
// 那排点已被用户要求删除 ⇒ 这次遍历**整段去掉**(格子现在是 O(1) 的)。
const c: FbCalCell = {
day: d,
iso: iso,
inMonth: inMonth,
isToday: iso === today,
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());
// ⚠️ v59:这里原来查一遍 events 只为数"有几条"(画小点用)——
// 那排点已按用户要求删除 ⇒ 整段去掉。
out.push({
iso: iso, day: dt.getDate(), w: this.weekLabels[i],
isToday: iso === today, lunar: this.lunarOf(iso)
});
}
return out;
}
// ============================================================
// 列表视图
// ============================================================
/** 从今天往后(含过去 7 天)按天分组;空的日子不占位 */
private agendaDays(): FbAgendaDay[] {
const out: FbAgendaDay[] = [];
const now: Date = new Date();
// ⚠️ 边界必须与 `load()` 请求的 from/to 一致(见 AGENDA_BACK_DAYS 的说明)
for (let i = -AGENDA_BACK_DAYS; i <= AGENDA_FWD_DAYS; 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(FB_MASK)
.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(FB_MASK)
.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(FB_MASK)
.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(FB_MASK)
.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: FbTint.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)
/* 🔴 v59:选中 / 今天的配色与月视图**同一套**(选中深底白字、
今天浅底)—— 用户要的是"点哪天哪天明显",两处不一致会让人
以为换视图就丢了选中。判据同样走方法(见 `isPickedCell` 的说明)。 */
Text(d.day.toString())
.fontSize(FbMetric.fontSmall)
.fontWeight(d.iso === this.pickedIso || d.isToday
? FontWeight.Medium : FontWeight.Normal)
.fontColor(d.iso === this.pickedIso
? Color.White
: FbColors.ink)
.width(30)
.height(30)
.textAlign(TextAlign.Center)
.borderRadius(15)
.backgroundColor(d.iso === this.pickedIso
? this.accent
: (d.isToday ? fbPalette(this.accent).wash : Color.Transparent))
/* 🔴 v59(用户 2026-10-18):「日期下面有几个点,合格都去掉。」
⇒ 周视图这一排小圆点也删(同一条要求,别只改月视图)。
⚠️ 留等高占位,避免周条高度变化。 */
Row().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 }) {
/* 🔴🔴 v59(用户 2026-10-18):「现在点击了某一天之后,并不明显,
应该把这一天的颜色变深,而今天用一个较浅的颜色来显示,
你参考 synccalendar 的做法。」
⇒ 照本机范本 SyncCalendar 的 `weekStrip`:
· **选中** → `selected_bg` **深色实底** + 白字(最强调);
· **今天** → `today_bg` **浅色底** + 常规文字色(弱强调)。
原来的写法正好**反了**:今天用实底(`this.accent` + 白字)抢走了
全部注意力,而选中的那天只有一层 12% 的淡色 —— 点下去几乎看不出变化。
⚠️ 两个都免得同时成立(选中的那天可能也是今天):**选中优先**,
否则"我今天点了今天,却看不到选中效果"。
⚠️ 深浅对照是**这套配色的核心**:选中实底、今天浅底 —— 别再改回去。 */
Text(c.day.toString())
.fontSize(FbMetric.fontSmall)
.fontWeight(this.isPickedCell(c) || c.isToday ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.isPickedCell(c)
? Color.White
: (c.inMonth ? FbColors.ink : FbColors.faint))
.width(26)
.height(26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.backgroundColor(this.isPickedCell(c)
? this.accent // 选中:深色实底
: (c.isToday ? fbPalette(this.accent).wash // 今天:浅色底
: Color.Transparent))
// 农历/节日(有才显示)
if (c.lunar !== '') {
Text(c.lunar)
.fontSize(8)
.fontColor(this.isPickedCell(c) && c.inMonth ? FbColors.muted : FbColors.faint)
.maxLines(1)
} else {
// 没有农历就留出等高的空,免得两行格子和一行格子高度不一
Row().height(10)
}
/* 🔴 v59(用户 2026-10-18):「日程,日期下面有几个点,合格都去掉。」
⇒ 那一排小圆点**已删除**(连同 `FbDot` 的引用与 `dots()` 辅助函数
在月视图的调用点)。用户认为它是噪音 —— 日程内容在下面那块
「选中日的日程」里看得更清楚,格子里不需要再暗示"有几条"。
⚠️ 周视图的圆点也一并去掉(同一条要求),别只删月视图。
⚠️ 这里**保留等高占位**:不留的话格子高度会变,7 行月历会整体上移。 */
Row().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);
}
})
}
/**
* 这一格是不是"当前选中的那天"。
*
* ⚠️ 必须是**方法调用**(build 里读 `this.pickedIso`)而不是把结果当参数传进
* `dayCell` —— 后者按值传递,@Builder 的多值参数不参与观察,
* 选中高亮会**永远停在初始那天**(本项目已在 FbTagChip 上踩过同一个坑,
* 见本文件 `dayPanel()` 上那段说明)。
*/
private isPickedCell(c: FbCalCell): boolean {
return c.inMonth && c.iso === this.pickedIso;
}
/**
* ⚠️ 日程条目行(列表模式)已经搬进 `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;
}
}