Files
FamilyBook_harmonyos/entry/src/main/ets/pages/home/FbHomePage.ets
T
laoyang 48a0507455 v73:桌面卡片刷新修复 + 首页日程/任务就地弹详情卡 + 一键导航
用户 2026-10-21 三条:
  「卡片里面的数据并不刷新……应用里面的日程已经有了,但卡片上的日程却并不刷新」
  「首页上的那个日程和任务,点击之后都应该像日程和任务里面的卡片一样弹出来,
    不要进入到日程里面……有地址的还可以直接一键导航」
  「任务也是一样,点击任务之后弹出来详情,可以标记它是不是完成了」

① 卡片不刷新(根因)
   卡片进程(FormExtensionAbility)与应用进程靠 preferences 传 formIds / 翻页偏移。
   SDK 明文禁止跨进程用 preferences,且 getPreferences() 在**进程内**缓存实例、
   同名再取不读盘 ⇒ 应用进程可能永久持有空注册表 ⇒ pushAll() 首行
   `if (list.length === 0) return` 直接返回 ⇒ 卡片只靠系统 30 分钟自刷。
   修:store() 每次先 removePreferencesFromCacheSync 再取;并写死单写者纪律
   (register/unregister/setOffset/clearOffset 只由卡片进程调,应用进程只读)。

② 首页点日程 → 弹 FbEventSheet(canEdit: false,首页不给编辑入口)
   有地址的行整行可点 + 右侧「➤ 导航」胶囊;「编辑日程」才跳日历模块
   (编辑是日历页的职责,不在首页复制第二份表单)。

③ 首页点任务 → 新的 FbTaskDetailSheet
   状态圆可点(勾完成)、标题带删除线、底部「标记为已完成/未完成」+「改一改」。
   状态**必须走 POST /tasks/<id>/status**(PATCH 会忽略 status)。

④ 一键导航 FbNav.ets:startAbility 隐式 Want(不是 openLink,那是 App Linking);
   canOpenLink 需 module.json5 声明 querySchemes(未声明恒 false 且不抛错);
   高德/百度/花瓣/腾讯按序探测,一个地图都没装则复制地点到剪贴板 + Toast。

⑤ 首页登记 FB_BACK(与其余 10 个模块一致),根容器 Stack(浮层是兄弟节点,
   不能当 Column 子项排,否则卡片被挤出屏幕)。

静态守卫 905/0、v73 反向验证 17/0、assembleHap 通过、已装机启动。
2026-10-21 20:01:37 +08:00

