Files
FamilyBook_harmonyos/entry/src/main/ets/pages/people/FbPeoplePage.ets
T
laoyang 1c1a859464 修「名册卡片右侧贴屏幕」的真凶:width('100%') 与 margin 打架(不是 flexShrink)
真机实测纠正了我第一轮的误判。

现象(用户):「名册列表左侧有空隙了……怎么右侧不是呢?右侧还是紧贴屏幕。」
像素实测:左米色 52~72px、**右米色 0px**;布局树里卡片盒子 x = **50~1080**。

🔴 真根因:卡片**同时**写了
    .width('100%')
    .margin({ left: 16, right: 16 })
  `100%` 是相对**父容器全宽**(= 屏宽 1080),margin 再把盒子往右推
  ⇒ 盒子变成 50~1080(左边留 50、右边 0)—— 正是用户看到的形态。
  ⇒ 去掉 `.width('100%')`,Row 按"父宽 − margin"自适应,两边都是 16。
  真机复验:卡片盒子 x = 50~**1030**(左右各 50,对称);
            像素扫描 y=860/880/1090/1310 全部 **左=右**。

⚠️ 我第一轮误判成"内容把卡片撑破",去加了 `flexShrink(1)` —— 那是**错的**:
   `flexShrink` 防的是"长名字把同行的小标签挤没",与本 bug 无关。
   真正的证据是**布局树里卡片自己的 x 是 50~1080**(不是子节点越界)。
   ⇒ 教训:**先量"卡片本身"的盒子,再怀疑子节点。**
   (`flexShrink` 保留 —— 它本身是有价值的防护,但注释已改正,
     不再宣称它是本 bug 的修法。)

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 378/0;
_fb_v59c_falsify.py 16/0(本批新增第 16 项注入:把 width('100%') 加回卡片,
必须被抓住。该判据本身也反复调了三轮才既不恒红、又能抓住注入:
"别处也有" ⇒ 恒红;"取卡片尾部窗口" ⇒ 覆盖不到注入点;
"取 personRow 整段" ⇒ 段里天然含 4 处 width(3 处在注释里)⇒ 基线红;
最终 = **剥注释 + 只看卡片自己那一层**)。
2026-10-18 21:56:34 +08:00

1641 lines
68 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 名册 / 家谱(鸿蒙端)
*
* 对应 Web 端 `templates/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<number, FbRosterPerson> = new Map<number, FbRosterPerson>();
/**
* 家谱里的人(**来自接口** `/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<void> {
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<void> {
try {
const rows: FbRosterPerson[] = await this.repo.list();
const m: Map<number, FbRosterPerson> = new Map<number, FbRosterPerson>();
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<void> {
// 🔴 家谱与名册**并行拉**:两者是**独立的两批数据**
// (名册 = 我名册里的人,可上千;家谱 = 以我为中心的上下两代,十来人)。
// ⚠️ 并行(不是串行):串行会让"切到家谱"多等一个往返。
// ⚠️ 家谱**不 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<void> {
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<void> {
await loadModule<FbKinGraph>('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/<id>` 里有。
* ⇒ ① 立即用本地那份把浮层打开(不等网络,点开就有内容);
* ② 同时发一个详情请求,回来就**原地替换**(字段更多、更准)。
* ⚠️ 断网/失败时保持 ① 的显示,不弹错误 ——
* "详情少几项"比"点了没反应"好得多。
* ⚠️ 用 `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<number, FbKinPerson[]> = new Map<number, FbKinPerson[]>();
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';
}
}