从「演示数据壳子」走到「真的能连家里的服务器用」。
登录(用户 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)。
1070 lines
36 KiB
Plaintext
1070 lines
36 KiB
Plaintext
/**
|
||
* 家书 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;
|
||
}
|
||
} |