八条需求的落地与真因: 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 个模块全报"抽屉里没找到"(像应用坏了,其实是探针过期)。 - 资源/笔记两页写死了早已不存在的条目(户口本/房产证/阳台改造清单)⇒ 改成 结构性文案(副标题 + 常驻筛选胶囊),不随用户数据变化而恒红。
1147 lines
42 KiB
Plaintext
1147 lines
42 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, 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;
|
||
}
|
||
} |