/** * 家书 FamilyBook · 名册 / 家谱(鸿蒙端) * * 对应 Web 端 `templates/people/index.php`(名册)与 `templates/kin/tree.php`(家谱)。 * * 这两页在 Web 端是两个模块,但**看的是同一批人**:名册是「平铺的名单」(通讯录视角), * 家谱是「以我为中心的连线」(关系视角)。端上放在同一个文件里,用内部分段切换 —— * 手机上在两页之间来回跳没有意义,切一下就能对照看。 * * ⚠️ 家谱的树形图(Web 端是 HTML 折线)在手机窄屏上排不下。 * 端上改成**「以我为中心的三圈」**:我 → 直系(父母/配偶/子女)→ 其他亲属, * 每圈一张卡,卡里按人列。关系表达上等价,宽度可控。 */ import { FbColors, FbMetric, fbTint, FbTint } from '../../common/Theme'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbKinGraph, FbKinPerson, FbRosterItem } from '../../model/Types'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../../model/Modules'; import { parseKin } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FbAvatar, FbPrimaryButton, FbTag } from '../../components/Widgets'; import { FbPersonSheet } from '../../components/FbPersonSheet'; import { FbPersonQuickSheet } from '../../components/FbPersonQuickSheet'; import { FbPersonEditSheet } from '../../components/FbPersonEditSheet'; import { FbKinGraphView } from '../../components/FbKinGraph'; import { FbTagChip } from '../../components/FbTagChip'; import { FB_DOCK_RESERVE } from '../../components/FloatDock'; import { FbDb } from '../../data/FbDb'; import { FbPersonRepo, FbRosterPerson, FbRosterSyncResult, FbTagCount } from '../../data/FbPersonRepo'; import { FbApiResult } from '../../data/FbHttp'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { call } from '@kit.TelephonyKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll'; import { fbImageSrc } from '../../data/FbAttachQueue'; const DOMAIN: number = 0x0000; const TAG: string = 'FbPeoplePage'; /** * 把仓库的 `FbRosterPerson` 转成页面用的 `FbRosterItem`。 * * 🔴 为什么要这一层映射(而不是让页面直接用 `FbRosterPerson`): * 这个页面的 UI(444 行)是按 `FbRosterItem` 写的,字段语义也**不完全一样**: * · `kinship` ← 服务端的 `label`(**「我该怎么称呼他」**,如"父亲""妻子"); * · `group` ← 服务端 v28 起把"分组"降级成**标签词表**, * 所以这里用**第一个标签**填 group(页面按 group 分堆的逻辑仍可用); * · `birthday` ← 只取**阳历**的月日('MM-DD'),与 Web 端列表显示口径一致; * · `roster` ← 接口不返回(名册里的人都是本家的)⇒ 固定 'member'。 * 改 UI 的代价远大于加这一层,而且这层也**隔离**了"接口字段变了"的冲击。 */ function toRosterItem(p: FbRosterPerson): FbRosterItem { const it: FbRosterItem = { id: p.id, name: p.shown !== '' ? p.shown : p.name, // 「我该怎么称呼他」——服务端算好的(依赖整个家族图谱,端上不重算) kinship: p.label, group: p.tags.length > 0 ? p.tags[0] : '', tags: p.tags, avatarColor: p.avatarColor !== '' ? p.avatarColor : '#A84A28', avatar: p.avatar, phone: p.phone, phones: p.phones, initials: p.initials, roster: 'member', birthday: p.birthdaySolar, isAdmin: p.isAdmin, }; return it; } /** * 本地搜索(**在已加载的名册上过滤**)。 * * ⚠️ 与服务端 `?q=` 的能力**不完全一致**: * 本地能搜姓名/手机号/单位/职务/标签;**搜不到称谓**(那值依赖家族图谱)。 * 这是有意的取舍:本地搜索即时、断网可用;要按称谓搜就走接口。 * ⚠️ 所以这里**不假装**能力一样 —— 界面上的搜索框提示只说"姓名 / 单位 / 标签"。 */ function matchesKeyword(it: FbRosterItem, p: FbRosterPerson | undefined, q: string): boolean { const k: string = q.trim().toLowerCase(); if (k === '') { return true; } // ---- ① 文字包含(姓名 / 标签 / 手机 / 单位 / 职务)---- if (it.name.toLowerCase().indexOf(k) >= 0) { return true; } for (const t of it.tags) { if (t.toLowerCase().indexOf(k) >= 0) { return true; } } if (p !== undefined) { if (p.phone.indexOf(k) >= 0 || p.org.toLowerCase().indexOf(k) >= 0 || p.title.toLowerCase().indexOf(k) >= 0 || p.name.toLowerCase().indexOf(k) >= 0) { return true; } } // ---- ② 拼音首字母**子序列**匹配 ---- // 🔴 用户 2026-10-13:"增加按照名字的拼音首字母进行搜索, // 例如杨永全,可以使用 y、yy、yyq 来搜索和筛选。" // 用**子序列**(不是前缀): // · `y` → 在 `yyq` 里出现过 ✓ // · `yy` → y→y 依次出现 ✓ // · `yyq` → 全中 ✓ // · `yq` → 跳过第二个 y,y…q ✓(用户可能只记得"永全") // 这与服务端 `Pinyin::isSubsequence` **完全同一套规则** —— // 否则会出现"本地搜得到、服务端搜不到"这种诡异差异。 // ⚠️ 只对**纯字母**的查询词做子序列:如果用户输的是中文/数字, // 跟拼音首字母比没意义(`indexOf` 会全部返回 -1,等于白跑)。 if (/^[a-z]+$/.test(k)) { if (isSubsequence(k, it.initials.toLowerCase())) { return true; } // 昵称的首字母也要能搜到(服务端把 name + my_nickname 拼一起算 initials, // 但端上 `initials` 字段就是那个拼好的串 ⇒ 直接比即可) if (p !== undefined && isSubsequence(k, p.initials.toLowerCase())) { return true; } } return false; } /** * `q` 是否是 `s` 的**子序列**(与服务端 `Pinyin::isSubsequence` 同一算法)。 * * 例:`isSubsequence('yq', 'yyq') === true`;`isSubsequence('qy', 'yyq') === false`。 */ function isSubsequence(q: string, s: string): boolean { if (q === '') { return true; } let i: number = 0; for (let j: number = 0; j < s.length && i < q.length; j++) { if (s.charAt(j) === q.charAt(i)) { i++; } } return i === q.length; } /** * 家谱里的一代(一层)。 * * ⚠️ 由 `FbKinRing`(旧的"三圈"演示结构)改成"按代数分层" —— * 因为真实接口 `/api/v1/kin` 给的就是**按代数**的人(`gen` 字段), * 而"三圈"(直系/其他亲属)是当初照着演示数据设计的分法, * 与真实数据的形状对不上。 */ interface FbKinGen { /** 代数:0 = 我,负 = 长辈,正 = 晚辈 */ gen: number; /** 层名,如「父母辈」 */ title: string; /** 说明,如「父辈与母辈」 */ sub: string; /** 这一代的人 */ people: FbKinPerson[]; } @ComponentV2 export struct FbPeoplePage { /** * 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。 * 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。 */ @BuilderParam topBar: () => void; @Param app: FbAppState = fbAppState(); /** 子视图:名册(roster)/ 家谱(kin) */ @Local subView: string = 'roster'; /** 搜索词(本地过滤) */ @Local keyword: string = ''; /** * 当前选中的标签('' = 不筛选)。 * * 🔴 用户 2026-10-12:"名册不需要分组,是按照标签来的, * 把标签都同步过来放在上面,通过点击标签可以筛选"。 * ⇒ 用**标签**做筛选维度,取代原来的分组分堆。 */ @Local tagFilter: string = ''; /** 标签云(每个标签 + 人数),来自本地库聚合 */ @Local tagCloud: FbTagCount[] = []; // ---- 数据(本地优先:先读本地库,再后台同步)---- /** * 名册里的人(**来自本地库**)。 * * ⚠️ 初值给空数组而不是演示数据:先渲染假的、再被真数据替换, * 用户会看到"闪一下"。宁可先显示"加载中/暂无"。 */ @Local all: FbRosterItem[] = []; /** 是否正在首次加载 */ @Local loading: boolean = true; /** 顶部提示(同步状态 / 未登录说明) */ @Local syncNote: string = ''; /** * 当前点开详情的人(null = 没打开)。 * * ⚠️ 存**完整对象**而不是 id:详情页要用的字段全在列表数据里 * (`/roster` 一次就给全了),存 id 还得回查一遍。 */ @Local detailPerson: FbRosterPerson | null = null; /** * 详情请求的**序号**(防止"连点两个人,前一个的响应覆盖后一个")。 * * ⚠️ 不加 `@Local`:它不参与渲染,只是给异步回调比对用。 */ private detailToken: number = 0; /** * 是否正在"新建成员"(用户 2026-10-15:全功能客户端要有添加)。 * * ⚠️ 复用同一个编辑浮层(`isCreate: true`)—— 表单字段完全一样, * 没必要为"新建"再维护一套字段。 */ @Local creating: boolean = false; /** * 正在编辑的人(null = 没打开编辑)。 * * ⚠️ 详情与编辑**是两个独立的浮层状态**(不是"详情里嵌编辑"): * 点详情里的「编辑」⇒ 关详情、开编辑。 * 这样每个浮层的状态都简单(一个 null 判断),也不会互相盖住。 */ @Local editPerson: FbRosterPerson | null = null; /** * 「简要卡片」里的人(点列表整行弹出)。 * * 🔴 v59c(用户 2026-10-18):「点击列表,不打电话了,容易误触。而是弹出 * 一个卡片,卡片中是头像、名称、电话号码、邮箱等简要信息,下面三个按钮, * 详情、编辑和删除。」 * * ⚠️ 与 `detailPerson`(详情浮层)**分开两个状态**,不要合并: * 两者是"概览 → 深入"的两级,且**在同一个 Stack 里叠**(详见渲染处), * 合并成一个变量就分不清"现在该显示哪一级"(从详情返回时要回简要卡)。 */ @Local quickPerson: FbRosterPerson | null = null; /** * 「简要卡片」用哪份数据。 * * 优先用**已拉到的详情那份**(`detailPerson`,字段更全、含工作经历与补充电话); * 没有就退回列表那份(`quickPerson`)。 * ⚠️ 这是为了"从详情返回简要卡"时状态一致 —— 详见 `quickSource()` 的实现与 * `onBackFromDetail()`。 */ @Local quickFromDetail: FbRosterPerson | null = null; /** * 🔴 浮层开合时**让底部悬浮栏让位**(用户 2026-10-15 实测的真 bug)。 * * 现象:详情卡片底部的「编辑 / 关闭」按钮(屏幕 y≈2207-2331)与悬浮栏 * (y≈2240-2350)**在屏幕上重叠**,而悬浮栏在**更外层的 `Index` Stack** 里 * ⇒ 层级更高 ⇒ 点按钮被悬浮栏抢走 ⇒ **直接跳到日历页**。 * * 为什么用 `@Monitor` 而不是在 9 处赋值点手写 `openSheet()`/`closeSheet()`: * 本页 `detailPerson` / `editPerson` 的赋值点有 9 处(含回调、同步回填、 * 抽屉里点人等多种入口),**手写必然漏**;漏一处就是"浮层开着但悬浮栏还在, * 按钮又点不动"的隐蔽 bug。 * ⇒ 用 `@Monitor` 一处声明、自动跟随派生,赋值的写法一概不用改。 * * ⚠️ 用 `AppStorageV2.connect` 拿到的**同一个单例**(`fbAppState()` 返回的就是它)。 */ @Monitor('detailPerson', 'editPerson', 'creating') onSheetChanged(): void { const app: FbAppState = fbAppState(); // 只要有一个浮层开着,就让悬浮栏让位(计数 0/1 即可,本页不会同时开两个) const open: boolean = this.detailPerson !== null || this.editPerson !== null || this.creating; if (open) { app.sheetCount = 1; } else { app.sheetCount = 0; } } /** * 原始名册数据(按 id 索引)。 * * ⚠️ 为什么要多存这一份:页面 UI 用的是 `FbRosterItem`(没有手机号/单位/首字母), * 而**本地搜索**要能按这些字段搜 ⇒ 搜的时候回这里取。 * 只存一份(不用 @Local)也够:它不直接参与渲染。 */ private rawById: Map = new Map(); /** * 家谱里的人(**来自接口** `/api/v1/kin`)。 * * ⚠️ 与名册的 `all` **是两批数据**: * 名册是"我名册里的人"(可上千),家谱是"以我为中心的关系网"(上下两代、十来人)。 * 所以家谱要**单独拉**,不能从 `all` 里挑。 */ @Local kinPeople: FbKinPerson[] = []; /** 家谱的同步提示(与名册分开:两批数据可能一个成功一个失败) */ @Local kinSyncNote: string = ''; /** * 家谱的显示方式:'graph'(知识图谱,默认)/ 'list'(分层列表)。 * * 用户要求把它做成知识图谱那样的图,所以默认给图谱。 * 但保留列表:小屏上人多了图会挤,列表能看清每个人的关系摘要。 */ @Local kinMode: string = 'graph'; /** 我在家谱里的 id(图谱里要标出「我」) */ @Local kinMeId: number = 0; private repo: FbPersonRepo = FbPersonRepo.inst(); /** * 页面出现:先读本地库渲染,再后台同步。 * * ⚠️ 与观影页同一套流程(**先渲染本地、再后台同步**), * 理由见 FbMoviesPage 的说明:等网络回来才渲染 = 转圈好几秒、断网全白。 */ aboutToAppear(): void { this.bootstrap(); } private async bootstrap(): Promise { const ctx: common.Context | undefined = this.getUIContext().getHostContext(); if (ctx === undefined) { this.loading = false; this.syncNote = '本地存储不可用(上下文获取失败)'; return; } try { await FbDb.inst().open(ctx); } catch (err) { const e: BusinessError = err as BusinessError; hilog.error(DOMAIN, TAG, '本地库打开失败:%{public}s', e.message !== undefined ? e.message : JSON.stringify(err)); this.loading = false; this.syncNote = '本地库打开失败'; return; } await this.reloadFromLocal(); this.syncInBackground(); } /** 空状态的标题 */ private emptyTitle(): string { return this.keyword.trim() !== '' ? '没找到' : '名册还是空的'; } /** * 空状态的说明。 * * ⚠️ 这两个方法**不是**为了"好看"才抽出来的 —— * 在 `@Builder` 的组件参数里写**跨行的三目表达式**会让 ArkTS 编译器 * 解析错乱,报的却是完全无关的 `comma operator` / `standalone this` * (实测踩过:真正的错在 334-336 行,报错却指向 341 行)。 * ⇒ 组件参数的取值**一律抽成方法**,别在括号里塞多行表达式。 */ private emptyHint(): string { return this.keyword.trim() !== '' ? '换个关键词试试(能搜姓名 / 单位 / 职务 / 标签)' : '登录后会从服务器拉取你名册里的人'; } /** 从本地库读名册并刷新界面 */ private async reloadFromLocal(): Promise { try { const rows: FbRosterPerson[] = await this.repo.list(); const m: Map = new Map(); for (const p of rows) { m.set(p.id, p); } this.rawById = m; this.all = rows.map(toRosterItem); // 标签云(从本地库聚合,见 FbPersonRepo.tagCloud 的说明) this.tagCloud = await this.repo.tagCloud(); // ⚠️ 筛选中的标签如果已经不存在了(同步后标签被删),要清掉 —— // 否则列表会一直是空的,用户以为"人没了"。 if (this.tagFilter !== '' && !this.hasTag(this.tagFilter)) { this.tagFilter = ''; } } catch (err) { hilog.error(DOMAIN, TAG, '读本地名册失败:%{public}s', JSON.stringify(err)); } finally { this.loading = false; } } /** 标签云里是否还有这个标签 */ private hasTag(tag: string): boolean { for (const t of this.tagCloud) { if (t.tag === tag) { return true; } } return false; } /** * 后台同步(不阻塞界面)。 * * ⚠️ 失败**不弹窗、不清空已有数据** —— 本地优先下"同步失败"只是 * "这次没更新",用户手上的数据仍然可用。只在页面上留一行小字。 */ private async syncInBackground(): Promise { // 🔴 家谱与名册**并行拉**:两者是**独立的两批数据** // (名册 = 我名册里的人,可上千;家谱 = 以我为中心的上下两代,十来人)。 // ⚠️ 并行(不是串行):串行会让"切到家谱"多等一个往返。 // ⚠️ 家谱**不 await 在名册后面**:名册走实体表、家谱走通用缓存, // 两条路互不依赖。 this.loadKin(); const token: string = FB_TOKEN_STORE.get(); if (token === '') { this.syncNote = '未登录,显示的是本地数据'; return; } /* 🔴🔴 必须 try/catch(2026-10-18 真机踩出来的真 bug)。 * * 原来这里直接把 `repo.sync()` 的返回值当"唯一失败通道"—— * 但 `sync()` 里的**落库**(batchInsert / executeSql)抛异常时, * 异常会从 `syncInBackground()` 一路冒到调用方(而调用方是 `bootstrap()`, * 之后没有任何 catch)⇒ **整个后台同步静默死掉**: * · `syncNote` 仍是 ''(看着像"同步成功") * · 列表没有任何数据 * · 用户看到的就是一句「登录后会从服务器拉取你名册里的人」 * ——**一个已经登录的人,被提示去登录**,完全猜不到真实原因。 * 实测这个 bug 藏了好几轮:真机日志里明明有 * `table person_roster has no column named phones`, * 界面上却一句提示都没有。 * * ⇒ 纪律:**后台任务里的异常必须就地落成"用户看得见的一句话"**, * 不能靠"调用方会 catch"(调用方往往是 fire-and-forget 的)。 */ try { const res: FbRosterSyncResult = await this.repo.sync(token); if (!res.ok) { hilog.warn(DOMAIN, TAG, '名册同步失败:%{public}s', res.error); this.syncNote = '同步失败:' + res.error; return; } this.syncNote = ''; await this.reloadFromLocal(); } catch (err) { const e: BusinessError = err as BusinessError; const msg: string = e.message !== undefined ? e.message : JSON.stringify(err); hilog.error(DOMAIN, TAG, '名册同步抛异常(落库失败?):%{public}s', msg); // ⚠️ 文案要**给出可行动的信息**:本地库写不进去,建议重装/清数据 —— // 只说"同步失败"用户只能反复下拉,还是不知道怎么办。 this.syncNote = '名册写入本地库失败:' + msg; this.loading = false; } } /** * 「删除」——**破坏性操作,必须先二次确认**。 * * 🔴 v59c(用户 2026-10-18):简要卡上加了「删除」按钮。 * 这是本项目第一个"从浮层直接删人"的入口(原来只能进编辑浮层再删), * 所以确认这一步**不能省**: * · 一次误触就真的把人删了(服务端是物理删除,没有回收站); * · 而且这个人可能还在观影/日程里被引用 —— 后果不可逆。 * ⇒ 弹系统 `AlertDialog`(比自绘确认框稳,也不占额外的浮层层级)。 * * ⚠️ 文案里带上**姓名**:"删除「老杨」?"比"确定删除吗?"安全得多 —— * 用户能立刻看出"是不是点错了人"(这正是误触最需要的信息)。 */ private confirmDelete(): void { const who: FbRosterPerson | null = this.quickSource(); if (who === null) { return; } const name: string = who.shown !== '' ? who.shown : who.name; this.getUIContext().showAlertDialog({ title: '删除联系人', message: '确定把「' + name + '」从名册里删除吗?此操作无法撤销。', autoCancel: true, primaryButton: { value: '取消', action: (): void => { } }, secondaryButton: { value: '删除', fontColor: FbColors.red, action: (): void => { this.doDelete(who); } } }); } /** 真正执行删除(确认之后) */ private async doDelete(who: FbRosterPerson): Promise { const token: string = FB_TOKEN_STORE.get(); if (token === '') { this.syncNote = '未登录,无法删除'; return; } try { const res: FbApiResult = await this.repo.remove(who.id, token); if (!res.ok) { hilog.warn(DOMAIN, TAG, '删除联系人失败:%{public}s', res.error); this.syncNote = '删除失败:' + res.error; return; } // 关闭浮层 + 重新拉名册(那人整条会从本地库消失) this.quickPerson = null; this.quickFromDetail = null; this.detailPerson = null; this.editPerson = null; this.syncNote = '已删除 ' + (who.shown !== '' ? who.shown : who.name); await this.syncInBackground(); } catch (err) { const e: BusinessError = err as BusinessError; hilog.error(DOMAIN, TAG, '删除联系人抛异常:%{public}s', e.message !== undefined ? e.message : JSON.stringify(err)); this.syncNote = '删除失败:' + (e.message !== undefined ? e.message : '网络异常'); } } /** * 拉家谱(`/api/v1/kin`)—— 先缓存、再网络。 * * ⚠️ 家谱走**通用缓存**(`module_cache`)而不是实体表: * 它是"一张关系网",形状与名册的"一批人"不同, * 落成实体表要 4 张(人/父母/配偶/子女),收益却只是 * "省一次解析"——不值得。见 `FbModuleRepo` 的说明。 */ private async loadKin(): Promise { await loadModule('kin', parseKin, (g: FbKinGraph): void => { this.kinPeople = g.people; this.kinMeId = g.meId; }, (note: string): void => { this.kinSyncNote = note; }); } build() { // 🔴 外层再套一个 Stack:把"页面内容"与"人员详情浮层"叠起来。 // 浮层是**页面内的覆盖层**(不是系统弹窗),理由见 FbPersonSheet 文件头。 Stack({ alignContent: Alignment.Bottom }) { this.pageBody() if (this.detailPerson !== null) { // 半透明遮罩:点它也能关 Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.detailPerson = null; }) FbPersonSheet({ person: this.detailPerson, onClose: (): void => { /* 🔴 v59c:详情是**从简要卡进来的** ⇒ 关掉后回到简要卡, 而不是一路退回列表(用户会觉得"我刚才那层被吞掉了")。 见 `quickFromDetail` 的说明。 */ const back: FbRosterPerson | null = this.detailPerson; this.detailPerson = null; if (back !== null && this.quickPerson !== null) { this.quickFromDetail = back; } }, onEdit: (): void => { // 关详情、开编辑(把同一个人带过去) this.editPerson = this.detailPerson; this.detailPerson = null; } }) } // ---- 简要卡片(点列表整行弹出)---- // // 🔴 v59c(用户 2026-10-18):点行不再拨号,改弹这张卡。 // ⚠️ 与详情浮层**用同一个遮罩色**(`#00000066`)—— // 两级浮层的"压暗程度"必须一致,否则用户会觉得"换了一层世界"。 if (this.quickPerson !== null && this.detailPerson === null) { Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.quickPerson = null; this.quickFromDetail = null; }) FbPersonQuickSheet({ person: this.quickSource(), onClose: (): void => { this.quickPerson = null; this.quickFromDetail = null; }, onDetail: (): void => { // 关简要卡、开详情(把同一个人带过去) const who: FbRosterPerson | null = this.quickSource(); this.quickPerson = null; this.quickFromDetail = null; if (who !== null) { this.openDetail(who.id); } }, onEdit: (): void => { const who: FbRosterPerson | null = this.quickSource(); this.quickFromDetail = null; if (who !== null) { this.editPerson = who; } }, onDelete: (): void => { this.confirmDelete(); } }) } // ---- 编辑浮层 ---- if (this.editPerson !== null) { Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.editPerson = null; }) FbPersonEditSheet({ person: this.editPerson, // 已有标签 ⇒ 供**点选**(不是手输)—— 用户 2026-10-16 knownTags: this.allTagNames(), onCancel: (): void => { // 取消 ⇒ 回到详情(不是直接回列表:用户刚才是从详情进来的) this.detailPerson = this.editPerson; this.editPerson = null; }, onSaved: (): void => { // 🔴 v52:保存成功 ⇒ **回到详情**(而不是关掉回列表)。 // 理由:用户改完最想确认"改对了没",直接看详情最顺; // 关掉的话还得再点一次那个人。 // ⇒ 先用**刚保存的人**把详情打开(它是编辑前的快照、先垫上), // 紧接着发一次详情请求把服务端权威值取回来(`openDetail` 会做)。 const who: FbRosterPerson | null = this.editPerson; this.editPerson = null; this.syncInBackground(); if (who !== null) { this.openDetail(who.id); this.syncNote = '已保存 ' + who.shown; } else { this.detailPerson = null; this.syncNote = '已保存'; } }, onDeleted: (): void => { // 删除成功 ⇒ 关掉编辑、回列表,并**重新同步**(那人整条会消失)。 // ⚠️ 与 `onSaved` 分开处理:保存只需刷新那一条的显示, // 删除要把整份名册重新拉一遍(本地库那一行已经没了)。 const who: FbRosterPerson | null = this.editPerson; this.editPerson = null; this.detailPerson = null; this.syncInBackground(); this.syncNote = who !== null ? ('已删除 ' + who.shown) : '已删除'; } }) } // ---- 新建成员浮层(复用编辑浮层,`isCreate` 打开新建模式)---- if (this.creating) { Column() .width('100%') .height('100%') .backgroundColor('#00000066') .onClick((): void => { this.creating = false; }) FbPersonEditSheet({ person: null, isCreate: true, knownTags: this.allTagNames(), onCancel: (): void => { this.creating = false; }, onSaved: (): void => { // 新建成功 ⇒ 关掉浮层、重新同步(新人要出现在列表里) this.creating = false; this.syncInBackground(); this.syncNote = '已添加'; }, onDeleted: (): void => { // 新建模式没有删除,不会走到这里 this.creating = false; } }) } } .width('100%') .height('100%') } /** 页面主体(原 build 的内容,抽出来给 Stack 用) */ @Builder pageBody() { // 🔴 根容器是 **Column 而不是 Scroll**(页面不滚、列表自己滚)。 // // 为什么不让整页进 `Scroll`(用户 2026-10-12 就定过): // 名册有上千人,整页滚动会把「所有行」都当成一个超长内容渲染, // 而 `List` 是**按需渲染**的(只建可视区附近的 item)。 // 把 List 放进 Scroll ⇒ List 高度塌成 0、性能也崩。 // // ⚠️ 但"顶栏固定不跟滚"是用户 2026-10-15 明确不接受的一点 // ("其他子页面全部按照这个布局方式进行改造",首页那套是跟滚的)。 // ⇒ 解法:**把顶栏与页头塞进 `List` 的第一个 item**, // 它们就成了"列表内容的一部分",随列表一起滚走; // 同时 `List` 仍是唯一滚动容器 ⇒ 按需渲染、性能不丢。 // (见 rosterView / kinView 里的 `this.pageHeaderItem()`) Column() { if (this.subView === 'roster') { this.rosterView() } else { this.kinView() } } .width('100%') .height('100%') // ⚠️ **不要**在这里加 `padding({ bottom: FB_DOCK_RESERVE })`: // · 首页/观影页是 `Scroll` + **内容末尾** padding ⇒ 预留只在滚到底时出现; // · 本页外层是 `Column`(不滚动),给它加 padding 等于**永久占掉**一块高度 // ⇒ 列表矮一截、底下露出一条宽米色(实测 144px vs 观影 80px)。 // ⇒ 让列表撑满整屏,"别被悬浮栏压住"交给**列表自己的底部内边距**。 } /** * 顶栏 + 页头(作为一个**列表首项**,随列表滚走)。 * * 🔴 为什么要做成"列表首项"而不是放在页面外层: * 用户 2026-10-15 要求本页也按首页那套(顶栏跟着内容向上滑走)。 * 本页又不能整页用 `Scroll`(会把 `List` 的高度压成 0,见 `pageBody` 的说明)。 * ⇒ 唯一同时满足两者的做法:把顶栏塞进 `List` 的**第一个 item**。 * 它是列表内容的一部分 ⇒ 上滑时跟着滚出屏幕(和首页观感一致), * 而 `List` 依旧按需渲染 ⇒ 上千人也不卡。 */ @Builder pageHeaderItem() { Column() { this.topBar() this.peopleHeader() } .width('100%') } /** * 页头(分段切换 + 搜索 + 标签筛选条)。 * * ⚠️ 从 `rosterView` 里提出来放在**页面级**: * 这样"名册/家谱"与搜索框在两个子视图下都常驻, * 切换子视图时不会跟着列表一起滚走。 */ @Builder peopleHeader() { Column() { // ---- 分段切换(名册 / 家谱)---- // // 🔴 v59c(用户 2026-10-18):「名册、家谱这两个标签,用的不是主题色。」 // ⇒ 原来两个都硬编码 `'#A84A28'`(**品牌砖红**),而名册的主题色是 // `FB_MODULES` 里那个 `#4A7C82`(青)—— 页面里其它地方 // (如列表项那个"详情"按钮的底色/字色)用的都是主题色粉调, // 于是这一处显得"跟整个页面不是一个色系"。 // ⚠️ 用 `fbThemeVars(...).brand` 走统一算法,而不是直接把 `#4A7C82` // 抄进来:`fbThemeVars` 会把色相拉到一个**可读的饱和度/亮度区间** // (见其实现),直接抄模块色在选中态当底、白字压上去会偏亮发飘。 // ⚠️ 两处都必须传同一个值 —— 之前正是"两处各写一遍字面量"才漂的。 Row({ space: 4 }) { this.segBtn('名册', 'roster') this.segBtn('家谱', 'kin') } .padding(3) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 }) if (this.subView === 'roster') { // ---- 搜索 + 添加 ---- Row({ space: 8 }) { Row({ space: 6 }) { Image($r('app.media.fb_ic_search')) .width(15) .height(15) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(FbTint.faint)) TextInput({ placeholder: '搜索:姓名 / 拼音首字母(yyq)/ 手机 / 单位', text: this.keyword }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontSmall }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(Color.Transparent) .padding(0) .layoutWeight(1) .onChange((v: string) => { this.keyword = v; }) } .layoutWeight(1) .height(36) .padding({ left: 12, right: 12 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .alignItems(VerticalAlign.Center) FbPrimaryButton({ label: '添加', bg: '#4A7C82', icon: $r('app.media.fb_ic_plus'), // 🔴 v52:接上(之前这颗按钮**没有回调**,点了什么都不发生 —— // "全功能客户端"不能有这样的摆设)。 onTap: (): void => { this.creating = true; } }) } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) // ---- 标签筛选条 ---- this.tagBar() } } .width('100%') } /** * 标签筛选条(横向滚动的一排 chips)。 * * 🔴 用户 2026-10-12:"名册不需要分组,是按照标签来的,把标签都同步过来, * 放在上面,通过点击标签,可以进行筛选,标签显示在每个人的下面。" * ⇒ 从"按分组分堆"改成"**顶部标签筛选 + 平铺列表**"。 * ⚠️ 这其实和 Web 端 v28 的改法**完全一致**(那边也是把分组降级成标签, * index.php 的注释写着理由:"一个人既是小学同学又是初中同学,分组放不下")。 * 端上之前还留着分组,是没跟上。 */ @Builder tagBar() { if (this.tagCloud.length > 0) { Scroll() { Row({ space: 8 }) { // 「全部」永远排第一(点它清除筛选) // 🔴 用 `FbTagChip` **组件**而不是 `@Builder` —— 见该组件的文件头说明 // (@Builder 的简单类型参数变化**不会**触发重建 = 点了没反应)。 FbTagChip({ label: '全部', active: this.tagFilter === '', count: -1, onPick: (): void => { this.tagFilter = ''; } }) ForEach(this.tagCloud, (t: FbTagCount) => { FbTagChip({ label: t.tag, active: this.tagFilter === t.tag, count: t.n, onPick: (): void => { // 再点当前已选中的标签 = 取消(符合直觉) this.tagFilter = this.tagFilter === t.tag ? '' : t.tag; } }) }, (t: FbTagCount): string => 'tc_' + t.tag) } .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad }) } // ⚠️ 横向滚动:标签可能十几个,竖屏排不下。 .scrollable(ScrollDirection.Horizontal) // 🔴 横向滚动上报(让抽屉给横滑组件让位)—— 见 common/FbScroll.ets .onDidScroll(fbOnHScroll) .scrollBar(BarState.Off) .width('100%') .padding({ bottom: 8 }) } } // ============================================================ // 名册 // ============================================================ @Builder rosterView() { Column() { // 提示行(同步状态 / 未登录说明)—— 只在有话说时显示 if (this.syncNote !== '') { Row() { Text(this.syncNote) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(2) } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 }) } if (this.loading) { Text('正在读取本地数据…') .width('100%') .padding({ top: 40, bottom: 40 }) .textAlign(TextAlign.Center) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } else if (this.visiblePeople().length === 0) { Text(this.emptyHint()) .width('100%') .padding({ top: 40, bottom: 40, left: 24, right: 24 }) .textAlign(TextAlign.Center) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } else { // 🔴 **平铺列表**(不再按分组分堆)。 // 用户 2026-10-12:"名册不需要分组,是按照标签来的", // ⇒ 分组筛选已移到顶部的标签条(见 tagBar),列表只负责呈现"筛完的人"。 // ⚠️ 这也让 `List` 的 `ListItemGroup` 整个消失 —— 少一层嵌套, // 而且不用再纠结"分组头的背景色"。 List({ space: 0 }) { // 🔴 **顶栏 + 页头作为第一个 item**(用户 2026-10-15 要求本页也跟滚)。 // 放进 List 内容 ⇒ 上滑时跟着滚出屏幕,与首页观感一致; // 同时 List 仍按需渲染 ⇒ 上千人也不卡(见 pageHeaderItem 的说明)。 ListItem() { this.pageHeaderItem() } ForEach(this.visiblePeople(), (p: FbRosterItem, idx: number) => { ListItem() { // 🔴 每条**独立圆角卡**(用户 2026-10-16)⇒ 卡与卡之间留空隙, // 不再用 Divider 连成一大块白底。 // ⚠️ 间距写在 `personRow` **内部**(@Builder 返回 void,不能链式修饰) this.personRow(p) } }, (p: FbRosterItem): string => 'pr_' + p.id.toString()) // 🔴 **末尾占位**:给底部悬浮栏留出高度。 // 为什么要做成**列表的最后一个 item**、而不是 List 的 `padding(bottom)`: // · `padding(bottom)` 是**容器内边距** ⇒ 它把列表的**可视底边往上收** // ⇒ 内容在那条线就停住/被裁,看着就是"没滚到屏幕最底、下面空一块" // (用户 2026-10-15:「它的内容是从悬浮框上面滚出来的」); // · 做成 item ⇒ 它是**内容的一部分**,跟着一起滚, // 平时在屏幕外、只有滚到底时才出现在悬浮栏后面 —— // 与观影页 `.padding` 挂在**内容 Column** 上的效果一致。 ListItem() { this.dockSpacerItem() } } // 🔴 **`layoutWeight(1)` 让列表吃掉页头之后的全部剩余高度** // ⇒ 列表白底一直铺到屏幕底部,下面只留一条极窄的页面底色 // (与首页/观影页观感一致;用户明确要求这个形态)。 // ⚠️ 这里能用 `layoutWeight` 的**前提是外层不再是 Scroll** —— // 在 Scroll 里子组件高度由内容决定,`layoutWeight` 会拿到 0。 // 见 `build()` 的说明。 .layoutWeight(1) .width('100%') /* 🔴🔴 v59b(用户 2026-10-18):「名册页面,不好看,太丑了,应该是 * 每个人都是圆角矩形,往中间收一点,距离左右都有点距离, * 每个人中间都有一点空隙。这样是很多个圆角矩形平铺下来的。」 * * 🔴 真凶:卡片**早就**是圆角矩形了(v51 就做了 `borderRadius` + * `margin`),但**容器也是纯白** ⇒ **白卡压在白底上**, * 圆角和左右收边**全都看不见** —— 看上去就是一整块通栏白。 * (这解释了为什么"改过一轮还是丑":改的是卡片的形状, * 而问题在于它没有可对比的背景。) * * ⇒ 容器改成**页面底色(宣纸米色)**,白卡才"浮"得出来; * 外层那个兜底白(见 build 末尾)也一并改掉,否则滚到底 * 会从米色突然切回白色。 */ .backgroundColor(FbColors.paper) // 🔴🔴 **这里绝对不能再加 `padding({bottom})`** // (用户 2026-10-15:「它的内容是从悬浮框上面滚出来的」)。 // // ⚠️ 容器上的 `padding(bottom)` 是**内边距**:它会把 List 的**可视底边** // 往上收一截 ⇒ 列表内容在那条线就被裁掉/停住, // 看上去就是"内容没能滚到屏幕最底、下面空出一块"。 // ⇒ 正确做法与观影页一致:预留改成**列表内容末尾的一个占位 item** // (它是"内容",会跟着一起滚,只在滚到底时才占位)。 // 见下面的 `dockSpacerItem`。 .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) // 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets .onDidScroll(fbOnScroll) // 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断) // 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets .onTouch(fbOnTouch) } } .width('100%') .layoutWeight(1) /* 兜底底色 = **页面底色**(原来是纯白)。 ⚠️ 必须与列表容器同色:列表内容不足一屏时,露出来的就是这一层; 两层不同色会在底部切出一条突兀的色带(v51 踩过同类问题)。 */ .backgroundColor(FbColors.paper) } /** * 列表末尾的**悬浮栏占位**(纯留白)。 * * 🔴 存在的理由:底部悬浮快捷栏是**浮在内容之上**的, * 不留白的话最后一屏会被它压住。但这个留白**必须做成内容的一部分** * (列表的最后一个 item),不能做成容器的 `padding(bottom)` —— * 后者会把列表可视底边往上收,造成"内容没滚到屏幕最底"的观感。 * ⚠️ 高度用 `FB_DOCK_RESERVE`(栏高 + 下边距 + 余量),与观影页口径一致。 * ⚠️ 背景色给**白**:它是列表最后一块,白底要一直铺到屏幕底, * 否则这里会露出一条页面底色(用户看到的"黄条")。 */ @Builder dockSpacerItem() { Row() .width('100%') .height(FB_DOCK_RESERVE) .backgroundColor(FbColors.surface) } /** 一个人一行:头像 + 名字/称谓 + 标签 + 生日 */ @Builder personRow(p: FbRosterItem) { Row({ space: 12 }) { // 头像:有图的显示图、没图的回落色块字(800 人里只有 19 人有图) FbAvatar({ name: p.name, color: p.avatarColor, dia: 40, fontSizeVp: 16, imageUrl: fbImageSrc(p.avatar, FB_TOKEN_STORE.get()) }) Column({ space: 3 }) { Row({ space: 6 }) { Text(p.name) .fontSize(FbMetric.fontBody) .fontColor(FbColors.ink) .fontWeight(FontWeight.Medium) .maxLines(1) /* ⚠️ 允许收缩 —— 长名字不会把这一行顶出去。 * ⚠️ 注意:这**不是**"右侧贴屏幕"的根因(那个是卡片自己的 * `.width('100%')` + margin 打架,见下方卡片那段的说明); * 它防的是"长名字把同行的小标签挤没"。 */ .flexShrink(1) if (p.isAdmin) { FbTag({ text: '管理员', tone: 'brand' }) } if (p.kinship !== p.name && p.kinship !== '') { FbTag({ text: p.kinship, tone: 'muted' }) } } .alignItems(VerticalAlign.Center) .width('100%') Row({ space: 8 }) { if (p.tags.length > 0) { ForEach(p.tags, (t: string) => { FbTag({ text: t, tone: 'blue' }) }, (t: string): string => 'tg_' + p.id.toString() + '_' + t) } if (p.birthday.length > 0) { Text('生日 ' + p.birthday) .fontSize(10) .fontColor(FbColors.faint) } } .alignItems(VerticalAlign.Center) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) /* ⚠️ 这一列是弹性项,必须允许收缩 —— 否则它的最小宽度(由内容决定) 会把整行顶宽、把卡片撑破(右侧紧贴屏幕,用户 2026-10-18 报的那个)。 */ .flexShrink(1) /* 🔴 v59c(用户 2026-10-18):「列表页面中,也不要详情按钮了。」 * ⇒ 原来这里有个显式的「详情」按钮(v51 加的,当时"点行=拨号" * 占用了整行点击,详情只能另给入口)。 * 现在**点行改成弹简要卡片**(卡片里有详情/编辑/删除三个按钮) * ⇒ 整行点击已经是入口,这个按钮就是冗余的,且它正是把卡片 * 右侧撑破的元凶之一(固定宽度 + 不收缩)。 */ } /* 🔴🔴 **绝对不要在这里写 `.width('100%')`**(2026-10-18 真机实测找到的真凶): * 卡片同时有 `.margin({left:16, right:16})` 与 `.width('100%')` 时, * `100%` 是相对**父容器全宽**(= 屏宽 1080),margin 再把盒子往右推 * ⇒ 实测卡片 x 范围是 `50 ~ 1080`(**左边留了 50、右边 0**), * 正好就是用户报的「左侧有空隙、右侧紧贴屏幕」。 * ⇒ 去掉 `width`,Row 按"父宽 − margin"自适应,两边就都是 16。 * ⚠️ 一开始我误判成"内容把卡片撑破"(`flexShrink`),加了收缩也没用 —— * 真正的证据是**布局树里卡片自己的 x 是 50~1080**(不是子节点越界)。 * 教训:先量**卡片本身**的盒子,再怀疑子节点。 */ .padding({ left: 14, right: 14, top: 12, bottom: 12 }) /* 🔴 v59b(用户 2026-10-18):「每个人都是圆角矩形,往中间收一点, * 距离左右都有点距离,每个人中间都有一点空隙。这样是很多个 * 圆角矩形平铺下来的。」 * * ⚠️ 形状其实**早就是**圆角卡了(v51),用户说"丑"的真凶在容器 —— * 列表容器也是纯白 ⇒ 白卡压白底 ⇒ 圆角/收边**全看不见**。 * 已把容器改成页面底色(见 rosterView 的 `backgroundColor`), * 白卡这才"浮"得出来。 * ⚠️ 这里的三个数也一起放大了一档(user 明确要"收一点 + 有空隙"): * 左右 16(= 全站 pagePad,与页头对齐)、卡间 10(原来 8,偏挤)、 * 圆角 14(原来 10,在 12 的行内边距下 10 显得方)。 */ .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radiusCard) .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 10 }) .alignItems(VerticalAlign.Center) /* 🔴🔴 v59c(用户 2026-10-18):「点击列表,不打电话了,容易误触。 * 而是弹出一个卡片,卡片中是头像、名称、电话号码、邮箱等简要信息, * 下面三个按钮,详情、编辑和删除。」 * * ⇒ 点整行**不再拨号**(拨号容易误触:列表里手指一滑就拨出去了), * 改成弹「简要卡片」(见 `quickSheet` 那一组状态与 `quickPanel`)。 * 电话号码在卡片里也是可点的(那时是**确认过**的点击,不算误触)。 */ .onClick((): void => { this.openQuick(p); }) } /** * 打开某人的「简要卡片」(点列表整行触发)。 * * 🔴 v59c(用户 2026-10-18):「点击列表,不打电话了,容易误触。而是弹出 * 一个卡片,卡片中是头像、名称、电话号码、邮箱等简要信息,下面三个按钮, * 详情、编辑和删除。」 * * ⚠️ 与 `openDetail` 是**两级**: * 简要卡(概览 + 三个动作)→ 点「详情」才进完整浮层。 * 从列表那份(`rawById`)直接起 —— 它已经有头像/姓名/电话/邮箱/标签, * 够画这张卡(**不必等网络**,点开就有东西)。 */ private openQuick(p: FbRosterItem): void { const raw: FbRosterPerson | undefined = this.rawById.get(p.id); if (raw === undefined) { return; } this.quickFromDetail = null; this.quickPerson = raw; } /** * 简要卡要用哪份数据(详情那份优先)。 * * ⚠️ 抽成方法而不是在 `@Builder` 参数里写三目:括号里塞多行表达式会让 * ArkTS 编译器解析错乱(本项目踩过,报的却是无关的 `comma operator`)。 */ private quickSource(): FbRosterPerson | null { return this.quickFromDetail !== null ? this.quickFromDetail : this.quickPerson; } /** * 名册里**所有标签名**(给编辑浮层的"点选"用)。 * * ⚠️ 取自本地库聚合出来的标签云(`this.tagCloud`,按人数降序)—— * 它就是"这个家里用过哪些标签"的权威清单。 */ private allTagNames(): string[] { const out: string[] = []; for (const t of this.tagCloud) { out.push(t.tag); } return out; } /** * 打开某人的**详情浮层**(简要卡里点「详情」)。 * * 🔴 v52(用户 2026-10-15):**先秒开、再补全**。 * 本地库里那份只有"列表要用的字段",而"全功能"要求详情能看到 * **工作经历 / 补充电话 / 私人备注 / 各级地址** —— 这些只在 * `GET /people/` 里有。 * ⇒ ① 立即用本地那份把浮层打开(不等网络,点开就有内容); * ② 同时发一个详情请求,回来就**原地替换**(字段更多、更准)。 * ⚠️ 断网/失败时保持 ① 的显示,不弹错误 —— * "详情少几项"比"点了没反应"好得多。 * ⚠️ 用 `this.detailToken` 防止"快速连点两个人,前一个的响应把后一个覆盖"。 */ private openDetail(id: number): void { const raw: FbRosterPerson | undefined = this.rawById.get(id); if (raw === undefined) { return; } this.detailPerson = raw; const token: string = FB_TOKEN_STORE.get(); if (token === '') { return; } this.detailToken += 1; const my: number = this.detailToken; this.repo.fetchDetail(id, token).then((full: FbRosterPerson | null): void => { // ① 期间用户已经关了浮层、或又点开别人 ⇒ 丢弃这次结果 if (full === null || my !== this.detailToken) { return; } // ② 浮层还开着同一个人 ⇒ 原地升级成完整版 if (this.detailPerson !== null && this.detailPerson.id === id) { this.detailPerson = full; } // ③ 顺手把列表里那份也补全(详情带的字段是超集,回填不吃亏) this.rawById.set(id, full); }); } /** * 拨号(跳到系统拨号盘,**不直接呼出**)。 * * ⚠️ 用 `call.makeCall(context, 号码)` 拉系统拨号盘并填好号码, * 由用户按最后一下 —— 既满足"不直接呼出"的合规要求, * 又不会像 `action: 'ohos.want.action.dial'` 那样弹「选择打开方式」 * (隐式 Intent 会让所有认领该 action 的应用都成为候选,实测踩过)。 */ private dial(phone: string, who: string): void { // ⚠️ 必须是 **`UIAbilityContext`**,不是 `common.Context` —— // `startAbility` 只在 UIAbilityContext 上(实测:用 common.Context // 编译报 "Property 'startAbility' does not exist on type 'Context'")。 const ctx: common.UIAbilityContext | undefined = this.getUIContext().getHostContext() as common.UIAbilityContext | undefined; if (ctx === undefined) { return; } // 🔴 用 `call.makeCall(ctx, 号码)` 而**不是** `startAbility(dial + tel: URI)` // (用户 2026-10-15 真机实测踩出来的): // // `startAbility({ action: 'ohos.want.action.dial', uri: 'tel:...' })` 是 // **隐式 Intent** —— 系统里凡声明了该 action 的应用都会成为候选, // 于是弹出「选择打开方式」框(实测本机弹的就是这个,还误点进了"同步日历")。 // 而且各厂商对 `tel:` 的处理也不一致(有的拉起拨号盘、有的直接拨)。 // // `call.makeCall(context, phoneNumber)` 是**系统直达 API** // ("Launches the call screen and displays the dialed number"), // 没有应用选择框、行为跨机型一致,且**不需要任何权限** // (只需 `SystemCapability.Applications.Contacts`,非受限)。 // ⚠️ 仍只是"打开拨号盘并填好号码",最后一下由用户按 —— 合规要求满足。 // ⚠️ `@since 12`,本工程 `compatibleSdkVersion` 24,可用。 call.makeCall(ctx, phone).catch((err: BusinessError): void => { hilog.warn(DOMAIN, TAG, '拨号失败(%{public}s):%{public}s', who, JSON.stringify(err)); }); } // ============================================================ // 家谱 // ============================================================ /** * 家谱的说明条("以我为中心" + 图谱/列表切换 + 补关系按钮)。 * * ⚠️ 从 `kinView` 里抽出来的原因:**列表模式**要把它**一起放进滚动的 Scroll** * (这样它才跟着内容滚走),而**图谱模式**它是固定的一层。 * 抽成一个 Builder,两处引用同一份实现,不会改一处漏一处。 */ @Builder kinHeader() { Row({ space: 8 }) { Column({ space: 2 }) { Text('以我为中心') .fontSize(FbMetric.fontBody) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) Text(this.kinTotalText()) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) // 图谱 / 列表 切换 Row({ space: 0 }) { this.kinModeBtn('图谱', 'graph') this.kinModeBtn('列表', 'list') } .padding(2) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusPill) FbPrimaryButton({ label: '补关系', bg: '#8A6B4B', icon: $r('app.media.fb_ic_plus') }) } .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 }) .alignItems(VerticalAlign.Center) } @Builder kinView() { Column() { // 🔴 图谱模式**不跟滚**(它是 Canvas 画的一整屏图,本就没有"滚动内容"可言) // ⇒ 本模式顶栏仍是固定的一层。 // 「列表」模式则把页头移进内层 `Scroll` 一起滚(见下面的分支)。 if (this.kinMode === 'graph') { this.pageHeaderItem() this.kinHeader() } if (this.loading) { Text('正在读取本地数据…') .width('100%') .padding({ top: 40, bottom: 40 }) .textAlign(TextAlign.Center) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } else if (this.kinPeople.length === 0) { Text('还没有亲属关系。\n在网页端「家谱」里填上父母 / 配偶 / 子女,这里就会长出来。') .width('100%') .padding({ top: 40, bottom: 40, left: 24, right: 24 }) .textAlign(TextAlign.Center) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } else if (this.kinMode === 'graph') { // ---- 知识图谱(Canvas 画:圆节点 + 连线)---- Column() { FbKinGraphView({ people: this.kinPeople, meId: this.kinMeId, onPick: (id: number): void => { this.openDetail(id); } }) } .layoutWeight(1) .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: FB_DOCK_RESERVE - 24 }) } else { // ---- 分层列表 ---- Scroll() { Column({ space: 0 }) { // 🔴 **顶栏 + 页头 + 说明条**都放进这个滚动的 Scroll 里 // ⇒ 上滑时一起滚走(用户 2026-10-15 要求本页也按首页那套)。 // ⚠️ 图谱模式**不能**这样放(它不滚动,见上面的分支)。 this.pageHeaderItem() this.kinHeader() ForEach(this.kinGens(), (g: FbKinGen) => { this.genBlock(g) }, (g: FbKinGen): string => 'kg_' + g.gen.toString()) Text('只显示以我为中心、上下两代以内的亲属(手机上再往外排就看不下了)。') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width('100%') .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 8, bottom: 20 }) // 🔴 **末尾占位**:与名册列表同理 —— 悬浮栏留白必须做成**内容的一部分**, // 不能做成 Scroll 的 `padding(bottom)`(那会把可视底边往上收, // 看着就是"内容没滚到屏幕最底、下面空一块")。 this.dockSpacerItem() } .width('100%') } .layoutWeight(1) .width('100%') .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .align(Alignment.TopStart) // 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets .onDidScroll(fbOnScroll) // 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断) // 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets .onTouch(fbOnTouch) } } .width('100%') .layoutWeight(1) } /** * 一代人(家谱里的一层)。 * * ⚠️ 自己居中、长辈在上、晚辈在下 —— 与"从上往下读"的直觉一致。 * 标题写清"这是哪一代"(父母辈 / 我这一辈 / 子女辈), * 否则用户看到一堆名字不知道是什么关系。 */ @Builder genBlock(g: FbKinGen) { Column({ space: 0 }) { // 代数标题 Row({ space: 8 }) { Text(g.title) .fontSize(FbMetric.fontSmall) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) Text(g.sub) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Blank() Text(g.people.length.toString() + ' 人') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .width('100%') .padding({ left: 14, right: 14, top: 10, bottom: 6 }) .backgroundColor(FbColors.surface) // 这一代的人 ForEach(g.people, (p: FbKinPerson, idx: number) => { Column({ space: 0 }) { this.kinRow(p) if (idx < g.people.length - 1) { Divider() .strokeWidth(0.5) .color(FbColors.line) .margin({ left: 66 }) } } .width('100%') }, (p: FbKinPerson): string => 'kp_' + p.id.toString()) } .width('100%') .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 10 }) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .clip(true) } /** 家谱里的一行(头像 + 称呼 + 称谓 + 关系摘要) */ @Builder kinRow(p: FbKinPerson) { Row({ space: 12 }) { FbAvatar({ name: p.shown, color: p.avatarColor !== '' ? p.avatarColor : '#8A6B4B', dia: 40, fontSizeVp: 16 }) Column({ space: 3 }) { Row({ space: 6 }) { Text(p.shown) .fontSize(FbMetric.fontBody) .fontColor(FbColors.ink) .fontWeight(p.gen === 0 && p.label === '我' ? FontWeight.Bold : FontWeight.Medium) .maxLines(1) if (p.label !== '') { FbTag({ text: p.label, tone: 'muted' }) } } .alignItems(VerticalAlign.Center) // 关系摘要(有几个父母/子女/配偶)—— 让人一眼看出"这人有几个孩子" Text(this.relSummary(p)) .fontSize(10) .fontColor(FbColors.faint) .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) } .width('100%') .padding({ left: 14, right: 14, top: 11, bottom: 11 }) .alignItems(VerticalAlign.Center) .onClick((): void => { // 点家谱里的人 ⇒ 复用名册的详情浮层(同一个人、同一份数据源) const raw: FbRosterPerson | undefined = this.rawById.get(p.id); if (raw !== undefined) { this.detailPerson = raw; } }) } /** 一圈亲属卡 */ // ============================================================ // 共用小件 // ============================================================ /** * 分段切换的一个按钮(名册 / 家谱)。 * * 🔴 v59c(用户 2026-10-18):「名册、家谱这两个标签,用的不是主题色。」 * ⇒ 颜色**不再由调用方传字面量**,改成方法内部自己取名册模块的主题色。 * 之前两个调用点各写一遍 `'#A84A28'`(品牌砖红,与名册的青色主题不符), * 而且"两处各写一遍"本身就是漂移源(改一处忘一处)。 * ⚠️ 取色走 `fbThemeVars(名册模块色).brand`(统一算法), * 不是直接抄 `FB_MODULES` 里那个 `#4A7C82` —— 见调用点上方那段说明。 */ @Builder segBtn(label: string, key: string) { Text(label) .fontSize(FbMetric.fontSmall) .fontWeight(this.subView === key ? FontWeight.Medium : FontWeight.Normal) .fontColor(this.subView === key ? Color.White : FbColors.muted) .textAlign(TextAlign.Center) .height(30) .layoutWeight(1) .backgroundColor(this.subView === key ? this.themeBrand() : Color.Transparent) .borderRadius(FbMetric.radiusPill) .onClick(() => { this.subView = key; }) } /** 名册模块的主题色(选中态实底)。取色规则见 `segBtn` 的说明。 */ private themeBrand(): string { const m: FbModuleDef | undefined = fbModuleOf('people'); return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; } /** * 当前该显示的人(**标签筛选 + 关键词搜索**之后的结果)。 * * 🔴 取代了原来的 `byScope()`: * · 旧版按 `rosterScope`(家里的人/亲友)筛 —— 但名册接口返回的**全是 * 本家成员**,那个切换在数据上恒等于 member,等于没筛; * · 现在按**标签**筛(用户 2026-10-12 要求),标签就是 v28 起 * 取代"分组"的那套东西。 * * ⚠️ 三目运算符**绝不能跨行写**!这里原来是 * `const list = cond ? a : b` 分成三行 —— ArkTS 解析跨行 `?:` 会失败, * 而且报的是**几百行之外**的 "comma operator" 错(实测踩了一整轮)。 * ⇒ 一律用 if/else。 */ private visiblePeople(): FbRosterItem[] { let list: FbRosterItem[] = this.all; // ① 标签筛选 if (this.tagFilter !== '') { list = list.filter((it: FbRosterItem): boolean => it.tags.indexOf(this.tagFilter) >= 0); } // ② 关键词搜索(本地:姓名/手机/单位/职务/标签/首字母) if (this.keyword.trim() !== '') { list = list.filter((it: FbRosterItem): boolean => { const raw: FbRosterPerson | undefined = this.rawById.get(it.id); return matchesKeyword(it, raw, this.keyword); }); } return list; } /** 三圈:我 / 直系 / 其他亲属 */ /** 家谱视图切换按钮(图谱 / 列表) */ @Builder kinModeBtn(label: string, key: string) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(this.kinMode === key ? Color.White : FbColors.muted) .backgroundColor(this.kinMode === key ? '#4A7C82' : Color.Transparent) .borderRadius(FbMetric.radiusPill) .padding({ left: 12, right: 12, top: 5, bottom: 5 }) .onClick((): void => { this.kinMode = key; }) } /** 家谱:按代数分组(服务端已按 gen 排序) */ private kinGens(): FbKinGen[] { const map: Map = new Map(); for (const p of this.kinPeople) { const cur: FbKinPerson[] | undefined = map.get(p.gen); if (cur !== undefined) { cur.push(p); } else { map.set(p.gen, [p]); } } // ⚠️ Map 的键是数字,直接 sort 会按**字符串**排(-1, 0, 1, -2 → -1,-2,0,1) // ⇒ 先转成数组、按数值排。 const gens: number[] = []; map.forEach((_v: FbKinPerson[], k: number): void => { gens.push(k); }); gens.sort((a: number, b: number): number => a - b); const out: FbKinGen[] = []; for (const g of gens) { const ps: FbKinPerson[] = map.get(g) !== undefined ? (map.get(g) as FbKinPerson[]) : []; const it: FbKinGen = { gen: g, title: this.genTitle(g), sub: this.genSub(g), people: ps, }; out.push(it); } return out; } /** 代数 → 中文层名 */ private genTitle(g: number): string { if (g === 0) { return '我这一辈'; } if (g === -1) { return '父母辈'; } if (g <= -2) { return '祖辈(上 ' + (-g).toString() + ' 代)'; } if (g === 1) { return '子女辈'; } return '孙辈(下 ' + g.toString() + ' 代)'; } /** 代数说明 */ private genSub(g: number): string { if (g === 0) { return '我与配偶、兄弟姐妹'; } if (g < 0) { return '父辈与母辈'; } return '孩子与侄甥'; } /** 「N 位父母 · N 位子女」这类摘要 */ private relSummary(p: FbKinPerson): string { const parts: string[] = []; if (p.parents.length > 0) { parts.push('父母 ' + p.parents.length.toString()); } if (p.spouses.length > 0) { parts.push('配偶 ' + p.spouses.length.toString()); } if (p.siblings.length > 0) { parts.push('兄弟姐妹 ' + p.siblings.length.toString()); } if (p.children.length > 0) { parts.push('子女 ' + p.children.length.toString()); } return parts.length > 0 ? parts.join(' · ') : '暂无亲属关系'; } /** 家谱顶部的人数说明 */ private kinTotalText(): string { if (this.kinPeople.length === 0) { return '还没有亲属关系'; } return this.kinPeople.length.toString() + ' 位亲属 · 上下两代'; } /** 取某个人的头像色(从名册里查,查不到用默认砖红) */ private colorOf(id: number): string { const hit: FbRosterItem | undefined = this.all.find((p: FbRosterItem): boolean => p.id === id); return hit !== undefined ? hit.avatarColor : '#A84A28'; } }