1186 lines
52 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/home.php`。版面:
* ① 个人信息卡(问候 + 姓名 + 日期/星期/农历 + 日子标记 + 天气 + 近七天数字)
* ② 今天的日程(🔴 **直接用日程页那块面板组件** —— 用户 2026-10-16)
* ③ 近期任务(进行中 + 待办,进行中在前)
* ④ 往年的今天(用户 2026-10-17 要求加,与电脑端同一个逻辑)
*
* 🔴🔴 v54(用户 2026-10-16)—— 第一轮修的四件事:
* 1. 「概述块**太高了**……其实是因为我们调整之后,把家书那条调到上面去了,
* 所以概述块变得特别高。」
* 根因:`.padding({ bottom: FB_DOCK_RESERVE })`(**96vp** 的底部悬浮栏预留)
* 被接在了「天气地点 + 日子标记」那个小 Flex 上(缩进看着像它的修饰)——
* 它本该在**页面根部**(给最后一屏留白)。
* ⇒ 概述卡中间凭空多出 96vp 的空白,卡片被撑得老高。
* **修法:挪回页面根部的 Column。**
* 2. 「里面的数据好像有些不符,比如**寒露、重阳节、儿子的生日**,
* 这些都是**乱写的**,要用**真实数据**填写一下。」
* 根因:农历 / 日子标记 / 天气 / 周统计这四样接口**从来没给过**
* (网页版是在 index.php 里算好直接渲染的)⇒ 端上拿 `DemoData` 演示常量顶着。
* ⇒ 服务端 `/home` 已补齐(见 `ApiHandler::home()`),这里全部改接真实数据。
* 3. 「**今天的日程**……把今天那个块拿到首页上来就行了。」
* ⇒ 复用 `FbDayEventPanel`(日程页月/周/列表三处用的同一个组件),
* 于是时间轴/列表显示方式、冲突并排、当前时间红线在首页**与日程页完全一致**。
* 4. 「**我的家人、未来7天,还有全部模块**,这几个就是去掉了,不要了。」
* ⇒ 三个区块连同它们的演示常量一起删掉。
*
* 🔴🔴 v54b(用户 2026-10-17)—— 第二轮:
* 5. 「概述卡……**日程、在做、待办的数量是错误的**。」
* 真机实测:接口与端上缓存都是 `{1,3,2,0}`(正确),界面却是 `0/0/0`。
* 根因:`FbStatTriple` 内部 `@Builder cell(label, value, alarm)` 是**多值参数**
* ⇒ SDK 规则「两个及以上参数不触发刷新」⇒ 永远停在初始的 0。
* ⇒ 抽 `FbStatCell` **组件**(@Param 参与观察)—— 本项目第 6 次同一类修法。
* 6. 「近期任务的下面,应该加上**往年的今天**,跟电脑端一样。」
* ⇒ 服务端把 `home_memories()` 搬成 `Home::memories()`(两端同一份实现),
* `/home` 给 `memories` 字段,这里加第 ④ 块。
*/
import { FbColors, FbMetric, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbCalEvent, FbHomeData, FbMark, FbMemoryItem, FbRecentDay, FbTaskItem,
FbWeather, isEditableEvent } from '../../model/Types';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { parseHome } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FB_CAL_SETTINGS, FbCalStyle } from '../../data/FbCalSettings';
import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { setTaskStatus } from '../../data/FbTaskRepo';
import { FB_CARD_FORMS } from '../../data/FbCardForms';
import { fbApi, FbApiResult } from '../../data/FbHttp';
import { FbSectionTitle } from '../../components/Widgets';
import { FbStatCell } from '../../components/FbStatCell';
import { FbDayEventPanel } from '../../components/FbDayEventPanel';
import { FbEventSheet } from '../../components/FbEventSheet';
import { FbTaskDetailSheet } from '../../components/FbTaskDetailSheet';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { FB_BACK } from '../../common/FbBack';
import { fbOpenNavigation } from '../../common/FbNav';
import { fbModuleOf, FbModuleDef, fbThemeVars } from '../../model/Modules';
@ComponentV2
export struct FbHomePage {
/**
* 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。
* 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。
*/
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
// ---------------- 数据(全部来自 `/api/v1/home`)----------------
/** 今天的日程(服务端已排好:全天在前、其余按时间) */
@Local todayEvents: FbCalEvent[] = [];
/** 近期任务(进行中在前、待办在后) */
@Local tasks: FbTaskItem[] = [];
/** 首页问候语 */
@Local greeting: string = '';
/** 抬头日期(`10 月 16 日`) */
@Local todayLabel: string = '';
/** 星期几(`星期五`) */
@Local weekday: string = '';
/** 农历短写(`八月廿二`) */
@Local lunarShort: string = '';
/** 日子标记(节气/节日/纪念日/生日) */
@Local marks: FbMark[] = [];
/** 天气(null = 没配地点 ⇒ 整块不画) */
@Local weather: FbWeather | null = null;
/**
* 概述卡的四个数字。
*
* 🔴 v54b:**拆成四个独立的 `@Local number`**,而不是一个 `@Local stat: FbStat`
* 对象。理由(本次真机 bug 的教训):数字是最可靠被观察的类型;
* 用对象时,刷新链路要经过"对象整体替换 → 子组件 @Param 收到新值"两道,
* 任何一道出问题都表现为"界面永远停在初始的 0"。
* ⚠️ 拆开之后,四个格子各自只依赖一个 number,链路最短。
*/
@Local statToday: number = 0;
@Local statDoing: number = 0;
@Local statTodo: number = 0;
@Local statOverdue: number = 0;
/** 往年的今天(`/home` 的 memories) */
@Local memories: FbMemoryItem[] = [];
/** 🔴 v59:最近三个纪念日(`/home` 的 recent_days,服务端已排好序) */
@Local recentDays: FbRecentDay[] = [];
/** 顶部提示(同步失败等) */
@Local syncNote: string = '';
/**
* 日程显示方式(时间轴 / 列表)—— 与日程页**读同一个设置**。
* ⚠️ 首页显示"今天的日程",用户在哪一处把显示方式调了,另一处也该跟着变
* (它们本来就是同一份数据的不同入口)。
*/
@Local calStyle: FbCalStyle = 'timeline';
// ============================================================
// v73(用户 2026-10-21):首页点日程 / 点任务 → **就地弹详情卡**
// ============================================================
/* 🔴 用户原话:
「应用里面啊,就是首页上的那个日程和这个任务,点击之后都应该像那个日程和任务
里面的卡片一样弹出来,不要进入到日程里面。比如说一个日程,我在首页上点击之后
就能弹出和日程里面的卡片是一样的,看它里面的详情啊。呃,如果是有这个地址的,
还可以直接一键导航。任务也是一样,点击任务之后弹出来详情,可以标记可以标记
它是不是完成了什么之类的。」
⇒ 下面这八组状态就是这件事的全部:日程详情卡 / 任务详情卡 / 各自的开关。
⚠️ **不要**改回"点一下 `switchModule('calendar')`"—— 那是用户明确否掉的旧行为
(原来 `onPick` 只有一行 `this.app.switchModule('calendar')`,
点哪一条都跳到日程页,用户还得自己再找一遍那条)。 */
/** 摘要卡显示的那条日程(列表那份;非 null = 日程详情卡开着) */
@Local evtBase: FbCalEvent | null = null;
/** 日程详情(`/events/<id>` 的完整字段;null = 还在拉 / 拉不到) */
@Local evtFull: FbCalEventFull | null = null;
/** 日程详情加载中 */
@Local evtLoading: boolean = false;
/** 日程详情加载失败说明(空 = 没失败) */
@Local evtError: string = '';
/** 丢弃在途请求的令牌(连点两条日程时,先回来的那条不该覆盖后点的) */
@Local evtToken: number = 0;
/** 摘要卡显示的那条任务(非 null = 任务详情卡开着) */
@Local tskBase: FbTaskItem | null = null;
/** 任务卡上显示的当前状态(勾完**立刻**变,不等网络) */
@Local tskStatus: string = 'todo';
/** 任务状态正在提交(防连点) */
@Local tskBusy: boolean = false;
/** 任务卡的提示(失败原因 / 成功说明) */
@Local tskNote: string = '';
/**
* 详情卡开着 ⇒ 底部悬浮快捷栏让位。
*
* ⚠️ 与任务页/日程页同一套机制(`sheetCount` 计数):悬浮栏在**更外层
* `Index` 的 Stack** 里,层级比本页高 ⇒ 不让位就会盖住卡片底部的按钮、
* 还把点击抢走(名册/日程页都踩过)。
*/
@Monitor('evtBase', 'tskBase')
onDetailChanged(): void {
this.app.sheetCount = (this.evtBase !== null || this.tskBase !== null) ? 1 : 0;
}
aboutToAppear(): void {
// 显示方式来自日程设置(启动时已预载进内存,可同步读)
this.calStyle = FB_CAL_SETTINGS.getStyle();
/* 🔴 v73:**首页也要登记返回手势**。
原来首页根本没登记过 —— 因为以前它没有浮层,"返回"就该由 `Index` 处理
(回上一个模块 / 退到桌面)。现在多了两张详情卡,不登记的话:
卡开着按返回 ⇒ **卡片留在屏幕上**、模块却被切走了,回来还是那张卡。
⚠️ 登记/注销必须成对(见 `common/FbBack.ets` 的纪律)。 */
FB_BACK.register('home', (): boolean => this.backOnce());
this.load();
}
aboutToDisappear(): void {
FB_BACK.unregister('home');
}
/** 返回手势:先关详情卡,再交给 `Index`(返回 true = 已消费) */
private backOnce(): boolean {
if (this.evtBase !== null) {
this.closeEvent();
return true;
}
if (this.tskBase !== null) {
this.closeTask();
return true;
}
return false;
}
/**
* 拉首页数据。
*
* ⚠️ 首页**只要一个接口**了(v54)—— 原来还要并发拉 `/calendar`(未来七天)
* 和 `/tasks`(任务),因为那两块数据接口没给。
* 现在服务端 `/home` 把它们都备齐了,少两个请求就是首页更快一截。
*/
private async load(): Promise<void> {
await loadModule<FbHomeData>('home', parseHome,
(d: FbHomeData): void => {
this.todayEvents = d.todayEvents;
this.tasks = d.tasks;
this.greeting = d.greeting;
this.todayLabel = d.todayLabel;
this.weekday = d.weekday;
this.lunarShort = d.lunarShort;
this.marks = d.marks;
this.weather = d.weather;
this.statToday = d.stat.todayEvents;
this.statDoing = d.stat.doing;
this.statTodo = d.stat.todo;
this.statOverdue = d.stat.overdue;
this.memories = d.memories;
this.recentDays = d.recentDays;
/* 🔴🔴 v59b(用户 2026-10-18):「提醒按钮,明明没提醒,怎么还显示个 2?」
*
* 真根因:`app.unread` **从来没接过数据** —— 它只是 `FbAppState` 里的
* 一个写死的初值 `@Trace unread: number = 2;`,而全工程**只有一处**
* 给它赋值(通知页的「全部已读」把它改成 0)。
* ⇒ 于是"从没进过通知页"的用户**永远看到 2**;进了通知页又变成
* "永远是 0"(把真实未读也盖掉了)。两个方向都错。
*
* `/home` 本来就返回权威的 `unread`(服务端 `Notification::unreadCount`,
* 与通知页同一个来源)—— 这里把它同步进全局状态,铃铛角标就真了。
*
* ⚠️ 首页是启动后必到的页面(默认模块),所以这一个赋值点就够
* "进应用看到真数字";进通知页后由那边继续维护(标记已读会变)。
*/
this.app.unread = d.unread;
},
(note: string): void => {
this.syncNote = note;
});
}
// ============================================================
// v73:日程详情卡
// ============================================================
/**
* 点一条日程 → 弹详情卡(**不跳模块**)。
*
* 逻辑与日程页的 `openEvent` 保持同一套(那是最成熟的一份,别另写一套):
* ① 先用列表那条垫一份"壳"(标题/时间/地点/备注够画,用户立刻看到内容);
* ② 可编辑条目再拉 `/events/<id>` 补 `rrule` / `reminder` / 精确 `ends_at`;
* ③ 拉不到就保留壳 + 一行小字说明(**不要**把整张卡变成"读不到")。
*
* ⚠️ 衍生条目(农历/节气/节假日)**没有 `/events/<id>` 可拉**
* (服务端 `ref_id` 是 0,它们不是 events 表里的行)⇒ 不要发那个请求。
*/
private openEvent(e: FbCalEvent): void {
this.evtBase = e;
this.evtFull = null;
this.evtError = '';
if (!isEditableEvent(e)) {
this.evtLoading = false;
this.evtFull = this.shellOf(e);
return;
}
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.evtLoading = false;
this.evtFull = this.shellOf(e);
return;
}
this.evtLoading = true;
this.evtToken += 1;
const my: number = this.evtToken;
FbCalRepo.inst().fetchEvent(e.id, token).then((full: FbCalEventFull | null): void => {
if (my !== this.evtToken) {
return; // 已经有更新的一次点击了,这份丢弃
}
this.evtLoading = false;
if (full === null) {
this.evtError = '读不到详情';
this.evtFull = this.shellOf(e);
return;
}
this.evtFull = full;
});
}
/** 列表那条 → 完整字段的壳(只垫列表**已知**的字段,别编) */
private shellOf(b: FbCalEvent): FbCalEventFull {
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
};
return shell;
}
private closeEvent(): void {
this.evtBase = null;
this.evtFull = null;
this.evtError = '';
this.evtLoading = false;
this.evtToken += 1; // 丢弃在途请求
}
/** 详情卡上点「导航」/ 点地点行 → 拉起地图 */
private navigateEvent(): void {
const placeOfFull: string = this.evtFull !== null ? this.evtFull.location : '';
const placeOfBase: string = this.evtBase !== null ? this.evtBase.place : '';
const place: string = placeOfFull !== '' ? placeOfFull : placeOfBase;
const title: string = this.evtFull !== null ? this.evtFull.title
: (this.evtBase !== null ? this.evtBase.title : '');
/* ⚠️ 必须是 `UIAbilityContext`(`startAbility` 只在这个上下文上有;
用 `common.Context` 编译就报 "Property 'startAbility' does not exist",
名册拨号那次实测过)。 */
const ctx: common.UIAbilityContext | undefined =
this.getUIContext().getHostContext() as common.UIAbilityContext | undefined;
fbOpenNavigation(ctx, place, title);
}
/**
* 日程详情卡上点「编辑」→ 切到日程模块。
*
* ⚠️ 这里**仍然要跳模块**,而且是唯一该跳的地方:编辑是日程页的职责
* (它有日历本选择、重复规则、提醒、地点联想那一整套表单)。
* 在首页再复制一份编辑表单 = 同一件事两个实现,必然漂移。
* 用户否掉的是"**看点详情就跳走**",不是"编辑也不许跳"。
*/
private editEventFromDetail(): void {
this.closeEvent();
this.app.switchModule('calendar');
}
// ============================================================
// v73:任务详情卡
// ============================================================
private openTask(t: FbTaskItem): void {
this.tskBase = t;
this.tskStatus = t.status;
this.tskBusy = false;
this.tskNote = '';
}
private closeTask(): void {
this.tskBase = null;
this.tskBusy = false;
this.tskNote = '';
}
/**
* 勾完成 / 取消完成。
*
* 🔴🔴 **必须走 `/tasks/<id>/status`**(`setTaskStatus`)——
* 服务端 `PATCH /tasks/<id>` 会把请求里的 status **原样忽略**
* (见 `FbTaskRepo` 文件头),打 PATCH 会"看起来成功、状态没变"。
* ⚠️ 这是本项目已经踩过一次的坑,别改成 updateTask。
*
* ⚠️ 已完成 → 取消完成统一回 `todo`(**不回 `doing`**):
* 用户只想撤销一次误勾,把它标成"进行中"是替他做了他没表达的决定。
* (与任务页 `toggleDone` 同一套规则。)
*/
private async toggleTask(): Promise<void> {
const t: FbTaskItem | null = this.tskBase;
if (t === null || this.tskBusy) {
return;
}
this.tskBusy = true;
this.tskNote = '';
const next: string = this.tskStatus === 'done' ? 'todo' : 'done';
const res: FbApiResult = await setTaskStatus(FB_TOKEN_STORE.get(), t.id, next);
this.tskBusy = false;
if (!res.ok) {
this.tskNote = res.error !== '' ? res.error : '改状态失败,请稍后再试。';
return;
}
/* 本地先把状态改掉(卡上立刻有反馈),再重拉首页数据对齐派生字段
(逾期、看板计数、"近期任务"里这条还在不在,都是服务端算的)。
⚠️ 顺序不能反 —— 等网络回来再改,用户会以为"没点上"又点一次。 */
this.tskStatus = next;
this.tskNote = next === 'done' ? '已标记完成' : '已改回未完成';
this.load();
// 桌面卡片上的数字(今天日程/在做/待办)也可能受影响 —— 顺手推一次
this.pushCards();
}
/**
* 任务卡上点「改一改」→ 切到任务模块。
*
* ⚠️ 与日程同理:改标题/改截止/看板/负责人是一整套表单,属于任务页的职责。
* 用户要的"弹详情、能勾完成"这里已经给了;"改内容"跳过去是对的。
*/
private editTaskFromDetail(): void {
this.closeTask();
this.app.switchModule('tasks');
}
/**
* 把最新数据推给桌面上所有日程卡片。
*
* ⚠️ 不 await、失败只记日志:这是"顺手做的一件好事",绝不能影响勾选动作。
*/
private pushCards(): void {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
FB_CARD_FORMS.pushAll(ctx).catch((err: BusinessError): void => {
hilog.warn(0x0000, 'FbHome', '刷新桌面卡片失败:%{public}s', err.message);
});
}
/** 日程模块主题色(详情卡环色 / 编辑按钮色) */
private calAccent(): string {
const m: FbModuleDef | undefined = fbModuleOf('calendar');
return m !== undefined ? fbThemeVars(m.color).brand : '#A84A28';
}
/** 任务模块主题色 */
private taskAccent(): string {
const m: FbModuleDef | undefined = fbModuleOf('tasks');
return m !== undefined ? fbThemeVars(m.color).brand : '#A8574F';
}
/** 问候语时段(与 Web 端 home.php 里那段 PHP 同口径) */
private hello(): string {
const h: number = new Date().getHours();
if (h < 5) {
return '夜深了';
}
if (h < 11) {
return '早上好';
}
if (h < 13) {
return '中午好';
}
if (h < 18) {
return '下午好';
}
return '晚上好';
}
/** 日期行:`10 月 16 日 · 星期五`(服务端给的字段;没有就本地算兜底) */
private dateLine(): string {
if (this.todayLabel !== '') {
return this.weekday !== '' ? (this.todayLabel + ' · ' + this.weekday) : this.todayLabel;
}
const d: Date = new Date();
const w: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return (d.getMonth() + 1).toString() + ' 月 ' + d.getDate().toString() + ' 日 · ' + w[d.getDay()];
}
/** 天气图标颜色(按服务端给的 tone 分档,与网页版 `.hb-weather.is-*` 一一对应) */
private weatherTint(): string {
const w: FbWeather | null = this.weather;
const tone: string = w !== null ? w.tone : 'cloud';
if (tone === 'sunny') {
return '#E8A33D';
}
if (tone === 'rain') {
return '#3B7FBF';
}
if (tone === 'snow') {
return '#5AA6B8';
}
if (tone === 'storm') {
return '#7A5BB5';
}
if (tone === 'fog') {
return '#8C8C8C';
}
return '#7C8AA0';
}
/**
* 日子标记的配色(与网页版 `.hb-mark.is-*` 同一套,抄自 `assets/css/app.css`)。
*
* ⚠️ 用**具体的背景/前景两色**而不是 `FbTag` 的 tone —— 网页版那五档
* (holiday 红 / term 绿 / festival 橙 / anniv 紫 / birthday 粉)
* 比 FbTag 现有的四档更细,硬套会丢掉"生日和纪念日不是一回事"这个区分。
*/
private markBg(kind: string): string {
if (kind === 'holiday') {
return '#FDECEC';
}
if (kind === 'term') {
return '#EDF4E9';
}
if (kind === 'festival') {
return '#FDF2E3';
}
if (kind === 'birthday') {
return '#FDECF3';
}
return '#F2ECF7'; // anniv 及其它
}
private markFg(kind: string): string {
if (kind === 'holiday') {
return '#A8232B';
}
if (kind === 'term') {
return '#3F6B34';
}
if (kind === 'festival') {
return '#96551A';
}
if (kind === 'birthday') {
return '#A03060';
}
return '#6B4A86'; // anniv 及其它
}
/** 当前时刻(当天第几分钟)—— 时间轴画"当前时间红线"用 */
private nowMinutes(): number {
const d: Date = new Date();
return d.getHours() * 60 + d.getMinutes();
}
build() {
/* 🔴 v73:外层必须是 **Stack**(叠放),不能是 Column(排列)。
浮层要"盖在页面上",而不是"排在页面后面" —— 后者会把 Scroll 挤出屏幕,
表现是"点了日程详情打不开 / 打开了但整页空了"。
(本项目 2026-10-20 真机事故就是这个:`Column { 整高遮罩; 卡片 }`
把卡片挤出可视区,编译通过、`aboutToAppear` 照常跑、`dumpLayout` 里一个节点都没有。)
⚠️ 这也是为什么**根容器是 Stack 而不是 Scroll**:遮罩与卡片必须是它的兄弟,
不能塞进 Scroll 的 Column 里。 */
Stack({ alignContent: Alignment.TopStart }) {
Scroll() {
Column({ space: 0 }) {
this.topBar()
// ==================== ① 个人信息卡(问候 + 今天 + 天气)====================
// 🔴 用户 2026-10-12 定的:这一块**要白色卡片**("这样显得好看")。
// ⚠️ 曾一度做成"不套卡片的页面头部",理由是省首屏高度;
// 用户看了实机后仍要求套白卡 ⇒ **以用户观感为准**,别再改回去。
// 注意与顶栏的区别:顶栏**不能**有自己的背景色(否则与状态栏割裂),
// 而这张卡在顶栏**下面**、与状态栏不相邻,套白卡没有这个问题。
Column({ space: 10 }) {
// 第一行:问候 + 名字(左) / 温度(右)
Row() {
Column({ space: 5 }) {
Row({ space: 4 }) {
Text(this.hello())
.fontSize(22)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(',' + this.app.session.me.name)
.fontSize(22)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
}
.alignItems(VerticalAlign.Bottom)
// 日期 · 星期 · 农历(一行排开,手机上一行读得完)
Row({ space: 8 }) {
Text(this.dateLine())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
// 🔴 农历:真实值(接口给)。算不出时**不显示**,不编一个
if (this.lunarShort !== '') {
Text(this.lunarShort)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 天气:靠右,温度大字 + 文案小字
// ⚠️ 没配地点 / 取不到天气时**整块不画**(与网页版一致)——
// 画一个"——"占位反而让人以为坏了。
if (this.weather !== null) {
Row({ space: 6 }) {
Text(this.weather!.icon)
.fontSize(22)
.fontColor(this.weatherTint())
Column({ space: 0 }) {
Text(this.weather!.temp)
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.weather!.text)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.alignItems(HorizontalAlign.End)
}
.alignItems(VerticalAlign.Center)
}
}
.width('100%')
.alignItems(VerticalAlign.Top)
// 第二行:天气地点 + 日子标记(节气 / 节日 / 纪念日 / 生日)
// 🔴🔴 这里**绝不能**再出现 `FB_DOCK_RESERVE` 的 padding ——
// 它 96vp,接在这会把这个小方块撑成一大片空白(v54 修的那个"概述块太高")。
Column({ space: 6 }) {
if (this.weather !== null && (this.weather!.place !== '' || this.weather!.wind !== '')) {
Text(this.weatherLine())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.width('100%')
}
// 日子标记:今天是什么节气/节日/纪念日/谁的生日(真实数据)
if (this.marks.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.marks, (mk: FbMark, idx: number) => {
Text(mk.word)
.fontSize(FbMetric.fontTiny)
.fontColor(this.markFg(mk.kind))
.backgroundColor(this.markBg(mk.kind))
.borderRadius(FbMetric.radiusPill)
.padding({ left: 9, right: 9, top: 2, bottom: 2 })
.margin({ right: 6, top: 2 })
}, (mk: FbMark, idx: number): string => 'mk_' + idx.toString() + '_' + mk.word)
}
.width('100%')
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 第三行:四个数字(**与网页版 `hb-stats` 同源**)
// `N 条日程(今天)· N 件在做 · N 件待办 · N 件逾期`
// 🔴 用户 2026-10-16:「下面……**未来7天**……这几个就是去掉了,不要了。」
// ⇒ 原来这里有"未来七天"那一格(today..+7 天的条数),已去掉;
// 换成与网页版一样的口径(网页版概述卡本来也是这四个)。
//
// 🔴🔴 v54b(用户 2026-10-17):「里面的**日程、在做、待办的数量是错误的**」
// 真机实测:接口与端上缓存里都是 `{today_events:1, doing:3, todo:2}`(正确),
// 而界面显示 `0 / 0 / 0` —— 同一份数据里「今天的日程 1 条」却是对的。
// 根因:原来是 `FbStatTriple`,它内部用
// `@Builder cell(label, value, alarm)` —— **多值参数**,
// SDK 规则明写「**两个及以上参数不触发刷新**」
// ⇒ 初始渲染的 0 永远不会被后来的真实值替换。
// (本项目第 6 次踩同一个坑:@Builder 值参数不参与状态观察。)
// ⇒ 抽成 **`FbStatCell` 组件**(@ComponentV2 + @Param,@Param 参与观察)
// —— 与 `FbEventChip` / `FbCalOptionRow` / `FbTagPickChip` 同一套修法。
// ⚠️ 不要改回 `@Builder cell(...)`;也不要用**本页的 @Builder**(同样是按值传递)。
Divider().color(FbColors.line2)
Row() {
FbStatCell({ value: this.statToday, label: '今天日程' })
FbStatCell({ value: this.statDoing, label: '在做' })
FbStatCell({ value: this.statTodo, label: '待办' })
FbStatCell({ value: this.statOverdue, label: '逾期', alarm: true })
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
// 🔴 不要 `.width('100%')` —— 它会先把宽度占满,导致后面的 margin 失效,
// 卡片于是**顶到屏幕两侧边缘**(实测 x=0..1080),而别的区块是留边 16vp 的,
// 页面里就出现"有的满宽、有的有缝"的不统一(用户 2026-10-12 的反馈)。
// 正确做法:**只给 margin**,宽度由 margin 自动撑出(左右各 16vp)。
.padding(FbMetric.cardPad)
// 白色卡片:与下面「今天的日程」等块同一种卡片语言
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({
left: FbMetric.pagePad,
right: FbMetric.pagePad,
top: 8
})
.alignItems(HorizontalAlign.Start)
// ==================== ② 今天的日程 ====================
// 🔴 用户 2026-10-16:「今天的日程,现在显示的是日程里面的展示内容。
// 把**今天那个块**拿到首页上来就行了。」
// ⇒ 直接用日程页那块面板组件(月/周/列表三处用的同一个)——
// 于是显示方式(时间轴/列表)、冲突横向并排、当前时间红线
// 在首页与日程页**完全一致**,不会再出现"两个地方长得不一样"。
// ⚠️ 面板自带卡片样式与外边距,这里**不要再套一层卡片**。
// ⚠️ `showHead: false` —— 本页已有 `FbSectionTitle`,两个头会重复。
// ⚠️ 空态文案也换掉:首页没有"右下角 +"那个按钮。
FbSectionTitle({ title: '今天的日程', extra: this.todayEvents.length.toString() + ' 条' })
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
FbDayEventPanel({
label: this.todayEvents.length > 0 ? '今天' : '',
events: this.todayEvents,
style: this.calStyle,
isToday: true,
nowMin: this.nowMinutes(),
showHead: false,
emptyTitle: '今天没有安排',
emptyHint: '过得轻松一点',
onPick: (e: FbCalEvent): void => {
/* 🔴 v73(用户 2026-10-21):**就地弹详情卡**,不跳模块。
旧实现是 `this.app.switchModule('calendar')` —— 点哪条都跳到日程页,
用户还得自己再找一遍那条(用户明确否掉了这个行为)。 */
this.openEvent(e);
}
})
// ==================== ③ 近期任务 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '近期任务', extra: this.taskExtra() })
Column({ space: 0 }) {
if (this.tasks.length === 0) {
Row() {
Text('没有进行中的任务,也没有待办。')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.tasks, (t: FbTaskItem, idx: number) => {
this.taskRow(t, idx === this.tasks.length - 1)
}, (t: FbTaskItem, idx: number): string => 'htk_' + t.id.toString())
}
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 6 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ④ 最近纪念日 ====================
// 🔴🔴 v59(用户 2026-10-18):
// 「首页应该加一个『最近纪念日』模块,放在『往年的今天』前面、
// 『近期任务』后面。内容包括:
// 1. 未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。
// 2. 以倒计时形式展示:距离哪个纪念日还有多少天,
// 距离谁谁谁的生日还有多少天。」
// ⇒ 位置就卡在这两段之间(**不是**"放在首页某处")。
// ⇒ 数据来自 `/home` 的 `recent_days`(服务端已排好序、最多 3 条),
// 来源是"派生生日 + 自建纪念日",正好覆盖用户说的两样。
Column({ space: 0 }) {
FbSectionTitle({
title: '最近纪念日',
extra: this.recentDays.length > 0
? this.recentDays.length.toString() + ' 个'
: ''
})
Column({ space: 0 }) {
if (this.recentDays.length === 0) {
Column({ space: 6 }) {
Text('还没有要过的日子')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('在「纪念日」里添一个,或者给家人的资料里填上生日。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.recentDays, (d: FbRecentDay, idx: number) => {
this.recentDayRow(d, idx === this.recentDays.length - 1)
}, (d: FbRecentDay, idx: number): string =>
'rd_' + d.date + '_' + d.title + '_' + idx.toString())
}
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 6 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ⑤ 往年的今天 ====================
// 🔴 用户 2026-10-17:「近期任务的下面,应该加上**往年的今天**,
// 跟电脑端一样,你看看那个是什么逻辑。」
// ⇒ 位置**就在近期任务下面**(用户明确指定,不是"放在首页某处")。
// ⚠️ v59 起它上面多了一块「最近纪念日」(用户 2026-10-18 指定插在
// 「近期任务之后、往年的今天之前」)—— 两块都是"日子"类信息,
// 顺序是用户定的,别再调。
// 电脑端的逻辑见 `App\Home::memories()`:只认**往年的同一天**
// (不退回同月),来源是「看过的电影」+「走过的**实际行程**」,
// 合并按时间倒序取 6 条。
// ⚠️ 数据来自 `/home` 的 `memories` —— 与网页版**同一份实现**,
// 不要端上自己再查一遍(会出现"网页 2 年前、手机 3 年前")。
Column({ space: 0 }) {
FbSectionTitle({
title: '往年的今天',
extra: this.todayLabel !== '' ? this.todayLabel : ''
})
Column({ space: 0 }) {
if (this.memories.length === 0) {
// 空态与网页版一字不差(两句:一句说没有,一句给个盼头)
Column({ space: 6 }) {
Text('往年的这一天还没有记录')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('日子还长,等有了第一次,明年这时候就会自己冒出来。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.memories, (m: FbMemoryItem, idx: number) => {
this.memoryRow(m, idx === this.memories.length - 1)
}, (m: FbMemoryItem, idx: number): string => 'mem_' + m.kind + '_' + m.id.toString())
}
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 6 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// 🔴🔴 **底部悬浮快捷栏的预留** —— 它必须留在**页面最根部**。
// v54 之前它被接在了概述卡中间那个小 Flex 上(96vp 凭空撑高卡片),
// 真正的页面底部反而没有留白、被悬浮栏压住最后一屏。
// ⚠️ 别再把它挪到任何中间区块上。
Blank().height(FB_DOCK_RESERVE)
}
.width('100%')
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ==================== 日程详情卡(v73)====================
// ⚠️ 遮罩与卡片是 Stack 的**兄弟**,不是 Column 的子项(见 build 开头那段说明)。
if (this.evtBase !== null) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.closeEvent();
})
FbEventSheet({
event: this.evtFull,
loading: this.evtLoading,
error: this.evtError,
accent: this.calAccent(),
bookName: this.evtBase !== null ? this.evtBase.calName : '',
bookColor: this.evtBase !== null ? this.evtBase.color : '#9AA0A6',
/* 🔴 首页**不给编辑入口**(`canEdit: false` 是有意的):
用户要的是"看详情、能导航",而"编辑"要跳去日程模块 ——
首页这颗按钮点下去会先关掉卡、再切模块,用户会看到"卡闪一下没了、
页面换成了日程",那正是他说"不要进入到日程里面"的那种突兀感。
⇒ 编辑请从日程模块进(那里点开同一条就带「编辑日程」)。
⚠️ 也别把这条改成"跳过去并自动打开这条的编辑"—— 那要传一串参数穿两个模块,
复杂度远大于收益,而且用户没提这个需求。 */
canEdit: false,
onNavigate: (): void => {
this.navigateEvent();
},
onClose: (): void => {
this.closeEvent();
},
onEdit: (): void => {
this.editEventFromDetail();
}
})
.transition(fbSheetTransition())
}
// ==================== 任务详情卡(v73)====================
if (this.tskBase !== null) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.closeTask();
})
FbTaskDetailSheet({
task: this.tskBase,
status: this.tskStatus,
busy: this.tskBusy,
note: this.tskNote,
accent: this.taskAccent(),
onToggle: (): void => {
this.toggleTask();
},
onEdit: (): void => {
this.editTaskFromDetail();
},
onClose: (): void => {
this.closeTask();
}
})
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
.alignContent(Alignment.TopStart)
}
/** 天气地点行:`青岛市 · 崂山区 · 东南风 12 km/h` */
private weatherLine(): string {
const w: FbWeather | null = this.weather;
if (w === null) {
return '';
}
const parts: string[] = [];
if (w.place !== '') {
parts.push(w.place);
}
if (w.tmax !== '' || w.tmin !== '') {
parts.push(w.tmax + ' / ' + w.tmin);
}
if (w.wind !== '') {
parts.push(w.wdir !== '' ? (w.wdir + ' ' + w.wind) : w.wind);
}
return parts.join(' · ');
}
/** 「近期任务」右上角小字:`3 件在做 · 5 件待办`(进行中与待办**分开数**) */
private taskExtra(): string {
let doing: number = 0;
let todo: number = 0;
for (const t of this.tasks) {
if (t.status === 'doing') {
doing++;
} else {
todo++;
}
}
if (todo > 0) {
return doing.toString() + ' 件在做 · ' + todo.toString() + ' 件待办';
}
return doing.toString() + ' 件在做';
}
/**
* 一条任务(首页版)。
*
* 🔴 与网页版首页同一套信息:圆点(看板色,逾期变红)+ 标题 + **状态词**
* (用户第 3 条:「除了显示任务的标题之外,还要显示他的状态」)
* + 子任务进度 + 截止时间。
* ⚠️ 看板色**必须**用真实派生色(`boardColor`)—— 用户 2026-10-08 报过
* 「首页近期任务……颜色并没有跟看板颜色一致」。
*
* 🔴 v73(用户 2026-10-21):「任务也是一样,点击任务之后弹出来详情,
* 可以标记它是不是完成了什么之类的。」
* ⇒ 整行可点,弹 `FbTaskDetailSheet`(**不再跳任务模块**)。
* ⚠️ 这是 `@Builder taskRow(t, isLast)` —— **两个参数**,本项目的著名反模式。
* 它在这里是安全的,理由与 `memoryRow` 完全相同:
* 列表由 `ForEach` 按 `t.id` 重建(数据变化 ⇒ 增删列表项 ⇒ 走重建路径,
* 不依赖 Builder 参数刷新)。⚠️ 如果哪天出现"数据变了、这一行文字没变",
* **先改成组件**(参照 `FbStatCell`),别在这个 Builder 上打补丁。
*/
@Builder
taskRow(t: FbTaskItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
// 圆点:逾期用红(状态语义优先),否则跟看板色
Row()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(t.overdue ? '#C0533F' : t.boardColor)
Text(t.title)
.fontSize(FbMetric.fontSmall)
.fontColor(t.overdue ? '#C0533F' : FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 状态词(待办 / 进行中)—— 用户第 3 条要求
Text(t.status === 'doing' ? '进行中' : '待办')
.fontSize(10)
.fontColor(t.status === 'doing' ? '#A8574F' : FbColors.faint)
.backgroundColor(t.status === 'doing' ? '#14A8574F' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
if (t.childTotal > 0) {
Text(t.childDone.toString() + '/' + t.childTotal.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.dueText.length > 0) {
Text(t.dueText)
.fontSize(FbMetric.fontTiny)
.fontColor(t.overdue ? '#C0533F' : FbColors.faint)
}
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
/* 🔴 v73:整行点开任务详情卡。
⚠️ 热区放在这个 `Row` 上(它含圆点/标题/状态/截止,一行都算),
不是放在 `Column` 上 —— Column 还包着分隔线,点分隔线也会触发,
两者视觉上不是"同一件东西"。
⚠️ 手指反馈:`stateStyles` 用不到(这里不是 Button),
所以靠"点开后有一张卡弹出来"作为反馈 —— 那已经足够强。 */
.onClick((): void => {
this.openTask(t);
})
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 18 })
}
}
.width('100%')
}
/**
* 一条「往年的今天」。
*
* 形态照网页版 `templates/home.php` 的 `.dash-row`:
* 圆点(旅程=绿 #3F7D5A / 观影=紫 #7A4E8C)→「N 年前」→ 🧳/🎬 + 标题 → 日期。
*
* ⚠️ 这里是 `@Builder` 带 **2 个参数** —— 与本次概述卡数字那个 bug 同一个反模式。
* 但两者的**后果不同**:本区块的数据(`this.memories`)是在页面首次加载后
* 随其它字段一起到的,且整块由 `ForEach` 按 `m.id` 重建
* (数据变化 ⇒ 列表项增删 ⇒ 走 ForEach 的重建路径,不依赖 Builder 参数刷新)。
* ⚠️ 若将来出现"memories 变了、这一行文字没变"的现象,**先改成组件**
* (参照 `FbStatCell`),不要在这个 Builder 上打补丁。
*/
@Builder
memoryRow(m: FbMemoryItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
// 圆点:两类的颜色与网页版一致(旅程绿 / 观影紫)
Row()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(m.kind === 'trip_node' ? '#3F7D5A' : '#7A4E8C')
// 「N 年前」
Text(m.yearsAgo.toString() + ' 年前')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(52)
// 🧳 / 🎬 + 标题
Text((m.kind === 'trip_node' ? '🧳 ' : '🎬 ') + m.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 那天的日期(`YYYY-MM-DD`)
Text(this.memoryDate(m.startsAt))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 18 })
}
}
.width('100%')
}
/** `YYYY-MM-DD HH:MM:SS` → `YYYY-MM-DD`(只取日期,与网页版 `substr(...,0,10)` 一致) */
private memoryDate(s: string): string {
return s.length >= 10 ? s.substring(0, 10) : s;
}
/**
* 「最近纪念日」里的一行(v59,用户 2026-10-18)。
*
* 形态:**倒计时徽标**(`还有 12 天`)+ 标题 + 日期/周岁。
* 用户要的就是这个倒计时:「距离哪个纪念日还有多少天,
* 距离谁谁谁的生日还有多少天」—— `title` 里已经带了姓名
* (服务端拼的「老杨 的生日」),所以一句话读完就是用户要的表达。
*
* ⚠️ 与 `memoryRow` 同一个反模式提醒:这里也是**两参数 @Builder**。
* 它安全的前提是"整块由 ForEach 按 key 重建"(数据一次性到齐)——
* 若将来出现"数据变了但行文字没变",**先改成组件**(如 `FbStatCell`),
* 别在这个 Builder 上打补丁。
*/
@Builder
recentDayRow(d: FbRecentDay, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
// ---- 倒计时徽标:把"还剩几天"做成一眼可见的那一块 ----
// 今天那种用实底强调(它是"就是今天",比"还有 N 天"更该被看见)
Text(this.countdownText(d.days))
.fontSize(FbMetric.fontTiny)
.fontWeight(d.days === 0 ? FontWeight.Medium : FontWeight.Normal)
.fontColor(d.days === 0 ? Color.White : this.recentTint(d.source))
.backgroundColor(d.days === 0 ? this.recentTint(d.source) : this.recentWash(d.source))
.borderRadius(FbMetric.radiusPill)
.padding({ left: 9, right: 9, top: 3, bottom: 3 })
.textAlign(TextAlign.Center)
// ---- 标题(已含姓名 / 纪念日名)----
Text(d.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// ---- 右侧:日期(+ 周岁 / 第几个,有才显示)----
Text(this.recentMeta(d))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 18 })
}
}
.width('100%')
}
/**
* 倒计时文案。
*
* ⚠️ `days === 0` 必须说「就是今天」而不是「还有 0 天」——
* 后者读起来像"已经过了",正是最容易搞反的地方。
*/
private countdownText(days: number): string {
if (days <= 0) {
return '就是今天';
}
return '还有 ' + days.toString() + ' 天';
}
/**
* 右侧那行小字:日期 +(周岁 / 第几个)。
*
* · 日期一律显示(`MM-DD`,年份对"年年过"的纪念日没有信息量);
* · `age >= 0` ⇒ 生日,附「N 岁」(0 岁是**真实存在**的,不能当"没有");
* · `nth >= 1` ⇒ 用户填了起始年,附「第 N 个」。
*/
private recentMeta(d: FbRecentDay): string {
const md: string = d.date.length >= 10 ? d.date.substring(5, 10) : d.date;
const parts: string[] = [md];
if (d.age >= 0) {
parts.push(d.age.toString() + ' 岁');
}
if (d.nth >= 1) {
parts.push('第 ' + d.nth.toString() + ' 个');
}
return parts.join(' · ');
}
/** 纪念日的强调色:生日用粉、其余用紫(与网页版 `hb-mark` 的 birthday/anniv 一致) */
private recentTint(source: string): string {
return this.isBirthday(source) ? '#A03060' : '#6B4A86';
}
/** 对应的极浅底(与上面同一档色的淡化) */
private recentWash(source: string): string {
return this.isBirthday(source) ? '#FDECF3' : '#F2ECF7';
}
/** 是不是"生日"那类(服务端派生的生日条目;`custom` 是用户自己填的纪念日) */
private isBirthday(source: string): boolean {
return source === 'birthday_solar' || source === 'birthday_lunar';
}
}