Files
FamilyBook_harmonyos/entry/src/main/ets/pages/calendar/FbCalendarPage.ets
T
laoyang 776c95ba0b v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据
从「演示数据壳子」走到「真的能连家里的服务器用」。

登录(用户 2026-10-17「完整的登录功能」)
  · 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
  · 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
  · FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
    设备名取机型(marketName),并上报 platform / app_version

设备会话(v57,用户 2026-10-17)
  · 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
  · 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
    (离线超 24h → 只锁界面、不清数据、联网自动恢复)——
    混成一种就会违背用户要的"一直联网就不用每天输密码"
  · 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
    让登录/校验类请求跳过,避免"两个地方同时跳登录页")
  · 退出登录:先清本地令牌(主防线),再尽力通知服务端
  · 设置页显示版本号与「这台设备」

数据层
  · FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
  · 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
  · 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致

验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
2026-10-18 03:36:04 +08:00

1070 lines
36 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 } 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;
}
}