v73:家谱自绘重做(Canvas 画线 + 原生卡片),去掉列表只留图谱

用户原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」

改法:不再全 Canvas 手绘(只有彩色圆点 + 首字,中文又平又硬、做不出圆角阴影),
改成「Canvas 只画线 + 原生 Column/Text/FbAvatar 绝对定位」。
不能改用 Path 组件:Path.commands 单位是 px,而 .position() 是 vp,混用会缩 1/3;
Canvas 绘制坐标系本身是 vp,与 .position() 自洽。

布局算法抽成纯函数 common/KinLayout.ets(可单独验证,不再和"画"揉在一起):
- 横向单位是「夫妻单元」而不是「人」,夫妻并排紧邻
- 子女挂在夫妻单元中点下方(family-chart 语义)
- 迭代松弛 24 轮容纳冲突约束(家谱不是树:姻亲的"挂父母下方"与"夫妻并排"互相打架)
- 全程确定性(排序带 minId 兜底),避免每次重画卡片抖位置

真机抓到并修掉两个真 bug:
1. 户号 0 被当成"没分户":服务端 unit_of 从 0 编号,端上判 > 0
   ⇒ 我与妻子被拆两户(真机 Δx=680px)。修:布局层 >= 0,解析层缺失给 -1,
   不把任何数值当哨兵。
2. 线没连对(用户原话「主要是线没连对」):buildBloodEdges 用 gen 数值当行号,
   minGen=-1 ⇒ 长辈线从 y=-148 起(屏幕外),整组线串到上一代卡片。
   修:新增 rowIndex(gens, gen),用走标换算而不是数值。

验证:
- 离线布局 29/0(含户号 0、脏数据一户三人、镜像常量与端上源码一致)
- 反向验证 11/0(9 处注入,含"户号 0 当没分户"与"用 gen 数值当行号"两条真 bug 复现)
- 真机几何 15/0、真实数据+屏幕对照 9/0(反解密度 3.125x,残差 0.23/0.32px;
  12 个血缘线端点全部贴在卡片上下沿)
