真机实测纠正了我第一轮的误判。 现象(用户):「名册列表左侧有空隙了……怎么右侧不是呢?右侧还是紧贴屏幕。」 像素实测:左米色 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 处在注释里)⇒ 基线红; 最终 = **剥注释 + 只看卡片自己那一层**)。
1641 lines
68 KiB
Plaintext
1641 lines
68 KiB
Plaintext
/**
|
||
* 家书 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';
|
||
}
|
||
} |