用户 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 通过、已装机启动。
1186 lines
52 KiB
Plaintext
1186 lines
52 KiB
Plaintext
/**
|
||
* 家书 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';
|
||
}
|
||
} |