From 051d3974bade14ba9ec4e0ff38b0ea2daa8be9a7 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sat, 24 Oct 2026 20:22:35 +0800 Subject: [PATCH] =?UTF-8?q?v86=20=E9=A6=96=E9=A1=B5=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=BA=94=E9=A1=B9=E5=85=A5=E5=8F=A3=EF=BC=88=E5=B0=B1=E5=9C=B0?= =?UTF-8?q?=E7=BC=96=E8=BE=91=20+=204=20=E5=BC=A0=E5=8D=A1=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=20+=20=E6=96=B0=E5=A2=9E=E8=A7=82=E5=BD=B1/=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E5=8D=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-24「端上也全部都实现」,即 v84 验收时留下的三条: 名册新增成员 / 首页日程弹框后的编辑 / 首页其他卡片的新增。 实现: - 日程详情卡「编辑」改成就地叠编辑表单,不再 closeEvent()+switchModule - 近期任务 / 最近纪念日 / 最近观影 / 联系人 四张卡加「+ 新建」 - 观影卡与联系人卡端上原来没有,本次新建(数据走本地仓储,/home 不返回) - backOnce 按层加 6 个分支;@Monitor 覆盖 8 个标志位 - FbSectionTitle 扩 addText/onAdd(带默认值,老调用点不改) 真机验收抓出三个「断言全绿但用不了」的 bug,一并修掉: 1. 编辑表单所有按钮点不动 —— Stack 层叠顺序写反,详情卡的整屏遮罩 盖在表单之上。dumpLayout 坐标全对、节点全在,只有真点才会发现。 2. 「+ 新建」点了没反应 —— syncNote 在本页只有赋值没有渲染点, 失败分支静默 return。统一改成 note() = 赋值 + 当场 Toast。 3. 改选「不重复」存完还是「每天」—— recognize 的保护逻辑(防清掉自定义 重复规则)误伤了「用户主动清空」:原规则非空 + 选 none ⇒ rrule 整项 不发。加 fRepeatTouched 区分「归入 none」与「主动选 none」。 验证:守卫 1167/0 · 反向验证全量 242/0 · 装机成功 端到端落库 建→改→删 全走通,测试数据已清理 --- .../main/ets/components/FbEventEditSheet.ets | 32 +- entry/src/main/ets/pages/home/FbHomePage.ets | 875 +++++++++++++++++- 2 files changed, 865 insertions(+), 42 deletions(-) diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index e8ccd04..0028865 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -162,6 +162,17 @@ export struct FbEventEditSheet { @Local fNote: string = ''; /** 重复的 key(REPEAT_OPTS 里那个) */ @Local fRepeat: string = 'none'; + /** + * 🔴 用户**是否亲手点过**重复选项(v86d)。 + * + * 为什么要这一位:`save()` 里有个保护逻辑 —— 原日程的 `rrule` 若非空 + * 而端上认不出(归到 `none`),就不发 `rrule`,免得把人家自定义的规则清掉。 + * 但这条保护**误伤了一个常见操作**:原规则是「每天」,用户改选「不重复」, + * 此时 `fRepeat === 'none'` 且 `event.rrule !== ''` ⇒ 被判成"认不出"⇒ + * **整项跳过、规则原样保留** ⇒ 用户看着选了不重复,存完还是每天,且毫无提示。 + * ⇒ 用这一位区分"归入 none"(初始态/认不出)与"主动选了 none"(要清空)。 + */ + @Local fRepeatTouched: boolean = false; /** 提醒分钟(可多选;空数组 = 不提醒) */ @Local fRemind: number[] = []; /** 选中的日历本 id */ @@ -479,6 +490,11 @@ export struct FbEventEditSheet { active: this.fRepeat === o.key, onPick: (): void => { this.fRepeat = o.key; + /* 🔴 v86d(2026-10-24 真机事故):**记下"用户真的点过"**。 + 详见 `save()` 里 `recognize` 的说明 —— 没有这一位, + 用户从「每天」改选「不重复」时,保存会把这项**整条跳过**, + 于是"选了不重复、存完还是每天",而且**没有任何报错**。 */ + this.fRepeatTouched = true; } }) }, (o: FbRepeatOpt): string => 'rp_' + o.key) @@ -821,11 +837,17 @@ export struct FbEventEditSheet { note: this.fNote, reminder_minutes: this.fRemind }; - // ⚠️ 重复:只在用户**确实选过**时才发。 - // 编辑一条"自定义重复"的日程时,端上认不出来(归到"不重复"), - // 若照样发个空 rrule,就会把人家原来的规则**清掉**。 - // ⇒ 规则原文认不出(非空且 chip 是 none)⇒ 这一项**不发**,保持原样。 - const recognize: boolean = this.fRepeat !== 'none' + /* ⚠️ 重复:只在**能确定用户意图**时才发。 + 两种情况会走到这里: + ① 用户**亲手点过**某个 chip(含"不重复")⇒ 以他点的为准,必须发。 + ② 用户没点过,但原日程的 `rrule` 本来就认得出(或本来就是空)⇒ 发原值。 + 留空的唯一情形:用户没点过、且原规则非空又认不出(自定义重复)—— + 这时发空 `rrule` 会把人家的规则**清掉**,所以宁可不发。 + 🔴 v86d 修的正是漏掉情况 ① 里的"主动选不重复": + 原写法只判 `fRepeat !== 'none'`,于是"每天 → 不重复"被判成认不出, + 整项跳过 ⇒ 存完还是每天(真机实测,用户会以为点了没生效)。 */ + const recognize: boolean = this.fRepeatTouched + || this.fRepeat !== 'none' || (this.event !== null && this.event.rrule === ''); if (recognize) { input.rrule = this.ruleOfRepeat(); diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets index e2c15ff..faccdac 100644 --- a/entry/src/main/ets/pages/home/FbHomePage.ets +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -60,6 +60,12 @@ 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'; +import { FbMovieNewSheet } from '../../components/FbMovieNewSheet'; +import { FbPersonEditSheet } from '../../components/FbPersonEditSheet'; +import { FbMovieRepo, FbMovieListItem, FbCinemaItem } from '../../data/FbMovieRepo'; +import { FbPersonRepo, FbRosterPerson } from '../../data/FbPersonRepo'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; @@ -142,6 +148,16 @@ export struct FbHomePage { @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; @@ -201,17 +217,70 @@ export struct FbHomePage { */ @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[] = []; + /** 影院列表(`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') + @Monitor('evtBase', 'tskBase', 'tskEditOpen', 'calCreating', 'calEditing', + 'dayCreating', 'mvCreating', 'pfCreating') onDetailChanged(): void { this.app.sheetCount = (this.evtBase !== null || this.tskBase !== null - || this.tskEditOpen || this.calCreating) ? 1 : 0; + || this.tskEditOpen || this.calCreating || this.calEditing + || this.dayCreating || this.mvCreating || this.pfCreating) ? 1 : 0; } aboutToAppear(): void { @@ -224,6 +293,10 @@ export struct FbHomePage { ⚠️ 登记/注销必须成对(见 `common/FbBack.ets` 的纪律)。 */ FB_BACK.register('home', (): boolean => this.backOnce()); this.load(); + /* v86:观影 / 联系人两张卡走端上本地库,与 `/home` 无关 ⇒ 单独拉、不 await。 + ⚠️ 必须**不 await**:本地库首次打开或库很大时读一次要几百毫秒, + 串在上面会让首页首屏白等(而这两张卡是锦上添花)。 */ + this.loadSideCards(); } aboutToDisappear(): void { @@ -238,12 +311,31 @@ export struct FbHomePage { this.tskEditOpen = false; return true; } - /* v84 新建日程:它是**独立表单**(不在详情卡之上),但要优先于详情卡 —— - 两者不可能同时开,放前面只是保证"开了就一定关得掉"。 */ + /* 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; @@ -296,6 +388,8 @@ export struct FbHomePage { this.app.unread = d.unread; }, (note: string): void => { + /* ⚠️ 同 `loadTaskMeta` 的那条:这是首屏自动加载的后台失败, + 不是用户点击的直接后果 ⇒ **故意不走 `note()`**,不弹 Toast。 */ this.syncNote = note; }); } @@ -383,16 +477,22 @@ export struct FbHomePage { } /** - * 日程详情卡上点「编辑」→ 切到日程模块。 + * 日程详情卡上点「编辑」→ **就地**打开编辑表单(v86)。 * - * ⚠️ 这里**仍然要跳模块**,而且是唯一该跳的地方:编辑是日程页的职责 - * (它有日历本选择、重复规则、提醒、地点联想那一整套表单)。 - * 在首页再复制一份编辑表单 = 同一件事两个实现,必然漂移。 - * 用户否掉的是"**看点详情就跳走**",不是"编辑也不许跳"。 + * 🔴 历史(别走回头路): + * v73 把"点日程跳模块"改成"就地弹详情卡",用户原话是 + * 「不要进入到日程里面」。 + * v84 我却把详情卡上的「编辑」做成了 `closeEvent() + switchModule('calendar')` + * —— 于是点「编辑」还是会跳走,只是跳得晚一点。用户这轮说「端上也全部都实现」, + * 指的就是这类"看起来做了、其实还是老行为"的地方。 + * ⇒ 现在与任务的 `openTaskEdit` 对齐:卡片不关,直接在上面叠一张编辑表单。 + * + * ⚠️ 详情卡**不关**(`evtBase` 保留):关掉再开表单会让用户觉得"卡片没了", + * 而且保存后还要靠它在原位重拉一次详情。表单叠在它上面, + * 关闭表单就回到详情卡 —— 与任务面板 → 编辑表单的层级关系一致。 */ private editEventFromDetail(): void { - this.closeEvent(); - this.app.switchModule('calendar'); + this.openEventEdit(); } // ============================================================ @@ -423,6 +523,9 @@ export struct FbHomePage { (note: string): void => { /* ⚠️ 失败**不拦路**:面板里状态/子任务/留言都还能用, 只是"换看板"那一排会少几个(甚至只剩默认看板)。 */ + /* ⚠️ 这里**故意不走 `note()`**:它是首屏自动加载的后台失败, + 不是用户点击的直接后果。启动就弹一个 Toast 会让人莫名其妙, + 而且这条失败不影响主流程(面板本身能用)。 */ this.syncNote = note; }); } @@ -440,26 +543,190 @@ export struct FbHomePage { } /** - * v84:首页「今天的日程」区块点「+ 新建」→ **就地**打开新建表单。 + * v86:「近期任务」区块点「+ 新建」→ 就地打开新建任务表单。 * - * 流程:懒拉日历本 → 打开 `FbEventEditSheet(isCreate: true)`。 + * 🔴 用户第一轮原话里就有这条(「网页端,首页……在全部任务旁边,放一个新建任务的功能」), + * Web 端 v78 做了、端上一直缺 ⇒ 本轮补齐。 * - * ⚠️ 日历本为空时**不要"打不开就算了"**:新建表单的"日程本"是必选项, - * 没有可写本就画不出表单。这时要么提示、要么至少把表单打开让它自己报错 - * —— 选**明说一句**(用户点了一下什么都没发生,是最难查的失败)。 - * ⚠️ `presetDate` 用"今天":首页这条区块就叫「今天的日程」, - * 用户在这里点新建,十有八九是给今天加一件。 - * ⚠️ 不要 `await` 之后才置 `calCreating` 以外的任何界面状态 —— - * 拉本期间给一个 `calLoadingBooks`,防止连点开出两个表单。 + * ⚠️ 与 `openTaskEdit` 的区别**只在回填字段**:新建必须把 7 个字段**全部清零**。 + * 不清零的话,上一次编辑过的标题/看板/分类会漏进新建表单里 —— + * 表现为"新建任务时标题已经填好了上次那条的名字", + * 用户很难理解,而且会真的把它建出来(脏数据)。 + * ⇒ 清零逻辑集中在这一个方法里,别在别处再开一条通路。 + * ⚠️ 先拉看板/分类(`loadTaskMeta`):新建表单里"属于哪个看板 / 什么分类"是选择项, + * 没有元数据那两排就是空的。拉取失败**不拦路**(照常开表单,用户可以先写标题)。 */ - private async openCalCreate(): Promise { - if (this.calCreating || this.calLoadingBooks) { + private async openTaskCreate(): Promise { + if (this.tskEditOpen) { return; } + this.tskEditId = 0; + this.tskEditTitle = ''; + this.tskEditNote = ''; + this.tskEditDueAt = ''; + this.tskEditBoardId = 0; + this.tskEditCatId = 0; + this.tskEditRepeat = ''; + await this.loadTaskMeta(); + this.tskEditOpen = true; + } + + // ============================================================ + // v86:纪念日 / 观影 / 联系人 —— 卡片上的「新建」 + // ============================================================ + + /** + * 「最近纪念日」区块点「+ 新建」→ 就地打开纪念日表单。 + * + * ⚠️ 必须先把 `roster` 拉齐:纪念日表单里"关联谁"是多选, + * 名单空着那一栏就没人可选(但**不拦路** —— 不关联人也能建纪念日)。 + * ⚠️ 拉的是**端上本地库**(`FbPersonRepo.list()`),与纪念日页同一个来源; + * 本地库由 `syncAll` 定期同步,所以第一次装机时可能为空 —— + * 那时仍然要能建(纪念日本身不依赖人)。 + */ + private async openDayCreate(): Promise { + if (this.dayCreating) { + return; + } + if (this.dayRoster.length === 0 && !this.dayLoadingRoster) { + 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; + } + } + this.dayCreating = true; + } + + /** + * 「观影」区块点「+ 新增」→ 就地打开「记一场」表单。 + * + * ⚠️ `FbMovieNewSheet` 需要 `cinemas`(影院是**必选**的,没有影院建不了)。 + * 影院从本地库读 —— 本地库里一条都没有时(刚装机、还没同步过), + * 表单里那一排是空的、存不上。这时**明说一句**别让用户白填: + * 「先去观影模块把影院同步一次」。 + * ⚠️ 与日程的 `ensureCalBooks()` 同一个道理:**没有必选项就别开空表单**, + * 否则现象是"填完了点保存,什么都没发生"。 + */ + private async openMovieCreate(): Promise { + 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 { + 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 { const token: string = FB_TOKEN_STORE.get(); if (token === '') { - this.syncNote = '还没登录,无法新建日程'; - return; + this.note('还没登录,无法操作日程'); + return false; } if (this.calBooks.length === 0) { this.calLoadingBooks = true; @@ -473,12 +740,54 @@ export struct FbHomePage { } } if (this.calBooks.length === 0) { - this.syncNote = '这个账号还没有可写的日程本,先去日程页设置里建一本'; + /* ⚠️ 不要"打不开就算了":用户点了一下什么都没发生,是最难查的失败。 + 新建表单的"日程本"是必选项,没有可写本就画不出表单 ⇒ **明说一句**。 */ + this.note('这个账号还没有可写的日程本,先去日程页设置里建一本'); + return false; + } + return true; + } + + /** + * v84:首页「今天的日程」区块点「+ 新建」→ **就地**打开新建表单。 + * + * ⚠️ `presetDate` 用"今天":首页这条区块就叫「今天的日程」, + * 用户在这里点新建,十有八九是给今天加一件。 + * ⚠️ 拉本期间用 `calLoadingBooks` 挡住连点(否则会开出两个表单)。 + */ + private async openCalCreate(): Promise { + 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 { + if (this.evtFull === null || this.calEditing || this.calCreating) { + return; + } + if (!(await this.ensureCalBooks())) { + return; + } + this.calEditing = true; + } + /** * 把最新数据推给桌面上所有日程卡片。 @@ -507,6 +816,57 @@ export struct FbHomePage { 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(); @@ -810,7 +1170,16 @@ export struct FbHomePage { // ==================== ③ 近期任务 ==================== Column({ space: 0 }) { - FbSectionTitle({ title: '近期任务', extra: this.taskExtra() }) + FbSectionTitle({ + title: '近期任务', + extra: this.taskExtra(), + /* 🔴 v86(用户「端上也全部都实现」):与 Web 端 v78 对齐,卡片上直接新建。 + ⚠️ 别再删掉这颗按钮 —— 与日程那颗同理,它是本条需求的**唯一可见证据**。 */ + addText: '+ 新建', + onAdd: (): void => { + this.openTaskCreate(); + } + }) Column({ space: 0 }) { if (this.tasks.length === 0) { Row() { @@ -852,7 +1221,15 @@ export struct FbHomePage { title: '最近纪念日', 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) { @@ -860,7 +1237,7 @@ export struct FbHomePage { Text('还没有要过的日子') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) - Text('在「纪念日」里添一个,或者给家人的资料里填上生日。') + Text('点右上角「+ 新建」添一个,或者给家人的资料里填上生日。') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) } @@ -882,7 +1259,120 @@ export struct FbHomePage { .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: '最近观影', + 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: '联系人', + 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:「近期任务的下面,应该加上**往年的今天**, // 跟电脑端一样,你看看那个是什么逻辑。」 // ⇒ 位置**就在近期任务下面**(用户明确指定,不是"放在首页某处")。 @@ -1003,14 +1493,12 @@ export struct FbHomePage { accent: this.calAccent(), bookName: this.evtBase !== null ? this.evtBase.calName : '', bookColor: this.evtBase !== null ? this.evtBase.color : '#9AA0A6', - /* 🔴 首页**不给编辑入口**(`canEdit: false` 是有意的): - 用户要的是"看详情、能导航",而"编辑"要跳去日程模块 —— - 首页这颗按钮点下去会先关掉卡、再切模块,用户会看到"卡闪一下没了、 - 页面换成了日程",那正是他说"不要进入到日程里面"的那种突兀感。 - ⇒ 编辑请从日程模块进(那里点开同一条就带「编辑日程」)。 - ⚠️ 也别把这条改成"跳过去并自动打开这条的编辑"—— 那要传一串参数穿两个模块, - 复杂度远大于收益,而且用户没提这个需求。 */ - canEdit: false, + /* 🔴 v86(用户 2026-10-24「端上也全部都实现」):**打开编辑入口**。 + 老注释说这里是"有意 false",理由是编辑要跳模块、"用户会看到卡闪一下没了"。 + 那个理由成立,但**结论错了** —— 正确解法不是"不给编辑", + 而是"编辑也不许跳"(与 v79 任务面板的处理完全一致)。 + 现在点「编辑」只在本页叠一张表单,卡片不动、模块不动。 */ + canEdit: true, onNavigate: (): void => { this.navigateEvent(); }, @@ -1024,6 +1512,55 @@ export struct FbHomePage { .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 的"只读详情卡"升级成面板:状态 / 看板 / 子任务 / 附件 / 留言 @@ -1083,6 +1620,111 @@ export struct FbHomePage { }) .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()) + } + + // ==================== 新增电影 / 记一场(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%') @@ -1263,6 +1905,165 @@ export struct FbHomePage { return s.length >= 10 ? s.substring(0, 10) : s; } + /** + * 「最近观影」里的一行(v86)。 + * + * 形态照网页端 `_card_movies.php`:**片名**(主)+ 日期 · 影院(次)。 + * ⚠️ 不放海报:卡片上放图要么挤成小方块看不清、要么整行高度被拉起来, + * 而且每行拉一张图 = 首页多几个请求。看海报请进电影页。 + * ⚠️ 又是**两参数 @Builder**(`isLast` 只为画分隔线)—— + * 与 `memoryRow` / `recentDayRow` 同一个安全前提:整块由 ForEach 按 key 重建。 + */ + @Builder + movieRow(m: FbMovieListItem, isLast: boolean) { + Column({ space: 0 }) { + Row({ space: 10 }) { + /* 小圆角色块代替海报:用影院色(`cinemaColor`) + —— 既区分了影院、又不吃网络请求。 + ⚠️ 影院色可能是空串(没设影院的老数据)⇒ 兜底一个中性灰。 */ + Column() { + Text('🎬') + .fontSize(15) + } + .width(34) + .height(34) + .borderRadius(9) + .backgroundColor(this.movieTint(m.cinemaColor)) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + + 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%') + } + + /** 观影行的副标题:`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) + } + + /** + * 名册里那个人的显示名:**昵称优先**(`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)。 *