Files
FamilyBook_harmonyos/entry/src/main/ets/pages/people/FbPeoplePage.ets
T

1394 lines
55 KiB
Plaintext
Raw Normal View History

/**
* 家书 FamilyBook · 名册 / 家谱(鸿蒙端)
*
* 对应 Web 端 `templates/people/index.php`(名册)与 `templates/kin/tree.php`(家谱)。
*
* 这两页在 Web 端是两个模块,但**看的是同一批人**:名册是「平铺的名单」(通讯录视角),
* 家谱是「以我为中心的连线」(关系视角)。端上放在同一个文件里,用内部分段切换 ——
* 手机上在两页之间来回跳没有意义,切一下就能对照看。
*
* ⚠️ 家谱的树形图(Web 端是 HTML 折线)在手机窄屏上排不下。
* 端上改成**「以我为中心的三圈」**:我 → 直系(父母/配偶/子女)→ 其他亲属,
* 每圈一张卡,卡里按人列。关系表达上等价,宽度可控。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbKinGraph, FbKinPerson, FbRosterItem } from '../../model/Types';
import { parseKin } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FbPersonSheet } from '../../components/FbPersonSheet';
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 { 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;
/**
* 🔴 浮层开合时**让底部悬浮栏让位**(用户 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;
}
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();
}
/**
* 拉家谱(`/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 => {
this.detailPerson = null;
},
onEdit: (): void => {
// 关详情、开编辑(把同一个人带过去)
this.editPerson = this.detailPerson;
this.detailPerson = null;
}
})
}
// ---- 编辑浮层 ----
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() {
// ---- 分段切换(名册 / 家谱)----
Row({ space: 4 }) {
this.segBtn('名册', 'roster', '#A84A28')
this.segBtn('家谱', 'kin', '#A84A28')
}
.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)
.fillColor(FbColors.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%')
// 🔴 **白底铺满整屏、不内缩、不圆角**(用户 2026-10-15 反馈"最下面还有一条
// 有背景色的东西,不是从最底下滑上来的")。
//
// 之前是"左右 margin 内缩 + 圆角"的**卡片形态** ⇒
// ① 列表底部与屏幕底之间留出页面底色;
// ② 左右两条底色也会在滚到底时露出来。
// ⇒ 白底**通栏到底**,不再做卡片。
// ⚠️ 别再用 `margin`/`borderRadius`/`clip` —— 那三个就是"卡片感"的来源。
.backgroundColor(FbColors.surface)
// 🔴🔴 **这里绝对不能再加 `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)
// 🔴 外层也铺白底:内容不足一屏时,列表白底铺不到屏幕底,
// 这一层就是"兜底的白" —— 不能让页面底色从底部露出来。
.backgroundColor(FbColors.surface)
}
/**
* 列表末尾的**悬浮栏占位**(纯留白)。
*
* 🔴 存在的理由:底部悬浮快捷栏是**浮在内容之上**的,
* 不留白的话最后一屏会被它压住。但这个留白**必须做成内容的一部分**
* (列表的最后一个 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)
if (p.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
if (p.kinship !== p.name && p.kinship !== '') {
FbTag({ text: p.kinship, tone: 'muted' })
}
}
.alignItems(VerticalAlign.Center)
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)
// 「详情」按钮(用户要求:右侧加一个按钮写着"详情")
// ⚠️ 为什么不是那个灰箭头:箭头**只是装饰**(用户说的"灰色小横线"就是它),
// 而"点行"这个动作被用户指定成**拨号**了 ——
// 所以详情必须有个**显式的按钮**,否则没有任何入口。
Text('详情')
.fontSize(FbMetric.fontTiny)
.fontColor('#4A7C82')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#4A7C8215')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.openDetail(p.id);
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 11, bottom: 11 })
// 🔴 用户 2026-10-16:「人员列表项:每一个人员是不是都可以做成一个
// **圆角矩形**的形式?」⇒ 每条**独立一张圆角卡**(白底 + 间距),
// 不再靠 Divider 连成一大块。
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
// ⚠️ 间距放这里(@Builder 不能链式)—— 左右留边 + 卡间空隙
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.alignItems(VerticalAlign.Center)
// 🔴 点整行 = **拨打这个人的电话**(用户 2026-10-13 / 10-15 明确要求)。
// 规则(2026-10-15 用户确认):**有主号打主号**;
// 一个号码都没有时改成"打开详情"(点了没反应比什么都让人困惑)。
//
// ⚠️ 为什么不用"挑最近打过的号":
// HarmonyOS **没有"读通话记录列表"的公开 API**(只有个
// `hasMatchedCallLog`——判断某号码 6 小时内打过没,还要 `CHECK_CALL_LOG`
// 敏感权限),**无法按时间排出"最近那个"**。已与用户确认走"主号优先"。
// ⚠️ `phones` 由服务端保证「主号排第一」(见 FbRosterPerson.phones)。
// ⚠️ 用 `p.phones` 而不是 `p.phone`:后者只是 `phones[0]` 的便捷别名,
// 这里显式用数组,语义更清楚(也方便将来做"多号选择")。
.onClick((): void => {
if (p.phones.length > 0) {
this.dial(p.phones[0], p.name);
} else {
this.openDetail(p.id);
}
})
}
/**
* 打开某人的详情浮层(点「详情」按钮、或对没手机号的人点整行)。
*
* 🔴 v52(用户 2026-10-15):**先秒开、再补全**。
* 本地库里那份只有"列表要用的字段",而"全功能"要求详情能看到
* **工作经历 / 补充电话 / 私人备注 / 各级地址** —— 这些只在
* `GET /people/<id>` 里有。
* ⇒ ① 立即用本地那份把浮层打开(不等网络,点开就有内容);
* ② 同时发一个详情请求,回来就**原地替换**(字段更多、更准)。
* ⚠️ 断网/失败时保持 ① 的显示,不弹错误 ——
* "详情少几项"比"点了没反应"好得多。
* ⚠️ 用 `this.detailToken` 防止"快速连点两个人,前一个的响应把后一个覆盖"。
*/
/**
* 名册里**所有标签名**(给编辑浮层的"点选"用)。
*
* ⚠️ 取自本地库聚合出来的标签云(`this.tagCloud`,按人数降序)——
* 它就是"这个家里用过哪些标签"的权威清单。
*/
private allTagNames(): string[] {
const out: string[] = [];
for (const t of this.tagCloud) {
out.push(t.tag);
}
return out;
}
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;
}
})
}
/** 一圈亲属卡 */
// ============================================================
// 共用小件
// ============================================================
@Builder
segBtn(label: string, key: string, color: 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 ? color : Color.Transparent)
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.subView = key;
})
}
/**
* 当前该显示的人(**标签筛选 + 关键词搜索**之后的结果)。
*
* 🔴 取代了原来的 `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';
}
}