- ArkTS 静态守卫 905/0
This commit is contained in:
2026-10-21 22:18:43 +08:00
parent 67ce443915
commit 13f716c936
5 changed files with 1114 additions and 507 deletions
+127 -272
View File
@@ -168,21 +168,10 @@ function isSubsequence(q: string, s: string): boolean {
/**
* 家谱里的一代(一层)。
*
* ⚠️ 由 `FbKinRing`(旧的"三圈"演示结构)改成"按代数分层" ——
* 因为真实接口 `/api/v1/kin` 给的就是**按代数**的人(`gen` 字段),
* 而"三圈"(直系/其他亲属)是当初照着演示数据设计的分法,
* 与真实数据的形状对不上。
* ⚠️ v73:这个接口连带「列表」视图一起撤掉了 —— 家谱现在只有图一种形态,
* 代数分层由**图谱的纵向位置**表达(长辈在上、晚辈在下),
* 不再需要「这一代叫什么名字」这种中间结构。
*/
interface FbKinGen {
/** 代数:0 = 我,负 = 长辈,正 = 晚辈 */
gen: number;
/** 层名,如「父母辈」 */
title: string;
/** 说明,如「父辈与母辈」 */
sub: string;
/** 这一代的人 */
people: FbKinPerson[];
}
@ComponentV2
export struct FbPeoplePage {
@@ -322,12 +311,10 @@ export struct FbPeoplePage {
/** 家谱的同步提示(与名册分开:两批数据可能一个成功一个失败) */
@Local kinSyncNote: string = '';
/**
* 家谱的显示方式:'graph'(知识图谱,默认)/ 'list'(分层列表)。
*
* 用户要求把它做成知识图谱那样的图,所以默认给图谱。
* 但保留列表:小屏上人多了图会挤,列表能看清每个人的关系摘要。
* ⚠️ v73:原来这里有 `kinMode`('graph' / 'list' 两种视图)。
* 用户 2026-10-21 要求去掉列表、只留图谱 ⇒ 状态本身也不用了。
* 「列表」那套是按代数分组的通讯录,名册页已经有同样的能力。
*/
@Local kinMode: string = 'graph';
/** 我在家谱里的 id(图谱里要标出「我」) */
@Local kinMeId: number = 0;
private repo: FbPersonRepo = FbPersonRepo.inst();
@@ -568,8 +555,8 @@ export struct FbPeoplePage {
*/
private confirmDelete(): void {
/* 🔴 v59e:删除入口从"简要卡"挪到了"详情卡底部" ⇒ 目标人先取 `detailPerson`。
⚠️ 不能只取 `quickSource()`:详情有可能是**从家谱那行直接打开**的
(`kinRow` 的 onClick 直接写 `detailPerson`,`quickPerson` 一直是 null)
⚠️ 不能只取 `quickSource()`:详情有可能是**从家谱图上直接点开**的
(点卡片直接写 `detailPerson`,`quickPerson` 一直是 null)
⇒ 那样点删除会**静默什么都不发生**(`who === null` 就 return 了)。
这正是"按钮点了没反应"最难查的一类:没有报错、没有日志。 */
const who: FbRosterPerson | null =
@@ -1385,17 +1372,55 @@ export struct FbPeoplePage {
* ⚠️ 用 `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;
}
/* 🔴🔴 v73 真 bug:点家谱卡片**静默没反应**。
根因:这里原来只从 `rawById` 取,而 `rawById` 来自 `repo.list()`
—— 那条 SQL 带 `JOIN person_roster`,**只包含"我挑进名册的人"**。
家谱里的人(父母、岳父母、配偶)**不一定在我的名册里** ⇒ 查不到 ⇒ 直接 return。
字面上就是"点了没反应",而且不打日志的话 hilog 一个字都抓不到。
修法分两段(与 `byId()` 的注释同一条思路):
① 名册里有 ⇒ 立刻用本地那份开浮层(不等网络,点开就有内容);
② 名册里没有 ⇒ 先空着把浮层**打开**(`detailPerson` 用一个占位),
再异步 `byId()` / 网络详情补上 —— **不能**因为本地没缓存就不响应点击。
⚠️ 注意 `detailPerson` 是 `FbRosterPerson`,不能塞 `null` 当作"只开框",
所以第 ② 种情况必须先构造一个最小可渲染对象(只有 id + 姓名)。
*/
const cached: FbRosterPerson | undefined = this.rawById.get(id);
hilog.info(DOMAIN, TAG, 'openDetail(%{public}d):名册命中=%{public}s(名册共 %{public}d 人)',
id, (cached !== undefined ? 'yes' : 'no'), this.rawById.size);
this.detailToken += 1;
const my: number = this.detailToken;
if (cached !== undefined) {
this.detailPerson = cached;
} else {
// ② 名册里没有 —— 先用家谱那份拼一个最小对象把浮层打开
const kin: FbKinPerson | undefined = this.kinPeople.find(
(p: FbKinPerson): boolean => p.id === id);
if (kin === undefined) {
hilog.warn(DOMAIN, TAG, 'openDetail(%{public}d):名册与家谱都没有这个人', id);
return;
}
this.detailPerson = placeholderPerson(kin);
}
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
hilog.warn(DOMAIN, TAG, 'openDetail(%{public}d):令牌为空,浮层已开但不取详情', id);
return;
}
// ③ 本地库补一份(家谱那边的人往往不 JOIN roster,这里能补到)
this.repo.byId(id).then((local: FbRosterPerson | null): void => {
if (local === null || my !== this.detailToken) {
return;
}
if (this.detailPerson !== null && this.detailPerson.id === id) {
this.detailPerson = local;
}
});
this.repo.fetchDetail(id, token).then((full: FbRosterPerson | null): void => {
// ① 期间用户已经关了浮层、或又点开别人 ⇒ 丢弃这次结果
if (full === null || my !== this.detailToken) {
@@ -1482,7 +1507,7 @@ export struct FbPeoplePage {
// ============================================================
/**
* 家谱的说明条("以我为中心" + 图谱/列表切换)。
* 家谱的说明条(「以我为中心」+ 人数)。
*
* 🔴 v68:这里原来还有一颗「补关系」按钮,**从来没有 `onTap`** ——
* 点了一点反应都没有(全仓库扫过,只剩这一颗)。
@@ -1491,9 +1516,11 @@ export struct FbPeoplePage {
* ⇒ 与其留一个永远点不动的按钮,先撤掉(免得被当成"坏了")。
* 要做得先加 `POST /api/v1/persons/<id>/relations`,那是独立一批活。
*
* ⚠️ 从 `kinView` 里抽出来的原因:**列表模式**要把它**一起放进滚动的 Scroll**
* (这样它才跟着内容滚走),而**图谱模式**它是固定的一层。
* 抽成一个 Builder,两处引用同一份实现,不会改一处漏一处。
* 🔴 v73(用户 2026-10-21):右侧的「图谱 / 列表」切换**整块撤掉**。
* 原话是"手机端这个家谱实在是很难看",而那个"列表"视图
* 本质就是个按代数分组的通讯录 —— 名册页早就有同样的能力
* (还能搜索、筛选标签),留着它只会让人以为家谱有两种画法。
* 现在家谱只有一种形态:**图**。
*/
@Builder
kinHeader() {
@@ -1509,15 +1536,6 @@ export struct FbPeoplePage {
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 图谱 / 列表 切换
Row({ space: 0 }) {
this.kinModeBtn('图谱', 'graph')
this.kinModeBtn('列表', 'list')
}
.padding(2)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
@@ -1527,13 +1545,8 @@ export struct FbPeoplePage {
@Builder
kinView() {
Column() {
// 🔴 图谱模式**不跟滚**(它是 Canvas 画的一整屏图,本就没有"滚动内容"可言)
// ⇒ 本模式顶栏仍是固定的一层。
// 「列表」模式则把页头移进内层 `Scroll` 一起滚(见下面的分支)。
if (this.kinMode === 'graph') {
this.pageHeaderItem()
this.kinHeader()
}
this.pageHeaderItem()
this.kinHeader()
if (this.loading) {
Text('正在读取本地数据…')
@@ -1549,8 +1562,8 @@ export struct FbPeoplePage {
.textAlign(TextAlign.Center)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} else if (this.kinMode === 'graph') {
// ---- 知识图谱(Canvas 画:圆节点 + 连线)----
} else {
// ---- 家谱图(Canvas 画线 + 原生卡片,见 components/FbKinGraph.ets)----
Column() {
FbKinGraphView({
people: this.kinPeople,
@@ -1563,43 +1576,6 @@ export struct FbPeoplePage {
.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%')
@@ -1609,97 +1585,11 @@ export struct FbPeoplePage {
/**
* 一代人(家谱里的一层)。
*
* ⚠️ 自己居中、长辈在上、晚辈在下 —— 与"从上往下读"的直觉一致。
* 标题写清"这是哪一代"(父母辈 / 我这一辈 / 子女辈),
* 否则用户看到一堆名字不知道是什么关系。
* ⚠️ v73:这个 Builder 与 `kinRow` 一起**已删除** —— 它们只服务
* 「列表」视图,而用户 2026-10-21 要求去掉列表、只留图谱。
* 原来这里按代数分组、每组一个白卡片 + 行分隔线,
* 本质就是个通讯录;名册页已经有同样的能力(还能搜索/筛标签)。
*/
@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;
}
})
}
/** 一圈亲属卡 */
// ============================================================
// 共用小件
@@ -1770,99 +1660,10 @@ export struct FbPeoplePage {
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(' · ') : '暂无亲属关系';
}
/* ⚠️ v73:`kinModeBtn` / `kinGens` / `genTitle` / `genSub` / `relSummary`
五个方法一起删了 —— 它们只为「列表」视图服务,而那个视图已撤。
代数分层的信息现在由**图谱本身的纵向位置**表达(长辈在上、晚辈在下),
不再需要「父母辈 / 我这一辈」这种文字小标题。 */
/** 家谱顶部的人数说明 */
private kinTotalText(): string {
@@ -1877,4 +1678,58 @@ export struct FbPeoplePage {
const hit: FbRosterItem | undefined = this.all.find((p: FbRosterItem): boolean => p.id === id);
return hit !== undefined ? hit.avatarColor : '#A84A28';
}
}
/**
* 用「家谱里的那个人」拼一个**最小可渲染**的 `FbRosterPerson`。
*
* 🔴 为什么需要(v73 真 bug):家谱里的人**不一定在我名册里**
* (`FbPersonRepo.list()` 带 `JOIN person_roster`,只含"我挑进名册的人"),
* 那时点卡片以前是**静默 return ⇒ 点了没反应**。
* 这里先拼一个只够画浮层头部的对象,**让浮层立刻打开**,
* 随后 `repo.byId()` / `fetchDetail()` 到了再原地升级。
*
* ⚠️ 只填家谱真有的那几项,其余给空值 —— 不要瞎猜(填错比空着更糟:
* 用户会以为"系统里有这个资料")。本地库/接口补上来后会被整份替换掉。
*/
function placeholderPerson(k: FbKinPerson): FbRosterPerson {
const p: FbRosterPerson = {
id: k.id,
name: k.name,
shown: k.shown !== '' ? k.shown : k.name,
// 家谱那份的称谓是服务端算好的,这里能用
label: k.label,
tags: [],
avatarColor: k.avatarColor,
avatar: k.avatar,
birthdaySolar: '',
birthdayFull: '',
birthdayLunar: '',
phone: '',
phones: [],
initials: '',
org: '',
title: '',
slogan: '',
email: '',
addrCity: '',
gender: '',
note: '',
isAdmin: false,
addrCountry: '',
addrProvince: '',
addrDistrict: '',
address: '',
birthRank: '',
birthdayLunarLeap: false,
birthdayLunarRaw: '',
contacts: { phone: [], email: [], address: [], url: [] },
jobs: [],
myNote: '',
myNickname: '',
idCardMasked: '',
canEditAll: false,
isSelf: false
};
return p;
}