Files
FamilyBook_harmonyos/entry/src/main/ets/pages/home/FbHomePage.ets
T
laoyang a7947f31be 观影/联系人详情卡补内置蒙版 (#78)
用户:电影这个框弹出来的时候,应该在下面盖一层蒙版吧?跟近期任务类似。

首页各浮层里只有这两张卡没有遮罩:FbTaskPanel / FbDayEditSheet 都是组件内部
根 Stack 自带遮罩+卡片,而 FbMovieDetailSheet / FbPersonSheet 两处漏了,
卡片直接浮在正文上,底下的片名、影院、联系人名字透出来。

改法(按项目既有约定:谁铺满全屏,谁负责画遮罩):
- FbMovieDetailSheet.build 根节点 Column -> Stack,前面插全屏 FB_MASK 遮罩,
  onClick 关闭;卡片容器保持铺满+居中且 HitTestMode.Transparent;
- FbPersonSheet.build 同样套一层 Stack + 遮罩(embedded 分支跳过,
  那种形态宿主是 bindSheet,系统已有遮罩,再叠就是双暗);
- 宿主 FbHomePage 不再包遮罩,只留注释说明。

原因:组件根节点自己铺满全屏,会在外面先吃掉点击 —— 宿主包遮罩会
蒙版看着有、点它关不掉;包两层还会越点越黑(本轮真机实测踩到)。

验证(真机 5NC0225B27004718):
- 弹卡后卡片外像素 (247,243,234) -> (147,146,141),反推 alpha=0.40 = 0x66/255;
- 卡片内海报 (230,212,188) 不受影响;
- 点遮罩后恢复 (247,243,234),卡片关闭;
- 联系人卡同样三条全过;hvigorw assembleHap BUILD SUCCESSFUL。
2026-11-06 07:35:47 +08:00

2802 lines
125 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, FbCalBook, FbCatOpt, FbHomeData, FbMark, FbMemoryItem, FbRecentDay,
FbTaskItem, FbBoardOpt, FbTaskDetail, FbTaskMeta, taskKey,
FbWeather, isEditableEvent, isEventWritable } 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, loadPath } 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 { parseTaskMeta } from '../../data/FbParse';
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 { FbEventEditSheet } from '../../components/FbEventEditSheet';
import { FbTaskPanel } from '../../components/FbTaskPanel';
import { FbTaskEditSheet } from '../../components/FbTaskEditSheet';
/* v86:三张卡的新建表单 —— 全是**模块页正在用的同一个组件**,不另写。 */
import { FbDayEditSheet } from '../../components/FbDayEditSheet';
/* #87:派生生日的只读卡(用户:「如果这个纪念日来自生日,我们是不能编辑的」) */
import { FbDayBirthdaySheet } from '../../components/FbDayBirthdaySheet';
import { FbPersonSheet } from '../../components/FbPersonSheet';
import { FbSheetRing } from '../../components/FbSheetRing';
import { FbMovieNewSheet } from '../../components/FbMovieNewSheet';
import { FbPersonEditSheet } from '../../components/FbPersonEditSheet';
import { FbMovieRepo, FbMovieListItem, FbCinemaItem, FbMovieDetail } from '../../data/FbMovieRepo';
import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet';
import { FbPersonRepo, FbRosterPerson } from '../../data/FbPersonRepo';
import { fbImageSrc } from '../../data/FbAttachQueue';
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 { FbDiag } from '../../common/FbDiag';
import { fbModuleOf, FbModuleDef, fbThemeVars } from '../../model/Modules';
/** 日志域(与 `FbBackgroundSync` / `FbNav` 同一个值,端上统一 0x0000) */
const DOMAIN: number = 0x0000;
const TAG: string = 'FbHomePage';
@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';
// ============================================================
// v84(用户 2026-10-23):首页卡片上的「新建 / 新增」按钮
// ============================================================
/* 🔴 用户原话:「日程,没有新建日程按钮,名册,也没有新增成员这个按钮。」
------------------------------------------------------------------
⚠️ 这条**上一轮只做到 Web 端**(`app/HomeCards.php` 的 `TYPES['agenda']['add']`),
端上首页压根没做 —— 所以用户装完机仍然说"没有按钮"。
教训:这类"两端都要"的需求,改完一端必须**立刻去另一端 grep 同一件事**。
⚠️ 为什么首页要**自己拉一次 `/calendar/books`**、不复用日程页那份:
首页和日程页是两个独立组件(首页不持有日程页的 `@Local books`)。
把它塞进 `/home` 接口当然更省,但那要动服务端 + 端上解析 + 网页端,
收益只有"少一个请求",风险却横跨三层。
⇒ 折中:**按需懒加载**(点开新建才拉),且拉到后缓存在 `calBooks` 里,
同一会话内只拉一次。 */
@Local calBooks: FbCalBook[] = [];
/** 首页「新建日程」表单开着 */
@Local calCreating: boolean = false;
/**
* v86:首页「编辑日程」表单开着(从详情卡点「编辑」进来)。
*
* ⚠️ 与 `calCreating` **必须分开**:两者渲染的是同一个 `FbEventEditSheet`,
* 但入参完全不同(`event: null` + `isCreate: true` + `presetDate`
* vs `event: evtFull` + `isCreate: false`)。合成一个布尔再在里面分叉,
* 会让"到底是新建还是编辑"取决于 `evtFull` 是否为 null —— 那是隐式契约,
* 改一处就静默错。分开两个状态位,一眼能看懂,`backOnce` 也好按层关。
*/
@Local calEditing: boolean = false;
/** 正在拉日历本(防连点开两个空表单) */
@Local calLoadingBooks: boolean = false;
// ============================================================
// 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;
/* ============================================================
#62/#63/#64/#65:首页各区块的**只读详情卡**(先看后改)
============================================================
🔴 用户 2026-11-05:
「近期任务、纪念日、最近观影都像日程一样,弹出来一个卡片,
点编辑的时候才进入编辑。」
「最近观影点了之后没反应…点了之后应该有反应,要么弹出来详细信息。」
「首页上的联系人…点击之后弹出来详细信息,就跟名册里面那个人的列表
点击完之后的效果是一样的。」
「往年的今天也是一样,点完其中一个之后…就应该弹出来跟电影有关的弹框卡片。」
⚠️ 这些字段都要计入 `sheetCount`(见 @Monitor)——
否则按返回键会直接退出页面,而不是先关掉详情卡。 */
/** 最近观影的详情(null = 没开) */
@Local mvDetail: FbMovieDetail | null = null;
/** 观影详情正在拉 */
@Local mvDetailLoading: boolean = false;
/** 观影详情拉取失败说明 */
@Local mvDetailError: string = '';
/** 被点的观影条目(列表自带评分/短评数,详情还没到时先用它垫) */
@Local mvSeed: FbMovieListItem | null = null;
/** 联系人详情(null = 没开) */
@Local pfDetail: FbRosterPerson | null = null;
/** 纪念日详情(null = 没开) */
@Local dayDetail: FbRecentDay | null = null;
/** 往年的今天 —— 被点的条目(按 kind 分发卡片) */
@Local memDetail: FbMemoryItem | null = null;
/* ============================================================
#59:概述四格数字 → 锚点跳转
============================================================
🔴 用户 2026-11-05:「点击上面这几个数字的时候,要滑到首页上的
对应位置,也就是锚点处…它是有作用的,不只是一个数字。」
⚠️ ArkUI 没有 Web 的 `#anchor`,用 `Scroller.scrollTo` 量坐标实现。
⚠️ 首页是 `Scroll + Column`(不是 List),没有"项索引"可滚,
所以只能记每个区块的 y 偏移。
⚠️ 用 `@Local` 而不是普通字段:坐标是 `onAreaChange` 进来的,
虽然只在点击时读一次,但保持与其它状态一致,避免有人在
`build` 里读它却拿不到最新值。 */
private homeScroller: Scroller = new Scroller();
/** 「今天的日程」区块在滚动内容里的 y 偏移 */
@Local yEvents: number = 0;
/** 「近期任务」区块的 y 偏移 */
@Local yTasks: number = 0;
/**
* v79:面板里能用的**全部**看板 / 分类(`?view=boards`)。
*
* 🔴 为什么不在页面加载时就拉:首页 `/home` 不返回看板表,
* 而"挪分组/选分类"只有点开任务后才用得上 —— 按需拉更省一次请求。
* ⚠️ 拉的是**全部看板**(含空看板),不是从任务里反推
* (反推会让新建的空看板永远选不到)。
*/
@Local tskBoards: FbBoardOpt[] = [];
@Local tskCats: FbCatOpt[] = [];
/** 任务编辑表单是否打开(v79:在首页直接改,不再跳任务页) */
@Local tskEditOpen: boolean = false;
/** 编辑表单回填的字段 */
@Local tskEditId: number = 0;
@Local tskEditTitle: string = '';
@Local tskEditNote: string = '';
@Local tskEditDueAt: string = '';
@Local tskEditBoardId: number = 0;
@Local tskEditCatId: number = 0;
@Local tskEditRepeat: string = '';
/** 编辑时的原状态(todo/doing/done) */
@Local tskEditStatus: string = 'todo';
/**
* 面板的"重画脉冲"(v79)。
*
* 🔴 为什么需要:编辑表单保存成功后,面板里的标题/截止/分类都变了,
* 但面板的数据是它**自己**拉的 —— 宿主只重拉首页列表,它不会跟着变。
* ⚠️ 别用"关掉再打开"代替:会闪一下、还会丢掉滚动位置。
*/
@Local tskTick: number = 0;
// ============================================================
// v86:纪念日 / 观影 / 联系人 三张卡的新建入口
// ============================================================
/* 🔴 用户 2026-10-24:「端上也全部都实现。」
------------------------------------------------------------------
背景:Web 端 v81/v82 已经把「新增电影 / 新建纪念日 / 新增成员」搬到首页
卡片上了(`app/HomeCards.php` 里的 `add` + `add_modal`),
但**端上一个都没跟**。用户装机后只能进模块里建 ⇒ 与 Web 端体验不一致。
🔴 端上与 Web 端的**一处结构性差异**(决定了这里为什么不能照抄 `add_modal`):
Web 端是"卡片右上角一颗按钮 → 点开一个预先渲染好的隐藏弹框",
弹框 HTML 在页面里早就存在,靠 `data-fb-add-open` 找容器。
端上没有"预先存在的 DOM" —— 表单是**组件**,要在 `if (开着的标志)` 里
现场挂上去。⇒ 端上是"标志位 + Stack 里挂组件",不是"找容器"。
⚠️ 所以这里**没有** `add_modal` 那种 id 必须逐字一致的问题,
但换成了另一个纪律:**每个表单一个独立的布尔,别复用**。
复用(比如三个表单共用一个 `formOpen`)会让"打开哪一个"取决于
一堆隐式状态,而且 `backOnce` 没法按层关。
⚠️ 三张卡的数据源**不是** `/home`:
`/home` 只返回日程/任务/纪念日提醒/往年今天,不含观影列表和名册。
⇒ 观影与联系人走**端上本地库**(`FbMovieRepo` / `FbPersonRepo`,
它们由 `syncAll` 定期同步)。这是端上的既定做法(电影页、名册页
也都是读本地库),不是本轮新增的临时方案。 */
/** 「新建 / 编辑纪念日」表单开着(首页只用新建;editingId=0) */
@Local dayCreating: boolean = false;
/** 纪念日表单要的关联人名单(懒拉,与纪念日页同一个来源) */
@Local dayRoster: FbRosterPerson[] = [];
@Local dayLoadingRoster: boolean = false;
/** 「新增电影(记一场)」表单开着 */
@Local mvCreating: boolean = false;
/** 观影卡要显示的最近几条(本地库) */
@Local mvRecent: FbMovieListItem[] = [];
/**
* 海报加载失败的电影 id。
*
* 🔴 用户 2026-11-05:「首页最近观影没有图片呢,左侧都是占位符。」
* v86 当初为了"省网络请求"刻意用 🎬 色块占位,网页端 `_card_movies.php`
* 一直是真海报 ⇒ 端上补回真图。
*
* ⚠️ 海报是网络图,取不到是常态(没网、令牌失效、图被删)⇒ 必须记下失败,
* 那张卡退回 🎬 色块(与"本来就没海报"同观感),否则会一直挂半张白图。
* ⚠️ 与电影页同一个手法:`@Local` 只代理第一层,**整体换新数组**才刷新。
*/
@Local mvPosterFailIds: number[] = [];
/** 影院列表(`FbMovieNewSheet` 的必填项之一,懒拉) */
@Local mvCinemas: FbCinemaItem[] = [];
@Local mvLoading: boolean = false;
/** 「新增成员」表单开着 */
@Local pfCreating: boolean = false;
/** 联系人卡要显示的人(本地库前 N 个) */
@Local pfRecent: FbRosterPerson[] = [];
/** 名册总人数(卡片底部"共 N 人"用) */
@Local pfTotal: number = 0;
@Local pfLoading: boolean = false;
/**
* 详情卡开着 ⇒ 底部悬浮快捷栏让位。
*
* ⚠️ 与任务页/日程页同一套机制(`sheetCount` 计数):悬浮栏在**更外层
* `Index` 的 Stack** 里,层级比本页高 ⇒ 不让位就会盖住卡片底部的按钮、
* 还把点击抢走(名册/日程页都踩过)。
*
* ⚠️ 新增的表单标志位**必须**加进这个列表:漏一个的后果是
* 表单开着时悬浮栏还浮在上面,把表单底部的「保存」按钮盖住 ——
* 现象是"表单填完了点不到保存"(v79 任务编辑表单踩过同型)。
*/
@Monitor('evtBase', 'tskBase', 'tskEditOpen', 'calCreating', 'calEditing',
'mvDetail', 'pfDetail', 'dayDetail', 'memDetail',
'dayCreating', 'mvCreating', 'pfCreating')
onDetailChanged(): void {
this.app.sheetCount = (this.evtBase !== null || this.tskBase !== null
|| this.mvDetail !== null || this.pfDetail !== null
|| this.dayDetail !== null || this.memDetail !== null
|| this.tskEditOpen || this.calCreating || this.calEditing
|| this.dayCreating || this.mvCreating || this.pfCreating) ? 1 : 0;
}
aboutToAppear(): void {
/* 诊断设施在这里初始化:地址联想发生在编辑弹窗里,
那时用户可能还没点过导航 ⇒ 不能等导航时才 init。 */
FbDiag.init(this.getUIContext().getHostContext());
// 显示方式来自日程设置(启动时已预载进内存,可同步读)
this.calStyle = FB_CAL_SETTINGS.getStyle();
/* 🔴 v73:**首页也要登记返回手势**。
原来首页根本没登记过 —— 因为以前它没有浮层,"返回"就该由 `Index` 处理
(回上一个模块 / 退到桌面)。现在多了两张详情卡,不登记的话:
卡开着按返回 ⇒ **卡片留在屏幕上**、模块却被切走了,回来还是那张卡。
⚠️ 登记/注销必须成对(见 `common/FbBack.ets` 的纪律)。 */
FB_BACK.register('home', (): boolean => this.backOnce());
this.load();
/* v86:观影 / 联系人两张卡走端上本地库,与 `/home` 无关 ⇒ 单独拉、不 await。
⚠️ 必须**不 await**:本地库首次打开或库很大时读一次要几百毫秒,
串在上面会让首页首屏白等(而这两张卡是锦上添花)。 */
this.loadSideCards();
}
aboutToDisappear(): void {
FB_BACK.unregister('home');
}
/** 返回手势:先关详情卡,再交给 `Index`(返回 true = 已消费) */
private backOnce(): boolean {
/* v79:**后打开的先关** —— 编辑表单是压在面板之上的,返回一次只该关它。
顺序反了会"按一次返回,两层一起没了"(用户会以为点错了)。 */
if (this.tskEditOpen) {
this.tskEditOpen = false;
return true;
}
/* v84 新建日程 / v86 编辑日程:都是**独立表单**(不叠在详情卡之上,
但详情卡在它们下面还留着),所以必须排在 `evtBase` 之前判 ——
否则按一次返回会"跳过表单直接关掉详情卡",表单还杵在屏幕上。 */
if (this.calEditing) {
this.calEditing = false;
return true;
}
if (this.calCreating) {
this.calCreating = false;
return true;
}
/* v86:三张卡的新建表单。与上面同理 —— 必须排在详情卡/面板之前,
否则按一次返回会"跳过表单",表单还留在屏幕上。 */
if (this.dayCreating) {
this.dayCreating = false;
return true;
}
if (this.mvCreating) {
this.mvCreating = false;
return true;
}
if (this.pfCreating) {
this.pfCreating = false;
return true;
}
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 => {
/* ⚠️ 同 `loadTaskMeta` 的那条:这是首屏自动加载的后台失败,
不是用户点击的直接后果 ⇒ **故意不走 `note()`**,不弹 Toast。 */
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 = '';
/* 🔴 2026-10-29(工单 #31):详情卡的「编辑日程」按 `calBooks[calId].canWrite` 判定,
而 `calBooks` 原本是懒加载(只在新建/编辑时才拉)⇒ 刚进详情时它还是空数组,
会把**本来能编辑**的日程也误判成只读。
这里顺手拉一次(拉过就复用,`ensureCalBooks` 内部有缓存判断),
不 await —— 详情内容先出来,按钮随 books 到位再显示,不拖慢打开速度。 */
if (this.calBooks.length === 0) {
this.ensureCalBooks().catch((): void => {
/* 拉不到就维持"不给编辑入口",比让用户白改一遍好 */
});
}
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;
});
}
/** 列表那条 → 完整字段的壳(只垫列表**已知**的字段,别编) */
/**
* 把列表条目垫成一份"详情壳"。
*
* 🔴 #69(用户 2026-11-05):「点击行程里旅行日历本里的信息时,弹出来的那个框…
* 还是没有出发地和目的地。」
*
* 为什么这个壳很重要:`openEvent()` 里
* `if (!isEditableEvent(e)) { this.evtFull = this.shellOf(e); return; }`
* —— 行程条目的 `kind` 是 `'trip'` 不是 `'custom'`,
* **永远走这条捷径、压根不请求详情接口**(而且也没有意义:
* 行程节点的数据在 `trip_nodes` 表里,`Calendar::event($id)` 查的是
* `calendar_events`,对节点 id 必然查不到)。
*
* ⇒ 位置信息**只能**靠这个壳带过去。原来只塞了 `location: b.place`,
* 而行程节点的 `place` 是空串(它的位置概念是"从哪到哪"),
* 于是详情卡里一行位置信息都没有。
*/
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,
/* #69:行程条目的「从哪到哪」—— 服务端不给 location,
不给这几个字段的话详情卡里就没有任何位置信息 */
fromPlace: b.fromPlace, toPlace: b.toPlace,
fromLng: b.fromLng, fromLat: b.fromLat, toLng: b.toLng, toLat: b.toLat
};
return shell;
}
private closeEvent(): void {
this.evtBase = null;
this.evtFull = null;
this.evtError = '';
this.evtLoading = false;
this.evtToken += 1; // 丢弃在途请求
}
/** 详情卡上点「导航」/ 点地点行 → 拉起地图 */
/**
* 去导航(#69:改成接受 `navTarget`)。
*
* 原来是无参的 —— 只读 `evtFull.location`。行程条目没有 location,
* 且详情卡里现在有「出发地」「目的地」两行,得知道点的是哪一行。
* ⇒ 详情卡把那一行的地名(带坐标时是 `lat,lng?地名`)直接交进来。
*/
private navigateEvent(navTarget: string): void {
const placeOfFull: string = this.evtFull !== null ? this.evtFull.location : '';
const placeOfBase: string = this.evtBase !== null ? this.evtBase.place : '';
/* 优先用详情卡传来的目标;它为空(老路径 / 极少数情况)才退回原来那套推导 */
const place: string = navTarget !== ''
? navTarget
: (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` 现在是 async(内部要 await 地理编码),
不接住 rejection 会报未处理异常。这里明确咽掉并提示 ——
导航失败不该把页面弄崩。 */
FbDiag.init(ctx);
fbOpenNavigation(ctx, place, title).catch((err: Error): void => {
hilog.warn(0x0000, 'FbNav', '导航调用异常:%{public}s', JSON.stringify(err));
});
}
/**
* 日程详情卡上点「编辑」→ **就地**打开编辑表单(v86)。
*
* 🔴 历史(别走回头路):
* v73 把"点日程跳模块"改成"就地弹详情卡",用户原话是
* 「不要进入到日程里面」。
* v84 我却把详情卡上的「编辑」做成了 `closeEvent() + switchModule('calendar')`
* —— 于是点「编辑」还是会跳走,只是跳得晚一点。用户这轮说「端上也全部都实现」,
* 指的就是这类"看起来做了、其实还是老行为"的地方。
* ⇒ 现在与任务的 `openTaskEdit` 对齐:卡片不关,直接在上面叠一张编辑表单。
*
* ⚠️ 详情卡**不关**(`evtBase` 保留):关掉再开表单会让用户觉得"卡片没了",
* 而且保存后还要靠它在原位重拉一次详情。表单叠在它上面,
* 关闭表单就回到详情卡 —— 与任务面板 → 编辑表单的层级关系一致。
*/
private editEventFromDetail(): void {
this.openEventEdit();
}
// ============================================================
// v79:任务面板(v73 的"详情卡"升级成可就地处理的面板)
// ============================================================
private openTask(t: FbTaskItem): void {
this.tskBase = t;
/* 看板/分类表按需拉一次(拉过就不再拉;它是"元数据",几条而己) */
this.loadTaskMeta();
}
private closeTask(): void {
this.tskBase = null;
this.tskEditOpen = false;
}
// ============================================================
// #62/#63/#64/#65:各区块的详情卡开/关
// ============================================================
/**
* 打开电影详情(#63 / #65 共用)。
*
* 🔴 用户:「"最近观影"点了之后没反应…点了之后应该有反应,
* 要么弹出来详细信息。」
*
* ⚠️ 两段式:先立即用「列表里已有的评分/短评数」把卡片显示出来
* (`loading=true`),再异步拉完整详情。
* 不许等详情回来才开卡 —— 网络慢时用户会觉得"点了没反应",
* 那正是这次要修的问题。
*/
private async openMovieDetail(id: number, seed: FbMovieListItem | null): Promise<void> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.mvDetailError = '未连接服务器,详情需要联网查看';
return;
}
this.mvSeed = seed;
this.mvDetail = null;
this.mvDetailError = '';
/* 先开卡片(带加载态),再拉数据 —— 网络慢时用户不能"点了没反应" */
this.mvDetailLoading = true;
/* ⚠️ 照 `FbMoviesPage.openDetail` 的成熟写法:
用 `FbMovieRepo.inst().detail(id, token, seed)`,它内部处理了
缓存/降级/字段兜底;不要自己拼 `/api/v1/movies/<id>` 再解析
(`parseMovieDetail` 不导出,且 repo 里已经算好了一堆派生字段)。 */
/* ⚠️ seed 可能是 null,而 detail() 要 FbMovieListItem | undefined
⇒ null 转 undefined */
const d: FbMovieDetail = await FbMovieRepo.inst().detail(id, token,
seed !== null ? seed : undefined);
/* 用户可能在等待期间关掉了卡片 ⇒ 回来时先确认还开着 */
if (this.mvDetail === null && !this.mvDetailLoading && this.mvSeed === null) {
return; // 已关闭
}
this.mvDetailLoading = false;
if (!d.ok) {
this.mvDetailError = d.error;
return;
}
this.mvDetail = d;
}
private closeMovieDetail(): void {
this.mvDetail = null;
this.mvDetailLoading = false;
this.mvDetailError = '';
this.mvSeed = null;
}
/** 打开人员详情(#64)。与名册点人的效果一致。 */
private openPersonDetail(p: FbRosterPerson): void {
this.pfDetail = p;
}
private closePersonDetail(): void {
this.pfDetail = null;
}
/**
* 这条纪念日是不是「派生生日」(不能编辑的那种)。
*
* 判据用 `source` 前缀而不是 `id === 0`:
* · 派生生日:`birthday_solar` / `birthday_lunar`,服务端保证 `id = 0`;
* · 自建纪念日:`custom`,`id > 0`。
* 两个判据目前等价,但 `source` 是**语义**、`id` 是**实现细节** ——
* 万一以后派生条目也带 id(比如就想让它能点进某处),用 id 判会静默错。
*/
private isBirthdayDay(): boolean {
const d: FbRecentDay | null = this.dayDetail;
if (d === null) {
return false;
}
return d.source === 'birthday_solar' || d.source === 'birthday_lunar';
}
/** 打开纪念日详情(#63)。 */
/**
* 打开纪念日详情(#63)。
*
* ⚠️ #91c:**必须先加载名册** —— 自建纪念日的编辑框要显示「关联人」
* (头像 + 名字)。原来这里不加载,roster 恒空 ⇒ 关联人区域空白。
* ⚠️ 派生(生日)走只读卡,不需要名册 —— 但这里统一加载也无害
* (一次调用,有缓存)。
*/
private openDayDetail(d: FbRecentDay): void {
this.dayDetail = d;
/* 不 await:先让卡片弹出来(用户点击要即时反馈),
名册回来后 `@Param` 变化会自动重画关联人 chips。 */
this.ensureDayRoster();
}
private closeDayDetail(): void {
this.dayDetail = null;
}
/**
* 打开「往年的今天」条目(#65)—— **按来源分发**。
*
* 🔴 用户:「点完其中一个之后,要么弹出来详细信息,
* 比方说它来自电影,就应该弹出来跟电影有关的弹框卡片。」
*
* ⚠️ `FbMemoryItem.kind` 只有 `movie` / `trip_node`:
* · `movie` → 走 `openMovieDetail`(id 就是 events.id)
* · `trip_node` → 行程卡需要 tripId + nodeId,而 memory 只有 nodeId
* ⇒ **拿不到 tripId 就没法开行程卡**,
* 退回通用摘要卡(`memDetail` 自己渲染)。
* 弹错类型的卡比不弹更糟,所以这里宁可保守。
*/
private openMemoryDetail(m: FbMemoryItem): void {
if (m.kind === 'movie') {
/* 电影:直接开电影详情卡(id 是 events.id,接口认这个) */
this.openMovieDetail(m.id, null);
return;
}
/* 其它(trip_node 等):通用摘要卡 */
this.memDetail = m;
}
private closeMemoryDetail(): void {
this.memDetail = null;
}
/**
* 滚到指定 y(#59)。
*
* ⚠️ 减去 `topBar` 的高度(约 88)会在某些机型上滚过头 ——
* 这里直接用区块自己的 y,顶到可视区顶部即可。
* 若实机发现区块标题被顶栏遮住,再减一个固定偏置。
*/
private scrollToY(y: number): void {
if (y <= 0) {
return;
}
this.homeScroller.scrollTo({ xOffset: 0, yOffset: y, animation: { duration: 280 } });
}
/** 概述格「今天日程」→ 今天的日程区块 */
private jumpToEvents(): void {
this.scrollToY(this.yEvents);
}
/** 概述格「在做 / 任务 / 逾期」→ 近期任务区块(三者都是任务状态计数) */
private jumpToTasks(): void {
this.scrollToY(this.yTasks);
}
/**
* 「往年的今天」的**通用摘要卡**(#65)。
*
* 🔴 用户:「点完其中一个之后,要么弹出来详细信息,
* 比方说它来自电影,就应该弹出来跟电影有关的弹框卡片。
* 然后我滑下去之后可以看信息,然后可以关闭它。」
*
* ⚠️ 电影类走 `openMovieDetail`(弹真正的电影卡),**不到这里**。
* 这里只处理 `trip_node` 这类拿不到 tripId 的来源 ——
* 行程卡需要 tripId + nodeId,而 memory 只有 nodeId,
* 硬开只会弹个空卡。宁可给一份诚实的摘要。
* ⚠️ 内容可滚动(用户说"我滑下去之后可以看信息")。
*/
@Builder
memorySheet(m: FbMemoryItem) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick((): void => {
this.closeMemoryDetail();
})
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.calAccent() }) {
Column({ space: 12 }) {
/* 标题栏 */
Row() {
Text('往年今天')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Bold)
.fontColor(this.calAccent())
Blank()
Text('✕')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.muted)
.padding(8)
.onClick((): void => {
this.closeMemoryDetail();
})
}
.width('100%')
Divider().color(FbColors.line2)
/* 内容(可滚动) */
Scroll() {
Column({ space: 10 }) {
Text(m.title)
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.width('100%')
Text(m.startsAt)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.width('100%')
Text(m.yearsAgo.toString() + ' 年前的今天')
.fontSize(FbMetric.fontSmall)
.fontColor(this.calAccent())
.width('100%')
/* 说明:为什么这里不是完整的行程卡 */
if (m.kind === 'trip_node') {
Text('这条来自旅程。要看完整的行程详情,请到「旅程」里打开对应旅程。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
}
}
.width('100%')
.padding({ top: 4, bottom: 8 })
}
.constraintSize({ maxHeight: 420 })
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
.width('100%')
.padding({ left: 18, right: 18, top: 18, bottom: 32 })
.alignItems(HorizontalAlign.Start)
}
}
.width('90%')
.constraintSize({ maxHeight: '80%' })
.margin({ top: 8, bottom: 8 })
.onClick((): void => {
/* 吃掉点击,避免穿透到底下的遮罩把自己关了 */
})
}
/** 拉全部看板 + 分类(面板的"挪分组"、表单的"分类"都要用) */
private async loadTaskMeta(): Promise<void> {
/* 🔴 #84(用户 2026-11-05):「鸿蒙端的分类那个地方没带进来。
明明是有分类的,但是它却显示的是未分类。」
**就是这个提前返回**:只看 `tskBoards` 有没有值。
而这个方法一旦先跑过一次、看板填上了,以后再进来就直接返回 ——
`tskCats`(分类)**永远停在上一次的赋值上**;如果第一次调用时
`m.cats` 恰好是空(或被 #83 之前的版本错过了),就永远补不上。
⇒ 改成"两样都有值才跳过"。任何一个为空就再拉一次
(接口很轻:`?view=boards` 不带任务列表)。
⚠️ 不能只判 `tskCats.length`:新家庭可能真的一条分类都没建,
那样每次都重拉。两条一起判才是"数据齐了"。 */
if (this.tskBoards.length > 0 && this.tskCats.length > 0) {
return;
}
await loadPath<FbTaskMeta>('tasks_meta', '/api/v1/tasks?view=boards', parseTaskMeta,
(m: FbTaskMeta): void => {
this.tskBoards = m.boards;
this.tskCats = m.cats;
},
(note: string): void => {
/* ⚠️ 失败**不拦路**:面板里状态/子任务/留言都还能用,
只是"换看板"那一排会少几个(甚至只剩默认看板)。 */
/* ⚠️ 这里**故意不走 `note()`**:它是首屏自动加载的后台失败,
不是用户点击的直接后果。启动就弹一个 Toast 会让人莫名其妙,
而且这条失败不影响主流程(面板本身能用)。 */
this.syncNote = note;
});
}
/** 面板里点「编辑」→ 在首页就地打开编辑表单(v79,不再跳任务页) */
private openTaskEdit(d: FbTaskDetail): void {
this.tskEditId = d.base.id;
this.tskEditTitle = d.base.title;
this.tskEditNote = d.base.note;
this.tskEditDueAt = d.base.dueAt;
this.tskEditBoardId = d.base.boardId;
this.tskEditCatId = d.categoryId;
this.tskEditRepeat = d.repeat;
this.tskEditStatus = d.base.status !== '' ? d.base.status : 'todo';
this.tskEditOpen = true;
}
/**
* v86:「近期任务」区块点「+ 新建」→ 就地打开新建任务表单。
*
* 🔴 用户第一轮原话里就有这条(「网页端,首页……在全部任务旁边,放一个新建任务的功能」),
* Web 端 v78 做了、端上一直缺 ⇒ 本轮补齐。
*
* ⚠️ 与 `openTaskEdit` 的区别**只在回填字段**:新建必须把 7 个字段**全部清零**。
* 不清零的话,上一次编辑过的标题/看板/分类会漏进新建表单里 ——
* 表现为"新建任务时标题已经填好了上次那条的名字",
* 用户很难理解,而且会真的把它建出来(脏数据)。
* ⇒ 清零逻辑集中在这一个方法里,别在别处再开一条通路。
* ⚠️ 先拉看板/分类(`loadTaskMeta`):新建表单里"属于哪个看板 / 什么分类"是选择项,
* 没有元数据那两排就是空的。拉取失败**不拦路**(照常开表单,用户可以先写标题)。
*/
private async openTaskCreate(): Promise<void> {
if (this.tskEditOpen) {
return;
}
this.tskEditId = 0;
this.tskEditTitle = '';
this.tskEditNote = '';
this.tskEditDueAt = '';
this.tskEditBoardId = 0;
this.tskEditCatId = 0;
this.tskEditRepeat = '';
this.tskEditStatus = 'todo';
await this.loadTaskMeta();
this.tskEditOpen = true;
}
// ============================================================
// v86:纪念日 / 观影 / 联系人 —— 卡片上的「新建」
// ============================================================
/**
* 「最近纪念日」区块点「+ 新建」→ 就地打开纪念日表单。
*
* ⚠️ 必须先把 `roster` 拉齐:纪念日表单里"关联谁"是多选,
* 名单空着那一栏就没人可选(但**不拦路** —— 不关联人也能建纪念日)。
* ⚠️ 拉的是**端上本地库**(`FbPersonRepo.list()`),与纪念日页同一个来源;
* 本地库由 `syncAll` 定期同步,所以第一次装机时可能为空 ——
* 那时仍然要能建(纪念日本身不依赖人)。
*/
/**
* 确保名册已加载(纪念日「关联人」要用)。
*
* 🔴🔴 #91c(用户 2026-11-05):「刚才关联人还有1人呢,现在关联人怎么没了?
* 是个空的。」
*
* **真根因**:这段加载原来**写在 `openDayCreate()` 里面** ——
* 也就是说只有"新建纪念日"会去拉名册;
* **点一条已有纪念日(`openDayDetail`)走的是另一条路,从不加载** ⇒
* `dayRoster` 恒为空数组 ⇒ 编辑框里按 id 查不到人 ⇒ 关联人区域一片空白。
*
* ⚠️ 数据没丢(`personIds` 一直是好的),只是**画不出来** ——
* 所以更吓人:看着像关联关系被清空了。
* ⚠️ 抽成独立方法,两个入口都调;`dayLoadingRoster` 防并发重复拉。
* ⚠️ 失败只记日志、不打断:纪念日本身不依赖人(名册为空也能建/能改)。
*/
private async ensureDayRoster(): Promise<void> {
if (this.dayRoster.length > 0 || this.dayLoadingRoster) {
return;
}
this.dayLoadingRoster = true;
try {
this.dayRoster = await FbPersonRepo.inst().list();
} catch (err) {
hilog.warn(DOMAIN, TAG, '首页拉名册失败(纪念日关联人):%{public}s', JSON.stringify(err));
this.dayRoster = [];
} finally {
this.dayLoadingRoster = false;
}
}
private async openDayCreate(): Promise<void> {
if (this.dayCreating) {
return;
}
await this.ensureDayRoster();
this.dayCreating = true;
}
/**
* 「观影」区块点「+ 新增」→ 就地打开「记一场」表单。
*
* ⚠️ `FbMovieNewSheet` 需要 `cinemas`(影院是**必选**的,没有影院建不了)。
* 影院从本地库读 —— 本地库里一条都没有时(刚装机、还没同步过),
* 表单里那一排是空的、存不上。这时**明说一句**别让用户白填:
* 「先去观影模块把影院同步一次」。
* ⚠️ 与日程的 `ensureCalBooks()` 同一个道理:**没有必选项就别开空表单**,
* 否则现象是"填完了点保存,什么都没发生"。
*/
private async openMovieCreate(): Promise<void> {
if (this.mvCreating || this.mvLoading) {
return;
}
this.mvLoading = true;
try {
if (this.mvCinemas.length === 0) {
this.mvCinemas = await FbMovieRepo.inst().cinemaList();
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '首页拉影院失败:%{public}s', JSON.stringify(err));
} finally {
this.mvLoading = false;
}
if (this.mvCinemas.length === 0) {
this.note('还没有影院可选,先去观影模块里添加一家影院');
return;
}
this.mvCreating = true;
}
/**
* 「联系人」区块点「+ 新增」→ 就地打开「新增成员」表单。
*
* ⚠️ `knownTags` 传的是**名册里已有的全部标签**(新建时可以点选复用),
* 不传的话用户只能手打,很容易打出"同事"和"同 事"两个标签。
* ⚠️ 名册可能是空的(第一次用)—— 这不拦路,新建第一个人正是这时候要用的。
*/
private openPersonCreate(): void {
if (this.pfCreating) {
return;
}
this.pfCreating = true;
}
/**
* 拉观影卡 / 联系人卡要展示的数据(都走端上本地库)。
*
* ⚠️ 为什么**不 await 在 load() 里串起来**:这两张卡是"锦上添花",
* 拉不到就画空态,绝不该拖慢首页首屏(`/home` 那条才是主路径)。
* ⇒ 独立方法、`catch` 吃掉异常、只写日志。
* ⚠️ 只取**很少几条**:卡片上放 4~5 条就够,多了首页会变成长列表,
* 而"看全部"本来就是进模块的事(Web 端也是这个口径:`moviesData()` 取 8 条)。
*/
private async loadSideCards(): Promise<void> {
this.mvLoading = true;
this.pfLoading = true;
try {
const mv: FbMovieListItem[] = await FbMovieRepo.inst().list(5);
this.mvRecent = mv;
} catch (err) {
hilog.warn(DOMAIN, TAG, '首页读观影列表失败:%{public}s', JSON.stringify(err));
this.mvRecent = [];
} finally {
this.mvLoading = false;
}
try {
const people: FbRosterPerson[] = await FbPersonRepo.inst().list();
this.pfTotal = people.length;
/* 卡片上只露前 6 个:与 `HomeCards` 的 `contacts` 默认 `n => 8` 同口径,
手机上 6 个(3 列 × 2 行)刚好一屏内不压其他卡片。 */
this.pfRecent = people.slice(0, 6);
} catch (err) {
hilog.warn(DOMAIN, TAG, '首页读名册失败:%{public}s', JSON.stringify(err));
this.pfRecent = [];
this.pfTotal = 0;
} finally {
this.pfLoading = false;
}
}
/**
* 给用户说一句话(首页的"为什么点了没反应"出口)。
*
* 🔴🔴 2026-10-24 真机事故(本轮造成的,也是本轮修掉的):
* `syncNote` 这个 `@Local` 在本页**只有赋值、没有任何渲染点**——
* `grep this.syncNote` 全文件 5 处,全是 `=`,没有一处 `Text(this.syncNote)`。
* 后果:`ensureCalBooks()` 判"这个账号还没有可写的日程本"时只是**静默 return**,
* 用户点「+ 新建」看到的现象就是**什么都没发生**(按钮确实在、点了也确实进了函数)。
* 这是本项目最难查的一类失败:所有"按钮存在"类断言全绿,功能却用不了。
*
* 对照:账本页 `FbLedgerPage` 里有 `if (this.syncNote !== '') { Text(this.syncNote) }`,
* 所以那边从来不出这个问题 —— 抄过来的时候**把渲染那一半丢了**。
*
* ⇒ 现在统一走这里:赋值 + **当场弹 Toast**。
* ⚠️ 为什么用 Toast 而不是页面里加一行 `Text`:本页是长滚动页,
* 用户常常是在页面中部/底部点按钮的,页面顶部那行提示**根本不在视口里**
* (pitfalls 343 就是这个:错误提示渲染在滚动区里 = 用户看不到)。
* Toast 浮在最上层,与滚动位置无关。
* ⚠️ `syncNote` 仍然保留赋值:它是**可断言的落点**(守卫与真机探针都靠它),
* 而且将来若要在页内显示历史提示,值还在。
*/
private note(msg: string): void {
this.syncNote = msg;
try {
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 2600 });
} catch (err) {
hilog.warn(DOMAIN, TAG, '首页提示弹不出来:%{public}s', JSON.stringify(err));
}
}
/**
* 确保日历本已加载(v84 新建 / v86 编辑**共用**)。
*
* 🔴 为什么抽出来:v84 只有「新建」需要日程本,v86 加了「就地编辑」之后
* 编辑表单同样要 `books`(不然"日程本"那一栏是空的、保存时静默丢失归属)。
* 两处各写一遍懒加载 = 迟早一处改了另一处没改(本项目反复出现的形态)。
*
* @returns true = 有可写的本,可以开表单;false = 已经给过提示,调用方直接 return
*/
private async ensureCalBooks(): Promise<boolean> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.note('还没登录,无法操作日程');
return false;
}
if (this.calBooks.length === 0) {
this.calLoadingBooks = true;
try {
const list: FbCalBook[] = await FbCalRepo.inst().fetchBooks(token);
this.calBooks = list;
} catch (err) {
hilog.warn(DOMAIN, TAG, '首页拉日历本失败:%{public}s', JSON.stringify(err));
} finally {
this.calLoadingBooks = false;
}
}
if (this.calBooks.length === 0) {
/* ⚠️ 不要"打不开就算了":用户点了一下什么都没发生,是最难查的失败。
新建表单的"日程本"是必选项,没有可写本就画不出表单 ⇒ **明说一句**。 */
this.note('这个账号还没有可写的日程本,先去日程页设置里建一本');
return false;
}
return true;
}
/**
* v84:首页「今天的日程」区块点「+ 新建」→ **就地**打开新建表单。
*
* ⚠️ `presetDate` 用"今天":首页这条区块就叫「今天的日程」,
* 用户在这里点新建,十有八九是给今天加一件。
* ⚠️ 拉本期间用 `calLoadingBooks` 挡住连点(否则会开出两个表单)。
*/
private async openCalCreate(): Promise<void> {
if (this.calCreating || this.calEditing || this.calLoadingBooks) {
return;
}
if (!(await this.ensureCalBooks())) {
return;
}
this.calCreating = true;
}
/**
* v86:日程**详情卡**上点「编辑」→ 就地打开编辑表单。
*
* 🔴 用户原话(本轮):「端上也全部都实现。」上一轮我把它做成了"跳日程模块",
* 理由是"编辑是日程页的职责"。但那正是用户在 v73 明确否掉的行为
* (「点击之后都应该像那个日程里面的卡片一样弹出来,不要进入到日程里面」)。
* ⇒ 现在改成**就地编辑**:`FbEventEditSheet` 本来就是同一个组件
* (日程页用的也是它),首页复用它**不是"再写一份"**,没有漂移风险。
* ⚠️ 这与 v79 把任务的"跳编辑"改成"就地编辑"是同一个决定,理由也一样。
* ⚠️ 不能直接复用 `calCreating`:新建时 `event` 传 null、`isCreate: true`,
* `presetDate` 也要用;编辑时传 `evtFull`、`isCreate: false`。
* 两个状态位分开,语义才不糊(也才关得掉 —— `backOnce` 要按层判断)。
*/
private async openEventEdit(): Promise<void> {
if (this.evtFull === null || this.calEditing || this.calCreating) {
return;
}
if (!(await this.ensureCalBooks())) {
return;
}
this.calEditing = true;
}
/**
* 把最新数据推给桌面上所有日程卡片。
*
* ⚠️ 不 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';
}
/**
* v86:另外三个模块的主题色。
*
* ⚠️ 三个方法长得一样却**不合成一个 `accentOf(key)`**:
* ArkUI 的 `@Builder`/方法里传字符串键很容易写错(`'movie'` vs `'movies'`),
* 而写错的后果是"表单标题色不对"—— 一个不会崩、只会有点怪的问题,
* 极难被发现。三个具体方法名让拼错在编译期就报错。
* ⚠️ 这与 `FbTaskEditSheet` 等组件只收 `accent: string` 的约定一致。
*/
private daysAccent(): string {
const m: FbModuleDef | undefined = fbModuleOf('days');
return m !== undefined ? fbThemeVars(m.color).brand : '#B4633C';
}
private moviesAccent(): string {
const m: FbModuleDef | undefined = fbModuleOf('movies');
return m !== undefined ? fbThemeVars(m.color).brand : '#6B7FA8';
}
private peopleAccent(): string {
const m: FbModuleDef | undefined = fbModuleOf('people');
return m !== undefined ? fbThemeVars(m.color).brand : '#4E7A63';
}
/**
* 名册里出现过的全部标签(新建成员时供点选)。
*
* ⚠️ 从**已加载的** `pfRecent` 推不出来 —— 它只有 6 个人。
* 这里直接调仓储的标签云(`tagCloud()`,本地库已算好并缓存)。
* ⚠️ 是**同步返回**:`tagCloud()` 是异步的,但 `@Param knownTags` 要立刻给值。
* ⇒ 传空数组也能用(用户可以手打标签),所以这里不做异步等待 ——
* 为了一个"可选的便利项"让表单打开晚 100ms 不值得。
*/
private personKnownTags(): string[] {
const out: string[] = [];
for (let i = 0; i < this.pfRecent.length; i++) {
const tags: string[] = this.pfRecent[i].tags;
for (let j = 0; j < tags.length; j++) {
if (out.indexOf(tags[j]) < 0) {
out.push(tags[j]);
}
}
}
return out;
}
/** 只重读观影 / 联系人两张卡的本地库数据(新建成功后调,不动 `/home`) */
private reloadSideCards(): void {
this.loadSideCards();
}
/** 问候语时段(与 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();
}
/**
* 今天 → `YYYY-MM-DD`(`FbEventEditSheet` 的 `presetDate` 要这个格式)。
*
* ⚠️ **不要**用 `toISOString().slice(0,10)`:那是 **UTC**,
* 东八区早上 8 点前会算成"昨天"(本项目真机上踩过同类:新建的日程
* 跑到前一天去了,用户完全无法理解)。必须用本地年月日拼。
* ⚠️ 月份、日期都要补零,否则 `2026-1-5` 服务端解析不了。
*/
private todayIso(): string {
const d: Date = new Date();
const m: number = d.getMonth() + 1;
const day: number = d.getDate();
return d.getFullYear().toString() + '-'
+ (m < 10 ? '0' + m.toString() : m.toString()) + '-'
+ (day < 10 ? '0' + day.toString() : day.toString());
}
build() {
/* 🔴 v73:外层必须是 **Stack**(叠放),不能是 Column(排列)。
浮层要"盖在页面上",而不是"排在页面后面" —— 后者会把 Scroll 挤出屏幕,
表现是"点了日程详情打不开 / 打开了但整页空了"。
(本项目 2026-10-20 真机事故就是这个:`Column { 整高遮罩; 卡片 }`
把卡片挤出可视区,编译通过、`aboutToAppear` 照常跑、`dumpLayout` 里一个节点都没有。)
⚠️ 这也是为什么**根容器是 Stack 而不是 Scroll**:遮罩与卡片必须是它的兄弟,
不能塞进 Scroll 的 Column 里。 */
Stack({ alignContent: Alignment.TopStart }) {
/* #59:绑 Scroller —— 概述四格数字点击后要滚到对应区块 */
Scroll(this.homeScroller) {
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() {
/* #59:四格都可点,滚到对应区块。
⚠️ 「在做 / 任务 / 逾期」都指向「近期任务」——
它们都是任务的不同状态计数,点哪个都是想去看任务。
让"逾期"单独跳一个不存在的"逾期列表"反而是骗人的。 */
FbStatCell({
value: this.statToday,
label: '今天日程',
onTap: (): void => {
this.jumpToEvents();
}
})
FbStatCell({
value: this.statDoing,
label: '在做',
onTap: (): void => {
this.jumpToTasks();
}
})
FbStatCell({
value: this.statTodo,
label: '任务',
onTap: (): void => {
this.jumpToTasks();
}
})
FbStatCell({
value: this.statOverdue,
label: '逾期',
alarm: true,
onTap: (): void => {
this.jumpToTasks();
}
})
}
.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: '今天的日程',
accent: this.calAccent(),
extra: this.todayEvents.length.toString() + ' 条',
/* 🔴 v84(用户 2026-10-23):「日程,没有新建日程按钮」。
⚠️ 别再删掉这颗按钮 —— 它是本条的**唯一可见证据**,
删了用户立刻又说"没有按钮"(上一轮就漏在端上)。 */
addText: '新建',
onAdd: (): void => {
this.openCalCreate();
}
})
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
/* #59:记录「今天的日程」区块的 y 偏移(概述格「今天日程」点击要滚到这里) */
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);
}
})
/* #59:记录 y 偏移 —— 概述格「今天日程」点击要滚到这里。
⚠️ onAreaChange 在首次布局和每次内容变化时都触发,
所以坐标持续有效(任务数变化会让下面的区块位移)。 */
.onAreaChange((_o: Area, n: Area): void => {
this.yEvents = Number(n.position.y);
})
// ==================== ③ 近期任务 ====================
Column({ space: 0 }) {
FbSectionTitle({
title: '近期任务',
accent: this.taskAccent(),
extra: this.taskExtra(),
/* 🔴 v86(用户「端上也全部都实现」):与 Web 端 v78 对齐,卡片上直接新建。
⚠️ 别再删掉这颗按钮 —— 与日程那颗同理,它是本条需求的**唯一可见证据**。 */
addText: '新建',
onAdd: (): void => {
this.openTaskCreate();
}
})
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)
/* 🔴 v79:键必须带状态等**会被画到行上的字段**(`taskKey` 的注释有完整推导)。
只用 id 的后果:面板里改成已完成 → 重拉 → 对象换了但键没变
⇒ ArkUI 复用旧节点 ⇒ 首页这张卡还是"待办"。 */
}, (t: FbTaskItem, idx: number): string => taskKey(t))
}
}
.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 })
/* #59:记录「近期任务」区块的 y —— 概述格「在做/任务/逾期」点击滚到这里。
⚠️ 挂在区块最外层 Column 上(含标题+内容),滚到的是整块的开头。 */
.onAreaChange((_o: Area, n: Area): void => {
this.yTasks = Number(n.position.y);
})
// ==================== ④ 最近纪念日 ====================
// 🔴🔴 v59(用户 2026-10-18):
// 「首页应该加一个『最近纪念日』模块,放在『往年的今天』前面、
// 『近期任务』后面。内容包括:
// 1. 未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。
// 2. 以倒计时形式展示:距离哪个纪念日还有多少天,
// 距离谁谁谁的生日还有多少天。」
// ⇒ 位置就卡在这两段之间(**不是**"放在首页某处")。
// ⇒ 数据来自 `/home` 的 `recent_days`(服务端已排好序、最多 3 条),
// 来源是"派生生日 + 自建纪念日",正好覆盖用户说的两样。
Column({ space: 0 }) {
FbSectionTitle({
title: '最近纪念日',
accent: this.daysAccent(),
extra: this.recentDays.length > 0
? this.recentDays.length.toString() + ' 个'
: '',
/* 🔴 v86(用户「端上也全部都实现」):与 Web 端 v81 对齐 ——
「如果是纪念日,就有一个『新增纪念日』」。
⚠️ 空态文案原来写「在『纪念日』里添一个」,现在卡片上就能添,
文案改成指这颗按钮(否则用户会以为必须进模块)。 */
addText: '新建',
onAdd: (): void => {
this.openDayCreate();
}
})
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 })
// ==================== ⑤ 最近观影(v86 新增)====================
/* 🔴 用户 2026-10-24「端上也全部都实现」。
Web 端首页一直有这张卡(`_card_movies.php`,v62 就有,v81 补了「新增电影」),
端上首页**从来没有**过 ⇒ 用户在手机上完全看不到观影的近期动态。
⇒ 位置放在「最近纪念日」之后、「往年的今天」之前:
上面三块是"今天/最近要做的事",下面两块是"往回看的日子",
观影正好是两者的交界(刚看过的片子既是记录、也进"往年的今天")。
⚠️ 数据走端上**本地库**(`FbMovieRepo.list()`),不是 `/home`:
`/home` 不带观影列表,而本地库由 `syncAll` 定期同步 —— 与电影页
同一个数据源,不会出现"首页和电影页不一致"。
⚠️ 卡片上只放 4 条、只显示「片名 + 日期 + 影院」:
海报要看图、要占一大块,卡片上不值得(点进电影页再看)。 */
Column({ space: 0 }) {
FbSectionTitle({
title: '最近观影',
accent: this.moviesAccent(),
extra: this.mvRecent.length > 0
? this.mvRecent.length.toString() + ' 部'
: '',
/* 文案照用户原话用「新增电影」—— 与 Web 端 `HomeCards` 的 `add` 一字不差。
⚠️ 表单标题仍是模块里的「记一场」,不为了统一去改模块自己的措辞。 */
addText: '新增',
onAdd: (): void => {
this.openMovieCreate();
}
})
Column({ space: 0 }) {
if (this.mvRecent.length === 0) {
Column({ space: 6 }) {
Text(this.mvLoading ? '正在读观影记录…' : '还没有观影记录')
.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.mvRecent, (m: FbMovieListItem, idx: number) => {
this.movieRow(m, idx === this.mvRecent.length - 1)
}, (m: FbMovieListItem, idx: number): string => 'mv_' + 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 })
// ==================== ⑥ 联系人(v86 新增)====================
/* 🔴 同一条用户需求(「端上也全部都实现」)。
Web 端 v62 就有这张卡、v82 补了「新增成员」;端上首页一直没有。
⚠️ 只列**我的名册**(`FbPersonRepo.list()`),不是全家所有人 ——
首页是"我的"(与 Web 端 `contactsData($familyId, $me, $n)` 同口径)。
⚠️ 头像用「首字 + 底色」而不是真图:卡片上一个个去拉头像会打一堆请求,
而且名册里大部分人是没传头像的(实测线上 1089 人里绝大多数是首字)。
有真头像时仍优先用真图 —— 见 `personAvatar()`。 */
Column({ space: 0 }) {
FbSectionTitle({
title: '联系人',
accent: this.peopleAccent(),
extra: this.pfTotal > 0 ? this.pfTotal.toString() + ' 人' : '',
addText: '新增',
onAdd: (): void => {
this.openPersonCreate();
}
})
Column({ space: 0 }) {
if (this.pfRecent.length === 0) {
Column({ space: 6 }) {
Text(this.pfLoading ? '正在读名册…' : '名册里还没有人')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('点右上角「新增」把家人加进来。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ top: 12, bottom: 12 })
} else {
/* 三列网格:6 个人正好两行,手机上一屏内不压其他卡。
⚠️ 用 `Flex(wrap)` 而不是 `Row` —— `Row` 不换行,
超过 3 个会横向溢出到卡片外面(本项目 v76 的著名坑)。 */
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.pfRecent, (p: FbRosterPerson) => {
this.personTile(p)
}, (p: FbRosterPerson): string => 'pf_' + p.id.toString())
}
.width('100%')
.padding({ top: 10, bottom: 2 })
if (this.pfTotal > this.pfRecent.length) {
Text('共 ' + this.pfTotal.toString() + ' 人,这里显示 '
+ this.pfRecent.length.toString() + ' 位。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width('100%')
.padding({ top: 6, bottom: 4 })
}
}
}
.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)
// ==================== 新建日程(v84)====================
// ⚠️ 遮罩与表单是 Stack 的**兄弟**,不是 Column 的子项
// (本项目踩过:`Column { 整高遮罩; 卡片 }` 会把卡片挤出屏幕,
// 编译通过、节点树里却一个节点都没有 —— 看起来像"点了没反应")。
if (this.calCreating) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.calCreating = false;
})
FbEventEditSheet({
event: null,
isCreate: true,
/* 首页这条区块叫「今天的日程」⇒ 默认给今天 */
presetDate: this.todayIso(),
books: this.calBooks,
onCancel: (): void => {
this.calCreating = false;
},
onSaved: (): void => {
this.calCreating = false;
/* 存成功立刻重拉首页 ⇒ 新日程出现在「今天的日程」里。
⚠️ 必须重拉而不是"本地塞一条":服务端会补 id / 颜色 / 日历本名,
本地塞的那条会和之后下拉刷新出来的长得不一样。 */
this.load();
},
onDeleted: (): void => {
this.calCreating = false;
}
})
.transition(fbSheetTransition())
}
// ==================== 日程详情卡(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',
/* 🔴 v86(用户 2026-10-24「端上也全部都实现」):**打开编辑入口**。
老注释说这里是"有意 false",理由是编辑要跳模块、"用户会看到卡闪一下没了"。
那个理由成立,但**结论错了** —— 正确解法不是"不给编辑",
而是"编辑也不许跳"(与 v79 任务面板的处理完全一致)。
现在点「编辑」只在本页叠一张表单,卡片不动、模块不动。 */
/* 🔴 2026-10-29 真机实测修(工单 laoyang/FamilyBook#31):
原来这里写死 `true`,于是**只读共享本**(如「工作学习」)里的日程
也显示「编辑日程」,用户改完点保存才被服务端拒
「保存失败:这本日历你没有写权限。」
v86 开编辑入口的决定本身没错(编辑就地改、不跳页),
错在没把"没有写权限的本"排除掉 ⇒ 改为按 calWrite 判定。 */
canEdit: this.evtBase !== null && isEventWritable(this.evtBase, this.calBooks),
onNavigate: (navTarget: string): void => {
this.navigateEvent(navTarget);
},
onClose: (): void => {
this.closeEvent();
},
onEdit: (): void => {
this.editEventFromDetail();
}
})
.transition(fbSheetTransition())
}
// ==================== 编辑日程(v86:就地改,不跳日程页)====================
/* 🔴🔴 **必须放在「日程详情卡」之后**(Stack 里后面的盖在前面的上面)。
⚠️⚠️ 2026-10-24 真机事故(本轮):我上一版把这一块**写在了详情卡前面**,
注释里写着"必须放在详情卡之后",代码却反着放 —— 于是详情卡的整屏遮罩
盖在编辑表单**上面**,表单看得见、**所有按钮都点不动**
(实测连右上角「取消」都点不动,`dumpLayout` 里坐标一切正常)。
⇒ 这就是"看着做完了、其实用不了",而**所有"节点存在"类断言全绿**。
⚠️ 只靠注释约束顺序是不够的,守卫里已经有专门一条守它(见 groupV86)。 */
if (this.calEditing && this.evtFull !== null) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.calEditing = false;
})
FbEventEditSheet({
event: this.evtFull,
isCreate: false,
books: this.calBooks,
onCancel: (): void => {
this.calEditing = false;
},
onSaved: (): void => {
this.calEditing = false;
/* 重拉首页列表(摘要卡、条数、统计都会变) */
this.load();
/* 🔴 详情卡还开着 ⇒ 它显示的是 `evtFull` 那份**旧**数据,
不重拉就会出现"表单里改了标题、关掉表单后卡片还是旧标题"。
复用 `openEvent` 那套拉取逻辑(含"先给壳再给全量"的降级)。
⚠️ 传 `evtBase` 而不是 `evtFull`:`openEvent` 的入参是列表行类型。 */
if (this.evtBase !== null) {
this.openEvent(this.evtBase);
}
this.pushCards();
},
onDeleted: (): void => {
/* 在编辑表单里删掉了这条 ⇒ 详情卡不能留在屏幕上(它指向已不存在的记录) */
this.calEditing = false;
this.closeEvent();
this.load();
this.pushCards();
}
})
.transition(fbSheetTransition())
}
// ==================== 任务面板(v79:可就地处理)====================
/* 🔴 用户 2026-10-22:「在首页上可以直接修改编辑任务。」
⇒ v73 的"只读详情卡"升级成面板:状态 / 看板 / 子任务 / 附件 / 留言
全在这张卡里做完,**不跳任务模块**(跳模块是用户明确否掉的)。
⚠️ 面板根节点自带遮罩与 `Stack`,这里**不要**再包一层遮罩 ——
包了就会有两层暗色叠在一起(越点越黑)。 */
if (this.tskBase !== null) {
FbTaskPanel({
taskId: this.tskBase.id,
seed: this.tskBase,
boards: this.tskBoards,
cats: this.tskCats,
accent: this.taskAccent(),
refreshTick: this.tskTick,
onClose: (): void => {
this.closeTask();
},
onEdit: (d: FbTaskDetail): void => {
this.openTaskEdit(d);
},
onChanged: (): void => {
/* 一次动作会牵动"近期任务"这张卡的排序/计数(服务端算的)⇒ 重拉首页 */
this.load();
this.pushCards();
},
onDeleted: (): void => {
this.closeTask();
this.load();
this.pushCards();
}
})
.transition(fbSheetTransition())
}
// ==================== 任务编辑表单(v79:就地改,不跳任务页)====================
if (this.tskEditOpen) {
FbTaskEditSheet({
accent: this.taskAccent(),
boards: this.tskBoards,
cats: this.tskCats,
editingId: this.tskEditId,
editingTitle: this.tskEditTitle,
editingNote: this.tskEditNote,
editingDueAt: this.tskEditDueAt,
editingBoardId: this.tskEditBoardId,
editingCatId: this.tskEditCatId,
editingRepeat: this.tskEditRepeat,
editingStatus: this.tskEditStatus,
onClose: (): void => {
this.tskEditOpen = false;
},
onSaved: (): void => {
this.tskEditOpen = false;
/* 面板还开着 ⇒ 让它自己重拉一次(标题/截止/分类都变了) */
this.tskTick = this.tskTick + 1;
this.load();
this.pushCards();
}
})
.transition(fbSheetTransition())
}
// ==================== 新建纪念日(v86)====================
/* ⚠️ 三个新表单各自一个**独立布尔**,故意不复用:
复用会让"打开哪一个"取决于一堆隐式状态,`backOnce` 也没法按层关。
⚠️ 遮罩都必须是 Stack 的**独立兄弟**(见 build 开头那段事故说明)。 */
if (this.dayCreating) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.dayCreating = false;
})
FbDayEditSheet({
accent: this.daysAccent(),
/* `editingId: 0` = 新建(与纪念日页 `openNew()` 同一个约定)。
⚠️ 其余字段虽然新建用不到,但**必须显式给默认值**:
组件是 `@Param`,不传就沿用上一次的残留(页面常驻不重建)。 */
editingId: 0,
editingTitle: '',
editingKind: '',
editingNote: '',
editingRepeat: 'solar',
editingMonth: new Date().getMonth() + 1,
editingDay: new Date().getDate(),
editingPersonIds: [],
roster: this.dayRoster,
onClose: (): void => {
this.dayCreating = false;
},
onSaved: (): void => {
this.dayCreating = false;
/* 「最近纪念日」是**服务端算的**(`recent_days`)⇒ 必须重拉,
本地塞一条排不出"最近三个"的正确顺序。 */
this.load();
},
onDeleted: (): void => {
this.dayCreating = false;
}
})
.transition(fbSheetTransition())
}
// ==================== #63:最近观影详情 ====================
/* 🔴 用户:「"最近观影"点了之后没反应…点了之后应该有反应,
要么弹出来详细信息。」
⚠️ 复用电影页的同一张卡(`FbMovieDetailSheet`),
不为首页另写一套 —— 否则改一处要改两处。
🔴 用户 2026-11-06:「电影这个框弹出来的时候,应该在下面盖一层蒙版吧?
跟近期任务类似。」
⇒ 蒙版由**组件自己**提供(`FbMovieDetailSheet` 内部根 Stack:
遮罩 + 卡片叠着),与 `FbTaskPanel` / `FbDayEditSheet` 同一做法。
**宿主这里不要再包一层** —— 包了就是两层暗色叠在一起(越点越黑),
而且点遮罩时会被组件自己的全屏根节点拦掉,关不掉。 */
if (this.mvDetail !== null || this.mvDetailLoading
|| this.mvDetailError !== '') {
FbMovieDetailSheet({
detail: this.mvDetail,
loading: this.mvDetailLoading,
error: this.mvDetailError,
listRating: this.mvSeed !== null ? this.mvSeed.reviewAvg : 0,
listReviewCount: this.mvSeed !== null ? this.mvSeed.reviewCount : 0,
accent: this.moviesAccent(),
onClose: (): void => {
this.closeMovieDetail();
}
})
.transition(fbSheetTransition())
}
// ==================== #64:联系人详情(可打电话)====================
/* 🔴 用户:「点击之后弹出来详细信息,就跟名册里面那个人的列表
点击完之后的效果是一样的,这样可以直接打电话。」
⚠️ 复用名册那张卡(`FbPersonSheet`),它自带手机号拨号入口。
🔴 2026-11-06 同一条:蒙版同样由组件自己提供(`FbPersonSheet` 内部根
Stack:遮罩 + 卡片)。宿主不包 —— 理由同上一段。 */
if (this.pfDetail !== null) {
FbPersonSheet({
person: this.pfDetail,
accent: this.peopleAccent(),
canDelete: false, // 首页进来的不给删(避免误删),要删去名册
onClose: (): void => {
this.closePersonDetail();
},
onEdit: (): void => {
/* 关掉详情卡,跳到名册去改 —— 首页没有人员编辑表单,
与其在这里硬塞一份,不如交给名册(那是它的主场)。 */
this.closePersonDetail();
this.app.switchModule('people');
}
})
.transition(fbSheetTransition())
}
// ==================== #62/#63:纪念日详情 ====================
/* 🔴 用户:「近期任务、纪念日、最近观影都像日程一样,弹出来一个卡片,
点编辑的时候才进入编辑。」
⚠️ 复用 `FbDayEditSheet`(它就是"回填一条已有纪念日"的表单)。
它的保存/删除按钮在卡里,看着是"可改的详情卡",
符合用户"先看到信息"的要求。 */
if (this.dayDetail !== null && this.isBirthdayDay()) {
/* 🔴🔴 #87(用户 2026-11-05):
「1. 如果这个纪念日来自生日,我们是不能编辑的。
2. 点击之后,像日程那样弹出一个框来显示就行:显示这个人的头像、姓名,
以及生日的『日』和日期就可以了。」
⚠️ **这里原来是个会改坏数据的 bug**:派生生日也走 `FbDayEditSheet`,
而且传的是 `editingMonth: 1, editingDay: 1`(写死的 1 月 1 日)。
结果点开任何一个生日都显示「1 月 1 日」,点保存还能把
**真实生日覆盖成 1 月 1 日** —— 等于把成员档案里的生日改掉了。
只读卡从根上避开这件事。 */
FbDayBirthdaySheet({
accent: this.daysAccent(),
item: this.dayDetail,
onClose: (): void => {
this.closeDayDetail();
}
})
.transition(fbSheetTransition())
} else if (this.dayDetail !== null) {
FbDayEditSheet({
accent: this.daysAccent(),
editingId: this.dayDetail.id,
editingTitle: this.dayDetail.title,
editingKind: this.dayDetail.kind,
editingNote: '',
editingRepeat: 'solar',
editingMonth: 1,
editingDay: 1,
/* 🔴🔴 #91d(用户 2026-11-05):「你去查一下这个领证纪念日到底关联了
几个人,数据库里。」
查库:「领证纪念日」关联 **2 人**(杨永全 #1 + 洪博为 #3)。
而这里原来只读 `dayDetail.personId` —— 那是**老列**
`anniversaries.person_id`,只能存一个 ⇒ 洪博为被静默丢掉,
编辑框只带出 1 个人。
⇒ 改用 `personIds`(服务端按多对多表给的完整列表)。
⚠️ `personIds` 为空时才退回老列(老数据还没回填多对多表的情况),
这段兜底已写在 `parseRecentDays` 里,这里直接用即可。 */
editingPersonIds: this.dayDetail.personIds.slice(),
roster: this.dayRoster,
onClose: (): void => {
this.closeDayDetail();
},
onSaved: (): void => {
this.closeDayDetail();
this.load();
},
onDeleted: (): void => {
this.closeDayDetail();
this.load();
}
})
.transition(fbSheetTransition())
}
// ==================== #65:往年今天的通用摘要卡 ====================
/* 电影类已走 `openMovieDetail`(弹电影卡);
这里只剩 `trip_node` 等拿不到 tripId 的来源 ⇒ 给只读摘要。
⚠️ 弹错类型的卡比不弹更糟,所以宁可保守。 */
if (this.memDetail !== null) {
this.memorySheet(this.memDetail)
}
// ==================== 新增电影 / 记一场(v86)====================
if (this.mvCreating) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.mvCreating = false;
})
FbMovieNewSheet({
cinemas: this.mvCinemas,
accent: this.moviesAccent(),
/* 「同看的人」默认带上**我**(与电影页同一个口径:自己不用选就该已选上)。
⚠️ 取不到(0)时组件内部不会硬塞一个错的 id。 */
meId: this.app.session.me.id,
onClose: (): void => {
this.mvCreating = false;
},
onCreated: (): void => {
this.mvCreating = false;
/* 本地库已经在 `createMovie()` 里重拉过了 ⇒ 这里只重读卡片那几条 */
this.reloadSideCards();
}
})
.transition(fbSheetTransition())
}
// ==================== 新增成员(v86)====================
if (this.pfCreating) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.pfCreating = false;
})
FbPersonEditSheet({
person: null,
isCreate: true,
/* 已有标签供点选(不传的话用户只能手打,很容易出现"同事"和"同 事"两个标签) */
knownTags: this.personKnownTags(),
accent: this.peopleAccent(),
onCancel: (): void => {
this.pfCreating = false;
},
onSaved: (): void => {
this.pfCreating = false;
this.reloadSideCards();
},
onDeleted: (): void => {
/* 新建模式没有删除,走不到这里;置 false 只是兜底 */
this.pfCreating = false;
}
})
.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%')
.onClick((): void => {
this.openMemoryDetail(m);
})
}
/** `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;
}
/**
* 「最近观影」里的一行(v86,v88 补海报)。
*
* 形态照网页端 `_card_movies.php`:**海报**(左)+ 片名(主)+ 日期 · 影院(次)。
*
* 🔴 用户 2026-11-05:「首页最近观影没有图片呢,左侧都是占位符。」
* v86 当初为了"首页少打几个请求"把海报砍成 🎬 色块,网页端一直是真图
* ⇒ 这里补回真海报。三条约束:
* ① 卡片上只放 **5 条**(`loadSideCards` 的 limit),5 张缩略图可接受;
* ② 走 `fbImageSrc` —— 本地库里已缓存的直接用本地明文,命中缓存时零请求;
* ③ **不加 `?w=` 压缩档位**:`media.php` 只留 1600 一档(#56),
* 对 34×46 的缩略图没意义,白付一次 OSS 处理费。
* ⚠️ 又是**两参数 @Builder**(`isLast` 只为画分隔线)——
* 与 `memoryRow` / `recentDayRow` 同一个安全前提:整块由 ForEach 按 key 重建。
*/
@Builder
movieRow(m: FbMovieListItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
/* 左侧海报位(2:3 竖版,34×46 —— 与网页端 `.hc-mv-poster` 同尺寸口径)。
有海报放真图,没有 / 加载失败退回 🎬 色块。 */
Stack({ alignContent: Alignment.Center }) {
if (m.posterKey !== '' && !this.mvPosterFailed(m.id)) {
Image(fbImageSrc(m.posterKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
// 底下垫一张图标:图还没回来时不会闪白块
.alt($r('app.media.fb_ic_movies'))
// 海报是大图,同步加载会卡住首页首屏
.onError((): void => {
this.markMvPosterFailed(m.id);
})
} else {
/* 没有海报 / 取不到:🎬 + 影院色底(影院色空串由 `movieTint` 兜底)。
这一层同时是上面 Image 的"底"—— 真图铺满后看不见。 */
Column() {
Text('🎬')
.fontSize(15)
}
.width('100%')
.height('100%')
.backgroundColor(this.movieTint(m.cinemaColor))
}
}
.width(34)
.height(46)
.borderRadius(4)
.clip(true)
.backgroundColor(FbColors.inputBg)
Column({ space: 3 }) {
Text(m.title !== '' ? m.title : '(未命名)')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
/* 日期 + 影院。影院名可能很长("万达影城(XX 广场店)")⇒ 一起截断,
用 `maxLines(1)` 保一行 —— 卡片上不该因为影院名换行而高一行。 */
Text(this.movieSub(m))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 44 })
}
}
.width('100%')
.onClick((): void => {
this.openMovieDetail(m.id, m);
})
}
/** 这张海报是不是已经确认取不到(取不到就退回 🎬 色块) */
private mvPosterFailed(id: number): boolean {
return this.mvPosterFailIds.indexOf(id) >= 0;
}
/**
* 记下"这张海报取不到"。
*
* ⚠️ 必须**换新数组**赋值(`concat`)—— `@Local` 不代理数组内部变化,
* `push` 进去界面不会刷新(现象:图还是白的,占位也没出现)。
* ⚠️ 已记过就不再重复加:`onError` 可能多次触发,重复加会让数组无限增长。
*/
private markMvPosterFailed(id: number): void {
if (this.mvPosterFailed(id)) {
return;
}
this.mvPosterFailIds = this.mvPosterFailIds.concat([id]);
}
/** 观影行的副标题:`2026-10-20 · 万达影城`(影院名空则只留日期) */
private movieSub(m: FbMovieListItem): string {
const d: string = this.memoryDate(m.startsAt);
if (m.cinemaName !== '') {
return d + ' · ' + m.cinemaName;
}
return d;
}
/**
* 影院色 → 那个小色块的底色。
*
* ⚠️ 影院色是**六位 hex**(`#RRGGBB`)⇒ 直接拿来做背景会太浓(一眼抢走视线),
* 所以拼一个很浅的透明度版本。用 `rgba` 而不是 `#AARRGGBB`:
* ArkUI 两种都认,但 `rgba` 可读性好得多,也便于这里做兜底。
* ⚠️ 非法值(空串 / 不是 `#RRGGBB`)一律退回中性灰 —— 否则 `rgba(#zzz, .12)`
* 会被解析成透明,色块直接消失(看着像"这一行没图标")。
*/
private movieTint(hex: string): string {
const m: RegExpMatchArray | null = hex.match(/^#[0-9a-fA-F]{6}$/);
if (m === null) {
return '#F1EDE7';
}
const r: number = parseInt(hex.substring(1, 3), 16);
const g: number = parseInt(hex.substring(3, 5), 16);
const b: number = parseInt(hex.substring(5, 7), 16);
return 'rgba(' + r.toString() + ',' + g.toString() + ',' + b.toString() + ',0.12)';
}
/**
* 「联系人」里的一个小格子(v86):头像/首字 + 称呼,三列排。
*
* ⚠️ 宽度写 **33.33%** 而不是固定 vp:卡片宽度会随屏幕变(不写死),
* 固定宽度在大屏上会留一大片空、在小屏上会溢出。
* ⚠️ 内层再套一层 `padding` 做**呼吸**:三列贴死会显得挤成一团。
*/
@Builder
personTile(p: FbRosterPerson) {
Column({ space: 5 }) {
if (p.avatar !== '') {
Image(p.avatar)
.width(38)
.height(38)
.borderRadius(19)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_people'))
} else {
/* 没有头像 ⇒ 首字 + 名册里那个人的底色(与名册页一致,不另算颜色)。
⚠️ 字也要用**对色**(`FbPersonRepo` 存了 avatarColor 是给底用的)——
这里简单用白字:底色本身就是中深色(名册页的取色规则)。 */
Text(this.personInitial(p))
.width(38)
.height(38)
.borderRadius(19)
.backgroundColor(p.avatarColor !== '' ? p.avatarColor : '#B7A99A')
.fontColor(Color.White)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.Center)
}
Text(this.personLabel(p))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Center)
}
.width('33.33%')
.padding({ top: 8, bottom: 8 })
.alignItems(HorizontalAlign.Center)
.onClick((): void => {
this.openPersonDetail(p);
})
}
/**
* 名册里那个人的显示名:**昵称优先**(`shown`),退回本名。
*
* ⚠️ 与名册页同一个口径(`shown` 就是"我怎么叫他")。
* 直接用 `name`(本名)会与名册页显示得不一样,
* 用户会觉得"首页那个人我不认识"。
*/
private personLabel(p: FbRosterPerson): string {
if (p.shown !== '') {
return p.shown;
}
if (p.label !== '') {
return p.label;
}
return p.name;
}
/** 首字(头像兜底)。空名兜底成 `?`,别画出一个空圆。 */
private personInitial(p: FbRosterPerson): string {
const nm: string = this.personLabel(p);
return nm.length > 0 ? nm.substring(0, 1) : '?';
}
/**
* 「最近纪念日」里的一行(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%')
.onClick((): void => {
this.openDayDetail(d);
})
}
/**
* 倒计时文案。
*
* ⚠️ `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';
}
}