v74 端上删掉家谱 + 名册页头改「醒目搜索 + 添加」
用户 2026-10-21:「算了,你画不好。这个功能直接从鸿蒙端去掉吧,不要了。 这样名册这个功能,进来以后,上面就是搜索和添加按钮,搜索,做的醒目一点, 现在看不清。家谱和名册的标签,都不要了。家谱的相关功能,都删除。」 家谱链路整条删除(不是隐藏): - 删 common/KinLayout.ets、components/FbKinGraph.ets 两个源文件 - 删 Types 的 FbKinPerson / FbKinGraph / FbKinRelation、FbParse 的 parseKin - 删 FbModuleLoad 路由、FbModuleRepo 模块名、FbSyncAll 预同步清单里的 kin - 删 DemoData 的 FB_DEMO_KIN_RELATIONS、Theme/Settings 的家谱图标映射 - 删死资源 fb_ic_kin.png - Index 的 kin 分支去掉(不再有「名册/家谱」两个模块共用一个页面) - FbPeoplePage:删 subView 分段切换、kinHeader/kinView、loadKin、segBtn; openDetail 不再借家谱字段兜底,改成纯 id 占位 + 异步补全 (保留"本地没缓存也要立刻开浮层",那是 v73 修过的"点了没反应") 名册页头: - 第一行就是搜索 + 添加,上面不再有任何东西 - 搜索框从「看不清」改成醒目:白底 #FFFFFF(原来 inputBg 与页面米色几乎同色 ⇒ 没有边界)、1.5 主题色描边、高度 36→44、图标 15→18 并染主题色、 字号 fontSmall→fontBody、提示词收短成「搜索姓名 / 首字母 / 手机」 验证: - ArkTS 静态守卫 917/0(新增 6 条:家谱五条链路全清 + 搜索框三个具体数值) - 反向验证 154/0(新增 7 条注入全部有效;另修好两条因代码演进失效的旧锚点) - 编译 BUILD SUCCESSFUL + 装机 install bundle successfully - 真机 v74 探针 22/0(高度实测 44.2vp、白底、占屏 66%、同排对齐、 家谱在页面与抽屉里都不存在、搜索输入与清空真的生效) 顺带修掉探针三处假红(都不是应用问题,是量错了节点): - 搜索框在界面树里是两层,TextInput(75px) 只是内部输入区, 白底容器 Row(138px) 才是盒子;只按"有文字/可点"收集会把它漏掉 - uitest uiInput inputText 是 (x,y,text) 三参数,只给文本会静默不输入 - 空态文案是「换个关键词试试…」,按"没有/找到"判永远为假
This commit is contained in:
@@ -1,879 +0,0 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 家谱布局算法(鸿蒙端)
|
||||
*
|
||||
* 🔴 为什么把布局从视图里**抽出来**(v73):
|
||||
* 原来 `FbKinGraph.ets` 里「算位置」和「画」揉在一起(287 行),
|
||||
* 结果位置算法没法单独验证 —— 只能靠肉眼看截图,改一处不知道碰坏了哪。
|
||||
* 现在布局是**纯函数**(输入人 → 输出坐标 + 连线),视图只管照着画。
|
||||
*
|
||||
* ============================================================
|
||||
* 布局语义(与 Web 端 `family-chart` 对齐)
|
||||
* ============================================================
|
||||
* · **横向的单位是「夫妻单元」**,不是「人」——
|
||||
* 这是与原实现最大的差别。原实现按 `gen` 均分横向位置,
|
||||
* 完全没看 `unit`(户号)⇒ **夫妻被拆散**(丈夫和妻子之间隔着别人),
|
||||
* 连线只能斜着穿过去,看起来一团乱。
|
||||
* · **子女挂在「父母单元」的中点下方**(不是挂在父亲或母亲个人下方)——
|
||||
* 这也是 family-chart 的做法:连线从夫妻横线中点垂下来。
|
||||
* · **同代不重叠**:同代 unit 之间至少留 `UNIT_GAP`。
|
||||
*
|
||||
* ============================================================
|
||||
* 为什么用「迭代松弛」而不是一次性算完的 tidy tree
|
||||
* ============================================================
|
||||
* 标准的 tidy tree(Reingold–Tilford)要求结构**是树**。
|
||||
* 但家谱不是树:岳父岳母这一类**姻亲**,他们的子女(妻子)
|
||||
* 已经作为配偶落在「我」的单元里 ——
|
||||
* · 子女要挂父母下方 ⇒ 妻子应该在我岳父岳母下面
|
||||
* · 夫妻要并排 ⇒ 妻子应该在我右边
|
||||
* 这两条**互相打架**,树结构表达不了。
|
||||
*
|
||||
* 松弛(迭代逼近)能容纳冲突约束:两边各让一步,收敛到折中位置。
|
||||
* 11 个节点 × 24 轮,开销可以忽略。
|
||||
*
|
||||
* ⚠️ 迭代必须**确定性**:同样的输入必须给同样的输出,
|
||||
* 否则每次重画(`onAreaChange` 会触发)图就抖一下。
|
||||
* ⇒ 排序的比较函数里永远带上「unit 内最小 id」当最后一级 tie-break,
|
||||
* 绝不依赖 `Array.sort` 的稳定性或遍历顺序。
|
||||
*/
|
||||
|
||||
import { FbKinPerson } from '../model/Types';
|
||||
|
||||
/**
|
||||
* 卡片与间距尺寸(vp)。
|
||||
*
|
||||
* ⚠️ 这些数字与 `FbKinGraph.ets` 里画的卡片**必须一致**:
|
||||
* 布局按它们算间距与连线落点,视图按它们画卡片。
|
||||
* 两边不一致 ⇒ 连线与卡片错位(用户 2026-10-03 在 Web 端踩过这个)。
|
||||
*/
|
||||
export class KinDim {
|
||||
/**
|
||||
* 卡片宽(v73 定稿 62)。
|
||||
*
|
||||
* 🔴 为什么是 62 而不是 70:真机实测(1080px / 3.0x ⇒ 360vp 屏,
|
||||
* 页面左右各 16vp padding ⇒ **可用只有 328vp**)。
|
||||
* 线上这个家庭 4 列(父母辈 4 人 / 我这一辈 4 人),
|
||||
* `70 宽 + 26 缝` 排出来 **358vp** ⇒ 最右边那一列(岳父、妻子)
|
||||
* 被裁到屏幕外,一进家谱就看不全 —— 用户第一眼看到的就是"不完整"。
|
||||
* 收成 `62 宽 + 16 缝` 后 4 列 = 296vp,稳稳落在 328vp 内。
|
||||
* ⚠️ 别为了"再宽松点"把这两个数调回去:一调就又会裁边。
|
||||
*/
|
||||
static readonly CARD_W: number = 62;
|
||||
/** 卡片高(头像 42 + 名字 + 称谓) */
|
||||
static readonly CARD_H: number = 84;
|
||||
/** 夫妻之间的缝(比代间距小得多 ⇒ 一眼看出"这俩是一对") */
|
||||
static readonly SPOUSE_GAP: number = 4;
|
||||
/** 同代不同 unit 之间的缝 */
|
||||
static readonly UNIT_GAP: number = 16;
|
||||
/** 每代的行高(卡片高 + 纵向留白,留给连线拐弯) */
|
||||
static readonly ROW_H: number = 148;
|
||||
/** 内容四周留白 */
|
||||
static readonly PAD_X: number = 8;
|
||||
static readonly PAD_Y: number = 22;
|
||||
/** 迭代轮数(收敛后再多也没变化,24 轮对 11 人绰绰有余) */
|
||||
static readonly ITERS: number = 24;
|
||||
}
|
||||
|
||||
/** 一张卡的位置(**中心**坐标,视图层自己减半宽半高) */
|
||||
export class KinCardPos {
|
||||
p: FbKinPerson;
|
||||
/** 中心 x */
|
||||
x: number = 0;
|
||||
/** 中心 y */
|
||||
y: number = 0;
|
||||
|
||||
constructor(p: FbKinPerson) {
|
||||
this.p = p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条连线(Canvas 直接照着画,不需要再算)。
|
||||
*
|
||||
* 这里把拐点算好(`midY`),视图只负责 `moveTo/lineTo`:
|
||||
* `(x1,y1) → (x1,midY) → (x2,midY) → (x2,y2)`
|
||||
*
|
||||
* 🔴 v73b:画法从「每个子女一条独立折线」改成「**每对父母一条母线**」,
|
||||
* 于是 `blood` 的内涵变了 —— 它现在只画**竖直段**,横段单独是 `bus`:
|
||||
*
|
||||
* · `blood` 主竖线:父母单元中点 → 母线高度(`x1 === x2`,`y2 === midY`)
|
||||
* · `bus` 母线横段:只覆盖这对父母**自己子女**的跨度(`y1 === y2 === midY`)
|
||||
* · `blood` 子女短竖线:母线高度 → 子女卡片上沿(`x1 === x2`,`y1 === midY`)
|
||||
*
|
||||
* ⚠️ 三种都是上面那条折线公式的**退化情形**(要么横段长度为 0、要么竖段为 0),
|
||||
* 所以视图里**不需要为 `bus` 单独写分支**,同一段代码照样画对。
|
||||
*/
|
||||
export class KinEdge {
|
||||
/** 'blood' = 父母→子女的竖直段;'bus' = 母线横段;'spouse' = 夫妻 */
|
||||
kind: string = 'blood';
|
||||
x1: number = 0;
|
||||
y1: number = 0;
|
||||
x2: number = 0;
|
||||
y2: number = 0;
|
||||
/** 折线的拐弯 y(spouse 线不用;bus/blood 都等于母线高度) */
|
||||
midY: number = 0;
|
||||
}
|
||||
|
||||
/** 布局结果 */
|
||||
export class KinLayout {
|
||||
cards: KinCardPos[] = [];
|
||||
edges: KinEdge[] = [];
|
||||
/** 内容包围盒(不含 PAD),视图据此算缩放与初始偏移 */
|
||||
width: number = 0;
|
||||
height: number = 0;
|
||||
/**
|
||||
* 「我」在画布上的位置(已归一化)。
|
||||
*
|
||||
* ⚠️ v73:家谱一打开就要把镜头对准我 —— 但「我」**不一定是几何中心**
|
||||
* (我这一辈里我可能是第二个,上面父母辈还铺开了 4 列)。
|
||||
* 视图拿这个坐标去 `scrollTo`,否则一进家谱看到的是左上角。
|
||||
*/
|
||||
meX: number = -1;
|
||||
meY: number = -1;
|
||||
}
|
||||
|
||||
/** 一个「夫妻单元」(同代内 `unit` 相同的人;`unit` 无效时按单人成组) */
|
||||
class KinUnit {
|
||||
key: string = '';
|
||||
gen: number = 0;
|
||||
members: FbKinPerson[] = [];
|
||||
/** 本单元总宽(成员并排 + 夫妻缝) */
|
||||
width: number = 0;
|
||||
/** 中心 x */
|
||||
x: number = 0;
|
||||
/** 本轮想去的中心 x */
|
||||
target: number = 0;
|
||||
/** 父母所在 unit 的 key(空 = 上方没有本图内的人) */
|
||||
parentKey: string = '';
|
||||
/** 单元内最小 id —— 排序的**最后一级 tie-break**,保证确定性 */
|
||||
minId: number = 0;
|
||||
|
||||
constructor(key: string, gen: number) {
|
||||
this.key = key;
|
||||
this.gen = gen;
|
||||
}
|
||||
}
|
||||
|
||||
/** id → 那个人(数组下标查找,避免 ArkTS 里 Map 的泛型噪音) */
|
||||
class PeopleIndex {
|
||||
private ids: number[] = [];
|
||||
private idx: number[] = [];
|
||||
|
||||
build(people: FbKinPerson[]): void {
|
||||
this.ids = [];
|
||||
this.idx = [];
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
this.ids.push(people[i].id);
|
||||
this.idx.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回下标;找不到 -1 */
|
||||
find(id: number): number {
|
||||
for (let i = 0; i < this.ids.length; i++) {
|
||||
if (this.ids[i] === id) {
|
||||
return this.idx[i];
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:算出一整张家谱的位置与连线。
|
||||
*
|
||||
* @param people 服务端 `/api/v1/kin` 给的人(已带 gen / unit / parents / children / spouses)
|
||||
* @param meId 我(标出中心;也是「父母优先连我自己的父母」的依据)
|
||||
*/
|
||||
export function layoutKin(people: FbKinPerson[], meId: number): KinLayout {
|
||||
const out: KinLayout = new KinLayout();
|
||||
if (people.length === 0) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const index: PeopleIndex = new PeopleIndex();
|
||||
index.build(people);
|
||||
|
||||
// ---- ① 按「代数 + 夫妻单元」分组 ----
|
||||
const units: KinUnit[] = [];
|
||||
const unitOfPerson: number[] = []; // 与 people 等长:每人属于哪个 unit 下标
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
unitOfPerson.push(-1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const p: FbKinPerson = people[i];
|
||||
/* 🔴🔴 v73 真机抓到的真 bug:**户 0 被当成"没分户"** ⇒ 我与妻子被拆成两户。
|
||||
*
|
||||
* 原来这里是 `p.unit > 0 ? 按户 : 单人`,理由是"0 表示没分户"。
|
||||
* 但服务端 `Kinship::familyMap()` 的 `unit_of` 是
|
||||
* `foreach ($units as $i => $u)` **从 0 开始**编号的 ——
|
||||
* 户 0 是**真实存在的一户**(线上实测:户 0 = 杨永全#1 + 洪博为#3,正是我和妻子)。
|
||||
* ⇒ 户 0 被走 `solo` 分支拆成两个单人单元,
|
||||
* 真机上表现为「我」与妻子之间横插了姐姐一家(Δx = 680px 而不是 198px),
|
||||
* 而**离线测试完全看不出来**(Python 镜像也抄了同样的错判)。
|
||||
*
|
||||
* 修法:**不把任何数值当成哨兵**。缺字段/无户号由**解析层**统一成 -1
|
||||
* (见 `FbParse.parseKin`:`unit` 缺失或 null 时给 -1),
|
||||
* 这里只判 `>= 0` 即"有一户"。这样服务端怎么编号都不会再错。
|
||||
* ⚠️ 别改回 `> 0`:那等于把"第 0 户"这颗雷重新埋上。
|
||||
* ⚠️ 也别用 `!== undefined`:`-1` 会漏过去,把全家人并成一户。
|
||||
*/
|
||||
const key: string = p.unit >= 0 ? (p.gen.toString() + ':' + p.unit.toString())
|
||||
: (p.gen.toString() + ':solo:' + p.id.toString());
|
||||
|
||||
let hit: number = -1;
|
||||
for (let u = 0; u < units.length; u++) {
|
||||
if (units[u].key === key) {
|
||||
hit = u;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit < 0) {
|
||||
const nu: KinUnit = new KinUnit(key, p.gen);
|
||||
nu.minId = p.id;
|
||||
units.push(nu);
|
||||
hit = units.length - 1;
|
||||
} else if (p.id < units[hit].minId) {
|
||||
units[hit].minId = p.id;
|
||||
}
|
||||
units[hit].members.push(p);
|
||||
unitOfPerson[i] = hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元内排序:把成员按**配偶链**串成一排。
|
||||
*
|
||||
* 🔴 为什么不是简单"我在最前、其余按 id"(v73 第一版就是那样,本地验出来了):
|
||||
* 服务端的 `unit`(夫妻单元号)是算出来的,正常一户 2 人;
|
||||
* 但数据脏的时候(重复录入、先录了"妻子"又录了"配偶")**一户可能 3 人**。
|
||||
* 那时按 id 排会让"我"排在最左、真配偶被挤到第三位,
|
||||
* 中间隔着另一个人 ⇒ **夫妻不再相邻**,连线横穿第三个人。
|
||||
* 按配偶链排就能保证:只要两人登记了夫妻关系,他们在卡片上一定挨着。
|
||||
*
|
||||
* 起点选择:优先从「我」开始;否则从链头(没有本户内配偶的那个人)开始。
|
||||
*/
|
||||
for (let u = 0; u < units.length; u++) {
|
||||
const ms: FbKinPerson[] = units[u].members;
|
||||
units[u].members = orderBySpouseChain(ms, meId);
|
||||
const ordered: FbKinPerson[] = units[u].members;
|
||||
units[u].width = ordered.length * KinDim.CARD_W + (ordered.length - 1) * KinDim.SPOUSE_GAP;
|
||||
}
|
||||
|
||||
// ---- ② 找每个单元的「父母单元」----
|
||||
//
|
||||
// ⚠️ 一个单元可能有多个人、每个人的父母又可能在不同单元
|
||||
// (「我」的父母 vs 「妻子」的父母)。
|
||||
// 规则:**「我」所在的单元优先认我自己的父母** ——
|
||||
// 否则妻子那侧的岳父母会把主树带偏(整张图看起来像"围着岳父家转")。
|
||||
for (let u = 0; u < units.length; u++) {
|
||||
units[u].parentKey = '';
|
||||
}
|
||||
for (let u = 0; u < units.length; u++) {
|
||||
const unit: KinUnit = units[u];
|
||||
const votes: string[] = [];
|
||||
let hasMe: boolean = false;
|
||||
|
||||
for (let m = 0; m < unit.members.length; m++) {
|
||||
const p: FbKinPerson = unit.members[m];
|
||||
if (p.id === meId) {
|
||||
hasMe = true;
|
||||
}
|
||||
for (let k = 0; k < p.parents.length; k++) {
|
||||
const pi: number = index.find(p.parents[k]);
|
||||
if (pi < 0) {
|
||||
continue;
|
||||
}
|
||||
const pu: number = unitOfPerson[pi];
|
||||
if (pu === u) {
|
||||
continue; // 同单元(夫妻互称父母)跳过
|
||||
}
|
||||
const puGen: number = units[pu].gen;
|
||||
if (puGen >= unit.gen) {
|
||||
continue; // 只认**上一代**,防止把子孙当父母
|
||||
}
|
||||
votes.push(units[pu].key);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMe) {
|
||||
// 「我」在的单元:只认我自己的父母(前面已经混进配偶父母了,这里重算)
|
||||
let own: string = '';
|
||||
const meIdx: number = index.find(meId);
|
||||
if (meIdx >= 0) {
|
||||
const me: FbKinPerson = people[meIdx];
|
||||
for (let k = 0; k < me.parents.length; k++) {
|
||||
const pi: number = index.find(me.parents[k]);
|
||||
if (pi < 0) {
|
||||
continue;
|
||||
}
|
||||
const pu: number = unitOfPerson[pi];
|
||||
if (pu >= 0 && units[pu].gen < unit.gen) {
|
||||
own = units[pu].key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
unit.parentKey = own;
|
||||
} else if (votes.length > 0) {
|
||||
// 其余单元:取出现次数最多的(平票时取 key 字典序最小 —— 确定性)
|
||||
votes.sort((a: string, b: string): number => a < b ? -1 : (a > b ? 1 : 0));
|
||||
let best: string = votes[0];
|
||||
let bestN: number = 0;
|
||||
let i: number = 0;
|
||||
while (i < votes.length) {
|
||||
let j: number = i;
|
||||
while (j < votes.length && votes[j] === votes[i]) {
|
||||
j++;
|
||||
}
|
||||
const cnt: number = j - i;
|
||||
if (cnt > bestN) {
|
||||
bestN = cnt;
|
||||
best = votes[i];
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
unit.parentKey = best;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ③ 初始排布:每代内按 (单元序号) 均分 ----
|
||||
const gens: number[] = [];
|
||||
for (let u = 0; u < units.length; u++) {
|
||||
if (gens.indexOf(units[u].gen) < 0) {
|
||||
gens.push(units[u].gen);
|
||||
}
|
||||
}
|
||||
gens.sort((a: number, b: number): number => a - b);
|
||||
|
||||
for (let g = 0; g < gens.length; g++) {
|
||||
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||
let total: number = 0;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
total += row[i].width;
|
||||
}
|
||||
total += (row.length - 1) * KinDim.UNIT_GAP;
|
||||
let cursor: number = -total / 2;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
row[i].x = cursor + row[i].width / 2;
|
||||
row[i].target = row[i].x;
|
||||
cursor += row[i].width + KinDim.UNIT_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ④ 迭代松弛 ----
|
||||
//
|
||||
// 每一轮做两件事:
|
||||
// ⓐ 自顶向下:把子女单元拉到「父母单元中点」下方(父母定,子女跟)
|
||||
// ⓑ 自底向上:把父母单元拉到「子女们的平均位置」(子女定,父母跟)
|
||||
// 交替进行,让冲突约束(夫妻并排 vs 子女挂父母下方)互相让步。
|
||||
//
|
||||
// ⚠️ 最后一轮**必须自顶向下**:用户看家谱最在意「谁在谁下面」,
|
||||
// 以子女位置收尾才保证「子女确实落在父母正下方」。
|
||||
for (let it = 0; it < KinDim.ITERS; it++) {
|
||||
relaxDown(units, gens);
|
||||
relaxUp(units, gens);
|
||||
}
|
||||
relaxDown(units, gens);
|
||||
|
||||
// ---- ⑤ 转成卡片坐标(纵向按代数排行)----
|
||||
const layout: KinLayout = new KinLayout();
|
||||
for (let g = 0; g < gens.length; g++) {
|
||||
const cy: number = g * KinDim.ROW_H;
|
||||
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||
for (let u = 0; u < row.length; u++) {
|
||||
const unit: KinUnit = row[u];
|
||||
let cx: number = unit.x - unit.width / 2 + KinDim.CARD_W / 2;
|
||||
for (let m = 0; m < unit.members.length; m++) {
|
||||
const pos: KinCardPos = new KinCardPos(unit.members[m]);
|
||||
pos.x = cx;
|
||||
pos.y = cy;
|
||||
layout.cards.push(pos);
|
||||
cx += KinDim.CARD_W + KinDim.SPOUSE_GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ⑥ 连线 ----
|
||||
// ⚠️ 顺序:先算血缘(配偶线在视图里画得更粗,不该被血缘盖住)
|
||||
buildBloodEdges(layout, people, index, unitOfPerson, units, gens);
|
||||
buildSpouseEdges(layout, people, index);
|
||||
|
||||
// ---- ⑦ 包围盒(居中用)----
|
||||
let minX: number = 1e9;
|
||||
let maxX: number = -1e9;
|
||||
let minY: number = 1e9;
|
||||
let maxY: number = -1e9;
|
||||
for (let i = 0; i < layout.cards.length; i++) {
|
||||
const c: KinCardPos = layout.cards[i];
|
||||
if (c.x < minX) {
|
||||
minX = c.x;
|
||||
}
|
||||
if (c.x > maxX) {
|
||||
maxX = c.x;
|
||||
}
|
||||
if (c.y < minY) {
|
||||
minY = c.y;
|
||||
}
|
||||
if (c.y > maxY) {
|
||||
maxY = c.y;
|
||||
}
|
||||
}
|
||||
layout.width = maxX - minX + KinDim.CARD_W;
|
||||
layout.height = maxY - minY + KinDim.CARD_H;
|
||||
|
||||
// 平移成「左上角为 (PAD, PAD)」的坐标,视图不用再关心负坐标
|
||||
const dx: number = -minX + KinDim.CARD_W / 2 + KinDim.PAD_X;
|
||||
const dy: number = -minY + KinDim.CARD_H / 2 + KinDim.PAD_Y;
|
||||
for (let i = 0; i < layout.cards.length; i++) {
|
||||
layout.cards[i].x += dx;
|
||||
layout.cards[i].y += dy;
|
||||
}
|
||||
for (let i = 0; i < layout.edges.length; i++) {
|
||||
layout.edges[i].x1 += dx;
|
||||
layout.edges[i].y1 += dy;
|
||||
layout.edges[i].x2 += dx;
|
||||
layout.edges[i].y2 += dy;
|
||||
layout.edges[i].midY += dy;
|
||||
}
|
||||
layout.width += KinDim.PAD_X * 2;
|
||||
layout.height += KinDim.PAD_Y * 2;
|
||||
|
||||
// 「我」的坐标(视图据此把镜头对准我)
|
||||
const mePos: KinCardPos | undefined = cardOf(layout, meId);
|
||||
if (mePos !== undefined) {
|
||||
layout.meX = mePos.x;
|
||||
layout.meY = mePos.y;
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把同一个「户」里的人按**配偶链**串成一排。
|
||||
*
|
||||
* 规则:从起点出发,每次挑「与已排到最后的那个人是配偶」的成员;
|
||||
* 挑不到就挑「与已排到第一个人是配偶」的(往左延伸);
|
||||
* 都没有就按 id 取下一个(不相干的人)。
|
||||
*
|
||||
* ⚠️ 起点优先「我」:我在最左、配偶在右,符合「从自己往右读」的直觉。
|
||||
* ⚠️ 全程用 `indexOf` 判重、按 id 定序 —— 保证**确定性**
|
||||
* (同样的输入每次给同样的排列,否则重画时卡片会跳位置)。
|
||||
*/
|
||||
function orderBySpouseChain(members: FbKinPerson[], meId: number): FbKinPerson[] {
|
||||
const rest: FbKinPerson[] = [];
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
rest.push(members[i]);
|
||||
}
|
||||
// 起点:我 → 否则 id 最小的
|
||||
rest.sort((a: FbKinPerson, b: FbKinPerson): number => {
|
||||
if (a.id === meId) {
|
||||
return -1;
|
||||
}
|
||||
if (b.id === meId) {
|
||||
return 1;
|
||||
}
|
||||
return a.id - b.id;
|
||||
});
|
||||
const out: FbKinPerson[] = [];
|
||||
if (rest.length === 0) {
|
||||
return out;
|
||||
}
|
||||
out.push(rest[0]);
|
||||
rest.shift();
|
||||
|
||||
while (rest.length > 0) {
|
||||
const tail: FbKinPerson = out[out.length - 1];
|
||||
let pick: number = -1;
|
||||
// ① 先找 tail 的配偶
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (tail.spouses.indexOf(rest[i].id) >= 0) {
|
||||
pick = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// ② 再找 head 的配偶(往左边继续串)
|
||||
if (pick < 0) {
|
||||
const head: FbKinPerson = out[0];
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (head.spouses.indexOf(rest[i].id) >= 0) {
|
||||
pick = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pick >= 0) {
|
||||
out.unshift(rest[pick]);
|
||||
rest.splice(pick, 1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// ③ 都不是配偶 ⇒ 按 id 取下一个(数据里不相干的人)
|
||||
if (pick < 0) {
|
||||
pick = 0;
|
||||
}
|
||||
out.push(rest[pick]);
|
||||
rest.splice(pick, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 取某代的所有单元 */
|
||||
function rowOf(units: KinUnit[], gen: number): KinUnit[] {
|
||||
const out: KinUnit[] = [];
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
if (units[i].gen === gen) {
|
||||
out.push(units[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 按 (target, minId) 排序 —— minId 是**确定性**的最后一级 */
|
||||
function sortByTarget(row: KinUnit[]): void {
|
||||
row.sort((a: KinUnit, b: KinUnit): number => {
|
||||
if (a.target !== b.target) {
|
||||
return a.target - b.target;
|
||||
}
|
||||
return a.minId - b.minId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 贪心排布:按顺序尽量靠近各自 `target`,但**不许重叠**。
|
||||
*
|
||||
* ⚠️ 不用"按 target 直接赋值":那会让同代的单元叠在一起
|
||||
* (家谱里同代人多时,父母中心往往挤在很小的一段)。
|
||||
*/
|
||||
function arrange(row: KinUnit[]): void {
|
||||
let cursor: number = -1e9;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const u: KinUnit = row[i];
|
||||
const wantLeft: number = u.target - u.width / 2;
|
||||
const left: number = wantLeft > cursor ? wantLeft : cursor;
|
||||
u.x = left + u.width / 2;
|
||||
cursor = left + u.width + KinDim.UNIT_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
/** 自顶向下:子女单元跟着父母单元的中点走 */
|
||||
function relaxDown(units: KinUnit[], gens: number[]): void {
|
||||
for (let g = 0; g < gens.length; g++) {
|
||||
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const u: KinUnit = row[i];
|
||||
if (u.parentKey !== '') {
|
||||
const pi: number = indexOfUnit(units, u.parentKey);
|
||||
if (pi >= 0) {
|
||||
u.target = units[pi].x;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 没有上一代(如岳父岳母):贴到「自己子女那侧」的位置。
|
||||
// ⚠️ 不能简单保持不动 —— 否则它和主树的关系会随时间漂到莫名其妙的地方。
|
||||
u.target = u.x;
|
||||
}
|
||||
sortByTarget(row);
|
||||
arrange(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** 自底向上:父母单元跟着子女单元的平均位置走 */
|
||||
function relaxUp(units: KinUnit[], gens: number[]): void {
|
||||
for (let g = gens.length - 1; g >= 0; g--) {
|
||||
const row: KinUnit[] = rowOf(units, gens[g]);
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const u: KinUnit = row[i];
|
||||
let sum: number = 0;
|
||||
let n: number = 0;
|
||||
for (let k = 0; k < units.length; k++) {
|
||||
if (units[k].parentKey === u.key) {
|
||||
sum += units[k].x;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
u.target = n > 0 ? sum / n : u.x;
|
||||
}
|
||||
sortByTarget(row);
|
||||
arrange(row);
|
||||
}
|
||||
}
|
||||
|
||||
function indexOfUnit(units: KinUnit[], key: string): number {
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
if (units[i].key === key) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `gen` 的**数值** → 卡片所在的**行下标**。
|
||||
*
|
||||
* 🔴🔴 v73 真机 bug:**线没连对**(用户 2026-10-21:「主要是线没连对」)。
|
||||
*
|
||||
* 卡片 y 用的是 `第几行`(`g = 0,1,2…`,见 ⑤ 里 `const cy = g * KinDim.ROW_H`),
|
||||
* 而这里原来直接用 `gen` 的**数值**当行号(`units[pu].gen * KinDim.ROW_H`)。
|
||||
* `gen` 的数值是相对我的代数(-1 = 长辈、0 = 我、+1 = 晚辈),
|
||||
* 它**不等于**行下标 —— 只有当 `minGen === 0` 时两者才碰巧相等。
|
||||
* 本家 `minGen = -1` ⇒ 连线的 y 整体**少算一行**:
|
||||
* 长辈线从 y=−148 起(在屏幕外),子女线的落点落在"我这一辈"卡片上,
|
||||
* 结果是线全部串到上一个人身上、与卡片错位。
|
||||
* ⇒ 唯一正确的换算是 `gens.indexOf(gen)`。
|
||||
*
|
||||
* ⚠️ 别用 `gen - minGen` 代替:`gens` 是**去重排序后的实际存在的行**,
|
||||
* 缺代(比如没有 -2 那一代)时 `gen - minGen` 会算出空行、线还是错。
|
||||
*/
|
||||
function rowIndex(gens: number[], gen: number): number {
|
||||
const i: number = gens.indexOf(gen);
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
/**
|
||||
* 血缘连线:**每对父母一条母线**。
|
||||
*
|
||||
* ============================================================
|
||||
* 🔴🔴 v73b:为什么不是「每个子女一条折线」(用户 2026-10-21:「连线还是乱七八糟的」)
|
||||
* ============================================================
|
||||
* 旧画法(v73 初版):每个子女各画一条 `父母中点 → 下 → 横移 → 下 → 子女` 的折线。
|
||||
* 病在真实数据上看得最清楚:
|
||||
*
|
||||
* 线A「岳父母(x=216) → 我这一户(x=72)」横段 216→72,拐点 y=132.9
|
||||
* 线B「我父母(x=72) → 姐这一户(x=216)」横段 72→216,拐点 y=132.9
|
||||
*
|
||||
* ⇒ 两条横段**完全重合**、拐点同高,画出来是一个「口」字框:
|
||||
*
|
||||
* 父母(72) 岳父母(216)
|
||||
* | |
|
||||
* +-----------------+ ← 两条线的横段叠在一起
|
||||
* | |
|
||||
* 我(72) 姐(216)
|
||||
*
|
||||
* 看上去就像"父母家和岳父母家连在一起、我和姐都挂在上面" —— 这正是用户说的
|
||||
* **「乱七八糟」**。而且 6 条线**全部**用同一个 0.42 拐点比例 ⇒ 所有横线挤在
|
||||
* 同一高度、糊成一片。
|
||||
*
|
||||
* ⚠️ 注意这类病**端点断言完全抓不到**(每个端点都好好地贴在卡片边缘上),
|
||||
* 所以离线套件里专门加了一条「没有任何两条线段完全重合」的断言
|
||||
* (见 `_fb_kin_layout_v73.py` ⑤b-2),否则"全绿但画面错"会一直重演。
|
||||
*
|
||||
* 新画法(家谱通行画法)——同一对父母的**所有子女共用一条母线**:
|
||||
*
|
||||
* 父母单元中点 ──竖──→ 母线高度 ──横(只覆盖自己子女跨度)──→ 各子女 ──竖──→ 子女顶端
|
||||
*
|
||||
* · 一条线只服务一对父母 ⇒ 不会再和别的父母的线交叉/重叠
|
||||
* · 横段只跨「自己子女」的范围,不会拉到别人头顶
|
||||
* · 每个子女只从母线上垂一根短竖线
|
||||
*/
|
||||
function buildBloodEdges(layout: KinLayout, people: FbKinPerson[],
|
||||
index: PeopleIndex, unitOfPerson: number[], units: KinUnit[],
|
||||
gens: number[]): void {
|
||||
// ---- ① 先把「父母单元 → 它的子女(**人**)」聚起来 ----
|
||||
//
|
||||
// 🔴🔴 v73b 关键修正:挂点必须是**子女本人那张卡**,不是"子女所在户的中点"。
|
||||
//
|
||||
// 病根(真实数据实测):
|
||||
// 我这一户同时是**两对父母**的子女户 ——
|
||||
// · 我父母(户 -1:1)的子女:我的户、老姐的户
|
||||
// · 岳父母(户 -1:4)的子女:我的户 ← 因为妻子在那户里
|
||||
// 旧代码给"子女户"画竖线时用**户中点**(我这户 = 72)
|
||||
// ⇒ 两对父母的竖线**都落在 x=72**、y 区间也相同(都到下一行上沿)
|
||||
// ⇒ 画出来是两条**完全重合**的线,像"我父母和岳父母接到了同一个人"。
|
||||
// 正确语义:妻子是岳父母的女儿,竖线该落在**妻子那张卡**(x=105);
|
||||
// 我是我父母的儿子,竖线落在**我那张卡**(x=39)。
|
||||
//
|
||||
// ⇒ 按「人」记录:父母户 key → 子女本人 id 列表。挂点用该人的卡片 x,
|
||||
// 天然把同一户里的不同血缘分开。
|
||||
const kidsOf: Map<string, number[]> = new Map<string, number[]>();
|
||||
const parentKeys: string[] = []; // 保序(Map 迭代序在 ArkTS 里不可依赖)
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const child: FbKinPerson = people[i];
|
||||
if (cardOf(layout, child.id) === undefined) {
|
||||
continue;
|
||||
}
|
||||
let pid: number = 0;
|
||||
for (let k = 0; k < child.parents.length; k++) {
|
||||
if (index.find(child.parents[k]) >= 0) {
|
||||
pid = child.parents[k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pid === 0) {
|
||||
continue;
|
||||
}
|
||||
const pi: number = index.find(pid);
|
||||
if (pi < 0) {
|
||||
continue;
|
||||
}
|
||||
const pu: number = unitOfPerson[pi];
|
||||
const cu: number = unitOfPerson[i];
|
||||
if (pu < 0 || cu < 0) {
|
||||
continue;
|
||||
}
|
||||
const pkey: string = units[pu].key;
|
||||
if (pkey === units[cu].key) {
|
||||
continue; // 同单元(夫妻互称父母)跳过
|
||||
}
|
||||
let list: number[] | undefined = kidsOf.get(pkey);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
kidsOf.set(pkey, list);
|
||||
parentKeys.push(pkey);
|
||||
}
|
||||
if (list.indexOf(child.id) < 0) {
|
||||
list.push(child.id); // 去重(同一人可能登记了多个 parent)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ② 每对父母画一组:主竖线 + 母线横段 + 各子女短竖线 ----
|
||||
for (let pi = 0; pi < parentKeys.length; pi++) {
|
||||
const pkey: string = parentKeys[pi];
|
||||
const pu: number = indexOfUnit(units, pkey);
|
||||
if (pu < 0) {
|
||||
continue;
|
||||
}
|
||||
// 🔴 行号必须用 `rowIndex`(gens 下标),**不能**用 gen 数值 ——
|
||||
// 真机 bug「线没连对」就是这里(见 `rowIndex` 的注释)。
|
||||
const pgi: number = rowIndex(gens, units[pu].gen);
|
||||
const y1: number = pgi * KinDim.ROW_H + KinDim.CARD_H / 2;
|
||||
const px: number = units[pu].x;
|
||||
|
||||
// 子女只保留在**下一代**的:脏数据可能把同代/上一代也登记成子女
|
||||
const raw: number[] | undefined = kidsOf.get(pkey);
|
||||
if (raw === undefined) {
|
||||
continue;
|
||||
}
|
||||
const kidXs: number[] = [];
|
||||
const kidIds: number[] = [];
|
||||
for (let k = 0; k < raw.length; k++) {
|
||||
const kid: number = raw[k];
|
||||
const kidCard: KinCardPos | undefined = cardOf(layout, kid);
|
||||
if (kidCard === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (rowIndex(gens, kidCard.p.gen) <= pgi) {
|
||||
continue;
|
||||
}
|
||||
kidIds.push(kid);
|
||||
kidXs.push(kidCard.x);
|
||||
}
|
||||
if (kidIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 按卡片 x 排序(母线从左往右画,不来回折)
|
||||
const order: number[] = [];
|
||||
for (let k = 0; k < kidIds.length; k++) {
|
||||
order.push(k);
|
||||
}
|
||||
order.sort((a: number, b: number): number => kidXs[a] - kidXs[b]);
|
||||
|
||||
let left: number = 1e9;
|
||||
let right: number = -1e9;
|
||||
let nearestRow: number = 1e9;
|
||||
for (let k = 0; k < order.length; k++) {
|
||||
const x: number = kidXs[order[k]];
|
||||
if (x < left) {
|
||||
left = x;
|
||||
}
|
||||
if (x > right) {
|
||||
right = x;
|
||||
}
|
||||
const kidCard2: KinCardPos | undefined = cardOf(layout, kidIds[order[k]]);
|
||||
if (kidCard2 !== undefined) {
|
||||
const r: number = rowIndex(gens, kidCard2.p.gen);
|
||||
if (r < nearestRow) {
|
||||
nearestRow = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 母线高度:落在父母下沿与**最近那代子女**上沿之间,靠子女一侧(分叉更明显)
|
||||
const yChildTop: number = nearestRow * KinDim.ROW_H - KinDim.CARD_H / 2;
|
||||
const busY: number = y1 + (yChildTop - y1) * 0.62;
|
||||
|
||||
// 主竖线:父母单元中点 → 母线高度
|
||||
const stem: KinEdge = new KinEdge();
|
||||
stem.kind = 'blood';
|
||||
stem.x1 = px;
|
||||
stem.y1 = y1;
|
||||
stem.x2 = px;
|
||||
stem.y2 = busY;
|
||||
stem.midY = busY;
|
||||
layout.edges.push(stem);
|
||||
|
||||
// 母线横段:只有一个子女时省掉(否则是零长线,白占一条)
|
||||
if (right - left > 0.5) {
|
||||
const bus: KinEdge = new KinEdge();
|
||||
bus.kind = 'bus';
|
||||
bus.x1 = left;
|
||||
bus.y1 = busY;
|
||||
bus.x2 = right;
|
||||
bus.y2 = busY;
|
||||
bus.midY = busY;
|
||||
layout.edges.push(bus);
|
||||
}
|
||||
|
||||
// 各子女:从母线上垂一根短竖线到**自己那张卡**的上沿
|
||||
for (let k = 0; k < order.length; k++) {
|
||||
const kidCard3: KinCardPos | undefined = cardOf(layout, kidIds[order[k]]);
|
||||
if (kidCard3 === undefined) {
|
||||
continue;
|
||||
}
|
||||
const drop: KinEdge = new KinEdge();
|
||||
drop.kind = 'blood';
|
||||
drop.x1 = kidCard3.x;
|
||||
drop.y1 = busY;
|
||||
drop.x2 = kidCard3.x;
|
||||
drop.y2 = rowIndex(gens, kidCard3.p.gen) * KinDim.ROW_H - KinDim.CARD_H / 2;
|
||||
drop.midY = busY;
|
||||
layout.edges.push(drop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 夫妻连线:两人卡片之间的一小段横线 */
|
||||
function buildSpouseEdges(layout: KinLayout, people: FbKinPerson[], index: PeopleIndex): void {
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const p: FbKinPerson = people[i];
|
||||
for (let k = 0; k < p.spouses.length; k++) {
|
||||
const sid: number = p.spouses[k];
|
||||
// ⚠️ 只画一次(否则同一对画两条,线宽叠加看起来发黑)
|
||||
if (sid < p.id) {
|
||||
continue;
|
||||
}
|
||||
if (index.find(sid) < 0) {
|
||||
continue;
|
||||
}
|
||||
const a: KinCardPos | undefined = cardOf(layout, p.id);
|
||||
const b: KinCardPos | undefined = cardOf(layout, sid);
|
||||
if (a === undefined || b === undefined) {
|
||||
continue;
|
||||
}
|
||||
const e: KinEdge = new KinEdge();
|
||||
e.kind = 'spouse';
|
||||
// 左的人在左、右的人在右(夫妻并排时 a 可能已经偏右)
|
||||
if (a.x <= b.x) {
|
||||
e.x1 = a.x + KinDim.CARD_W / 2;
|
||||
e.y1 = a.y;
|
||||
e.x2 = b.x - KinDim.CARD_W / 2;
|
||||
e.y2 = b.y;
|
||||
} else {
|
||||
e.x1 = b.x + KinDim.CARD_W / 2;
|
||||
e.y1 = b.y;
|
||||
e.x2 = a.x - KinDim.CARD_W / 2;
|
||||
e.y2 = a.y;
|
||||
}
|
||||
e.midY = e.y1;
|
||||
layout.edges.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 id 找卡片位置 */
|
||||
export function cardOf(layout: KinLayout, id: number): KinCardPos | undefined {
|
||||
for (let i = 0; i < layout.cards.length; i++) {
|
||||
if (layout.cards[i].p.id === id) {
|
||||
return layout.cards[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -321,7 +321,6 @@ export class FbIcon {
|
||||
function fbIconOf(name: string): Resource {
|
||||
switch (name) {
|
||||
case 'fb_ic_home': return $r('app.media.fb_ic_home');
|
||||
case 'fb_ic_kin': return $r('app.media.fb_ic_kin');
|
||||
case 'fb_ic_people': return $r('app.media.fb_ic_people');
|
||||
case 'fb_ic_movies': return $r('app.media.fb_ic_movies');
|
||||
case 'fb_ic_trips': return $r('app.media.fb_ic_trips');
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
|
||||
*
|
||||
* ============================================================
|
||||
* 🔴 v73:从「Canvas 全手绘」改成「Canvas 只画线 + 原生卡片」
|
||||
* ============================================================
|
||||
* 用户 2026-10-21 原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」
|
||||
*
|
||||
* 为什么不能再全用 Canvas(原实现 287 行,只有彩色圆点 + 首字):
|
||||
* · Canvas **没有原生字体**:只能写 `'12px sans-serif'` 这种泛字体,
|
||||
* 中文渲染出来又平又硬,和 App 其他地方的排版完全两个味道;
|
||||
* · Canvas **没有圆角/阴影/图片裁切**:卡片做不出「纸白 + 细边 + 微投影」,
|
||||
* 只能退化成纯色圆点 —— 这就是用户说的"难看";
|
||||
* · Canvas **不是响应式的**:数据一变要自己重画,容易漏。
|
||||
* ⇒ 现在:**卡片用原生 `Column`/`Text`/`FbAvatar`**,
|
||||
* 字体、圆角、阴影、头像裁切全部走 App 同一套,视觉自然统一。
|
||||
*
|
||||
* ⚠️ 那为什么连线还留在 Canvas 上,不用 `Path` 组件?
|
||||
* `Path.commands` 的坐标单位是 **px**(官方文档明确写明),
|
||||
* 而卡片定位的 `.position()` 单位是 **vp**。
|
||||
* 两套单位混用,在高分屏(本机 3.0x)上线会缩到 1/3、与卡片完全错位。
|
||||
* Canvas 的绘制坐标系**也是 vp**(与 `.position()` 一致,原实现已验证),
|
||||
* 所以「Canvas 画线 + 原生卡片绝对定位」是唯一单位自洽的组合。
|
||||
*
|
||||
* 布局算法**不在这个文件里** —— 见 `common/KinLayout.ets`(纯函数、可单独验证)。
|
||||
*
|
||||
* ⚠️ 不做双指缩放:`scale()` 是位图缩放,卡片里的中文会糊;
|
||||
* 内容超出一屏时用 `Scroll` 双向滚动 —— 更符合手机的肌肉记忆。
|
||||
*/
|
||||
|
||||
import { FbAvatar } from './Widgets';
|
||||
import { KinCardPos, KinDim, KinEdge, KinLayout, layoutKin } from '../common/KinLayout';
|
||||
import { FbColors, FbMetric } from '../common/Theme';
|
||||
import { FbKinPerson } from '../model/Types';
|
||||
import { fbImageSrc } from '../data/FbAttachQueue';
|
||||
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbKinGraphView {
|
||||
/** 家谱里的人 */
|
||||
@Param people: FbKinPerson[] = [];
|
||||
/** 我(居中显示、加粗) */
|
||||
@Param meId: number = 0;
|
||||
/** 点某个节点(弹详情) */
|
||||
@Event onPick: (id: number) => void = (id: number): void => {
|
||||
};
|
||||
|
||||
/** 算好的布局 */
|
||||
@Local layout: KinLayout = new KinLayout();
|
||||
/** 内容尺寸(`Scroll` 里的子层要有确定宽高,否则滚不动) */
|
||||
@Local contentW: number = 0;
|
||||
@Local contentH: number = 0;
|
||||
/** 视口宽度(内容比视口窄时居中) */
|
||||
@Local viewW: number = 0;
|
||||
/** 视口高度(判断"要不要滚") */
|
||||
@Local viewH: number = 0;
|
||||
/** 内容几何哈希:布局一变就要**重画 Canvas**(见 `drawEdges` 的说明) */
|
||||
@Local geoStamp: string = '';
|
||||
|
||||
/** 滚动容器(要能"一进来就对准我") */
|
||||
private scroller: Scroller = new Scroller();
|
||||
/** 已经自动对准过一次(避免用户手动滚动后被拉回去) */
|
||||
private centered: boolean = false;
|
||||
|
||||
/** Canvas 上下文(**必须在组件里创建**,不能跨组件共享) */
|
||||
private settings: RenderingContextSettings = new RenderingContextSettings(true);
|
||||
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* `@Param` 变化时重算。
|
||||
*
|
||||
* ⚠️ 用 `@Monitor` 而**不是**在 `build()` 里算:`build()` 每次重渲染都跑,
|
||||
* 在那里算布局等于每帧做一遍「11 人 × 24 轮松弛」。
|
||||
*/
|
||||
@Monitor('people', 'meId')
|
||||
onDataChange(): void {
|
||||
// 数据换了 ⇒ 允许重新对准我(否则同步刷新后镜头还停在上一个人的位置)
|
||||
this.centered = false;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.layout = layoutKin(this.people, this.meId);
|
||||
// `+ 2`:盖住描边的半像素溢出,否则最右侧卡片的边会被裁掉一条
|
||||
this.contentW = this.layout.width + 2;
|
||||
this.contentH = this.layout.height + 2;
|
||||
// 🔴 几何指纹:Canvas **不是响应式的**,布局变了必须自己重画。
|
||||
// 只靠 `onReady`(首帧一次)是不够的 —— 首帧时 `people` 往往还是空的,
|
||||
// 等数据到了布局才变,那时连线会**留在旧位置**(卡片动了线没动)。
|
||||
// 这里把每张卡的坐标拼成一个串,变了就触发重画。
|
||||
this.geoStamp = this.stampOf();
|
||||
}
|
||||
|
||||
/** 内容几何指纹(卡片坐标 + 线数) */
|
||||
private stampOf(): string {
|
||||
let s: string = '';
|
||||
for (const c of this.layout.cards) {
|
||||
s += c.p.id.toString() + ':' + c.x.toFixed(1) + ',' + c.y.toFixed(1) + ';';
|
||||
}
|
||||
return s + '|' + this.layout.edges.length.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把镜头对准「我」。
|
||||
*
|
||||
* ⚠️ 只在**第一次**做,且内容超出视口才需要滚 ——
|
||||
* 内容比视口窄时已经居中(见 `offsetX`),再滚反而会滚出空白。
|
||||
*/
|
||||
private centerOnMe(): void {
|
||||
if (this.centered || this.viewW <= 0 || this.viewH <= 0) {
|
||||
return;
|
||||
}
|
||||
if (this.layout.meX < 0) {
|
||||
return;
|
||||
}
|
||||
this.centered = true;
|
||||
const needX: boolean = this.contentW > this.viewW;
|
||||
const needY: boolean = this.contentH > this.viewH;
|
||||
if (!needX && !needY) {
|
||||
return;
|
||||
}
|
||||
this.scroller.scrollTo({
|
||||
xOffset: needX ? Math.max(0, this.layout.meX - this.viewW / 2) : 0,
|
||||
yOffset: needY ? Math.max(0, this.layout.meY - this.viewH / 2) : 0,
|
||||
animation: { duration: 260, curve: Curve.EaseOut }
|
||||
});
|
||||
}
|
||||
|
||||
/** 内容比视口窄 ⇒ 左右居中;否则从左边开始(可滚) */
|
||||
private offsetX(): number {
|
||||
if (this.contentW >= this.viewW) {
|
||||
return 0;
|
||||
}
|
||||
return (this.viewW - this.contentW) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 几何变了就补一次重画。
|
||||
*
|
||||
* 🔴 为什么光有 `onAreaChange` 不够:它只在**宽高变化**时触发。
|
||||
* 而布局变了、总宽高却没变的情况是真实存在的 ——
|
||||
* 比如两个人互换了位置、或者某个单元整体挪了 20vp。
|
||||
* 那时 Canvas 里留的是**旧坐标的线**,卡片却在**新位置** ⇒ 线穿到卡片外面。
|
||||
* `geoStamp` 是坐标指纹,它变就一定重画。
|
||||
*/
|
||||
@Monitor('geoStamp')
|
||||
onGeoChange(): void {
|
||||
// 推到下一个 tick:此时 `contentW/contentH` 与 Canvas 的布局都已生效
|
||||
setTimeout((): void => {
|
||||
this.drawEdges();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
build() {
|
||||
Scroll(this.scroller) {
|
||||
Stack({ alignContent: Alignment.TopStart }) {
|
||||
// ---- ① 连线层(**在卡片下面**:Canvas 没有 z-index,靠节点顺序)----
|
||||
Canvas(this.ctx)
|
||||
.width(this.contentW)
|
||||
.height(this.contentH)
|
||||
.onReady((): void => {
|
||||
this.drawEdges();
|
||||
this.centerOnMe();
|
||||
})
|
||||
.onAreaChange((_o: Area, _n: Area): void => {
|
||||
this.drawEdges();
|
||||
})
|
||||
// 连线不参与命中:点卡片边缘时不该点到它下面的线
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
|
||||
// ---- ② 卡片层 ----
|
||||
ForEach(this.layout.cards, (c: KinCardPos) => {
|
||||
this.personCard(c)
|
||||
}, (c: KinCardPos): string => 'kc_' + c.p.id.toString())
|
||||
}
|
||||
.width(this.contentW)
|
||||
.height(this.contentH)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// ⚠️ `Free`(可双向)不是 `Both` —— `Both` 在 API 9 就废弃了,
|
||||
// SDK 的 `ScrollDirection` 现在只有 Vertical / Horizontal / Free / None。
|
||||
.scrollable(ScrollDirection.Free)
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
// 内容比视口窄时居中:`Scroll` 没法「居中内容」,用对称 padding 顶到中间
|
||||
.padding({ left: this.offsetX(), right: this.offsetX() })
|
||||
.backgroundColor(FbColors.paper)
|
||||
.onAreaChange((_o: Area, now: Area): void => {
|
||||
this.viewW = Number(now.width);
|
||||
this.viewH = Number(now.height);
|
||||
this.centerOnMe();
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 画全部连线(先血缘、后夫妻 —— 夫妻线更粗,要压在上面)。
|
||||
*
|
||||
* ⚠️ Canvas 不是响应式的:布局变了必须**自己重画**。
|
||||
* 这里在 `onReady`(首帧)与 `onAreaChange` 各调一次。
|
||||
*/
|
||||
private drawEdges(): void {
|
||||
if (this.contentW <= 0 || this.contentH <= 0) {
|
||||
return;
|
||||
}
|
||||
const c: CanvasRenderingContext2D = this.ctx;
|
||||
c.clearRect(0, 0, this.contentW, this.contentH);
|
||||
|
||||
// ---- 血缘:`(x1,y1) → (x1,midY) → (x2,midY) → (x2,y2)` ----
|
||||
//
|
||||
// 🔴 v73b:这里**不需要**为母线(`bus`)写分支 —— 母线横段只是同一个公式的
|
||||
// 退化情形(`y1 === y2 === midY` ⇒ 头和尾那两段竖线长度为 0),
|
||||
// 照这个公式画出来正好是一条纯横线。三类几何都画对:
|
||||
// · 主竖线 x1===x2 ⇒ 纯竖线
|
||||
// · 母线横段 y1===y2===midY ⇒ 纯横线
|
||||
// · 子女短竖线 x1===x2 ⇒ 纯竖线
|
||||
// ⚠️ 别为了"看得更清楚"给 bus 单独写一段:两段代码会各自漂移,
|
||||
// 以后改拐点或线宽就会不一致。
|
||||
for (const e of this.layout.edges) {
|
||||
if (e.kind === 'spouse') {
|
||||
continue;
|
||||
}
|
||||
c.beginPath();
|
||||
c.lineWidth = 1.4;
|
||||
c.strokeStyle = '#C9BDA9';
|
||||
c.lineCap = 'round';
|
||||
c.lineJoin = 'round';
|
||||
c.moveTo(e.x1, e.y1);
|
||||
c.lineTo(e.x1, e.midY);
|
||||
c.lineTo(e.x2, e.midY);
|
||||
c.lineTo(e.x2, e.y2);
|
||||
c.stroke();
|
||||
}
|
||||
|
||||
// ---- 夫妻:卡片之间的一小段横线(粗、品牌色,与血缘一眼区分)----
|
||||
for (const e of this.layout.edges) {
|
||||
if (e.kind !== 'spouse') {
|
||||
continue;
|
||||
}
|
||||
c.beginPath();
|
||||
c.lineWidth = 2.2;
|
||||
c.strokeStyle = '#C97B6A';
|
||||
c.lineCap = 'round';
|
||||
c.moveTo(e.x1, e.y1);
|
||||
c.lineTo(e.x2, e.y2);
|
||||
c.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张人物卡。
|
||||
*
|
||||
* 视觉与 Web 端 `.kin-card` 对齐:纸白底 + 细边 + 微投影,
|
||||
* 头像在上(服务端头像是 1:1 裁好的),姓名在下,称谓再下一行。
|
||||
* 「我」用品牌色描边 + 姓名加粗 —— 一眼认出中心。
|
||||
*/
|
||||
@Builder
|
||||
personCard(c: KinCardPos) {
|
||||
Column({ space: 0 }) {
|
||||
FbAvatar({
|
||||
name: c.p.shown !== '' ? c.p.shown : c.p.name,
|
||||
color: c.p.avatarColor !== '' ? c.p.avatarColor : '#8A6B4B',
|
||||
// 卡宽 62 ⇒ 头像 40,左右各留 11(含 3 内边距)= 不贴边、也不挤
|
||||
dia: 40,
|
||||
fontSizeVp: 15,
|
||||
imageUrl: fbImageSrc(c.p.avatar, FB_TOKEN_STORE.get())
|
||||
})
|
||||
|
||||
Text(c.p.shown !== '' ? c.p.shown : (c.p.name !== '' ? c.p.name : '(无名)'))
|
||||
.fontSize(12)
|
||||
.fontWeight(c.p.id === this.meId ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(c.p.id === this.meId ? FbColors.brandInk : FbColors.ink)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ top: 4 })
|
||||
|
||||
// 称谓:「我」不重复写一遍(卡片上写"我"是废话)
|
||||
if (c.p.label !== '' && c.p.label !== '我') {
|
||||
Text(c.p.label)
|
||||
.fontSize(10)
|
||||
.fontColor(c.p.id === this.meId ? FbColors.brand : FbColors.brand2)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ top: 1 })
|
||||
}
|
||||
}
|
||||
.width(KinDim.CARD_W)
|
||||
.height(KinDim.CARD_H)
|
||||
.padding({ top: 4, bottom: 3, left: 2, right: 2 })
|
||||
// ⚠️ `.position()` 是**左上角**定位,布局给的是**中心**坐标 ⇒ 各减一半
|
||||
.position({
|
||||
x: c.x - KinDim.CARD_W / 2,
|
||||
y: c.y - KinDim.CARD_H / 2
|
||||
})
|
||||
.backgroundColor(FbColors.surface)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.border({
|
||||
width: c.p.id === this.meId ? 1.5 : 1,
|
||||
color: c.p.id === this.meId ? FbColors.brand : FbColors.line
|
||||
})
|
||||
.shadow({ radius: 3, color: '#1A2B2620', offsetX: 0, offsetY: 1 })
|
||||
.onClick((): void => {
|
||||
this.onPick(c.p.id);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@ export const FB_MODULE_PATH: Record<string, string> = {
|
||||
'books': '/api/v1/books',
|
||||
'notes': '/api/v1/notes',
|
||||
'notifications': '/api/v1/notifications',
|
||||
'kin': '/api/v1/kin',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ const TAG: string = 'FbModuleRepo';
|
||||
/** 模块名(同时也是缓存键) */
|
||||
export type FbModuleName =
|
||||
| 'home' | 'calendar' | 'days' | 'tasks' | 'trips'
|
||||
| 'ledger' | 'resources' | 'books' | 'notes' | 'notifications' | 'kin';
|
||||
| 'ledger' | 'resources' | 'books' | 'notes' | 'notifications';
|
||||
|
||||
/** 一次加载的结果 */
|
||||
export interface FbModuleLoad {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import {
|
||||
FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip,
|
||||
FbKinGraph, FbKinPerson, FbMark,
|
||||
FbMark,
|
||||
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem,
|
||||
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
|
||||
} from '../model/Types';
|
||||
@@ -437,58 +437,6 @@ export function parseLedgerEntries(json: string): FbLedgerDetail {
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* 家谱 → FbKinGraph。
|
||||
*
|
||||
* ⚠️ 服务端给的关系是**数组**(`parents`/`children`/`spouses`/`siblings`
|
||||
* 全是 id 数组),端上直接用 —— 不重新推导。
|
||||
* ⚠️ 亲属称谓(`label`)也**由服务端算**(`Kinship::label`),
|
||||
* 端上**不重算**:那依赖整个家族图谱,两边算法不一致就会出现
|
||||
* "App 说堂兄、网页说表兄"。
|
||||
*/
|
||||
export function parseKin(json: string): FbKinGraph {
|
||||
const root: Record<string, Object> | null = asObject(json);
|
||||
const empty: FbKinGraph = { meId: 0, people: [], total: 0 };
|
||||
if (root === null) {
|
||||
return empty;
|
||||
}
|
||||
const rows: Record<string, Object>[] =
|
||||
arr<Record<string, Object>>(root['people'] as Record<string, Object>[]);
|
||||
const people: FbKinPerson[] = [];
|
||||
for (const r of rows) {
|
||||
people.push({
|
||||
id: n(r['id'] as number),
|
||||
name: s(r['name'] as string),
|
||||
shown: s(r['shown'] as string) !== '' ? s(r['shown'] as string) : s(r['name'] as string),
|
||||
label: s(r['label'] as string),
|
||||
gen: n(r['gen'] as number),
|
||||
avatarColor: s(r['avatar_color'] as string),
|
||||
// v73:家谱卡片要显示头像(服务端一直返回,以前画圆点没解析)
|
||||
avatar: s(r['avatar'] as string),
|
||||
/* 🔴 v73:**不能**用 `n()` —— 它把 null/undefined 折成 0,
|
||||
而服务端的户号 `unit_of` 是**从 0 开始**编号的(户 0 是真实的一户)。
|
||||
⇒ 用 `n()` 会让"字段缺失"与"第 0 户"变成同一个值,
|
||||
布局层就无法区分,线上实测表现为「我与妻子被拆成两户」。
|
||||
这里统一成 -1 表示"没有户号",布局层只判 `>= 0`。
|
||||
⚠️ 别改回 `n(...)`,也别在布局层用 `> 0`。 */
|
||||
unit: r['unit'] !== null && r['unit'] !== undefined ? n(r['unit'] as number) : -1,
|
||||
// ⚠️ 从 `Record<string, Object>` 里取值必须**显式断言**成目标类型 ——
|
||||
// ArkTS 不会自动把 `Object` 收窄成 `number[]`(编译期报
|
||||
// "Argument of type 'Object' is not assignable to 'number[]'")。
|
||||
parents: idList(r['parents'] as number[]),
|
||||
children: idList(r['children'] as number[]),
|
||||
spouses: idList(r['spouses'] as number[]),
|
||||
siblings: idList(r['siblings'] as number[]),
|
||||
});
|
||||
}
|
||||
const g: FbKinGraph = {
|
||||
meId: n(root['meId'] as number),
|
||||
people: people,
|
||||
total: n(root['total'] as number),
|
||||
};
|
||||
return g;
|
||||
}
|
||||
|
||||
/** id 数组的容错转换(服务端可能给 null) */
|
||||
function idList(v: number[] | null | undefined): number[] {
|
||||
const xs: number[] = arr<number>(v);
|
||||
|
||||
@@ -87,21 +87,18 @@ const MODULE_CN: Record<string, string> = {
|
||||
'books': '藏书',
|
||||
'notes': '笔记',
|
||||
'notifications': '通知',
|
||||
'kin': '家谱',
|
||||
'home': '首页',
|
||||
};
|
||||
|
||||
/**
|
||||
* 走通用缓存的模块(观影 / 名册有实体表,单独走)。
|
||||
*
|
||||
* 🔴 `kin`(家谱)**必须在**这里(v59 补的):用户要的是"把所有数据都同步下来",
|
||||
* 而家谱页原来只在**打开时**才拉 —— 离线进去是一片空白。
|
||||
* 它的数据来源与名册同源(`persons` / `relations` / `nicknames`),
|
||||
* 所以服务端 `CHANGE_TABLES['kin']` 用的是同一批表:名册一变,家谱跟着重拉。
|
||||
* ⚠️ v74:`kin`(家谱)已从端上删除,这里一并移除 —— 否则每次全量同步
|
||||
* 都会去拉一个端上已经没有任何页面消费的接口。
|
||||
*/
|
||||
const CACHE_MODULES: string[] = [
|
||||
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
|
||||
'notifications', 'kin',
|
||||
'notifications',
|
||||
];
|
||||
|
||||
/** `GET /api/v1/changes` 的响应 */
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { FbAccount, FbBookItem, FbCalBook, FbCalEvent, FbGroup,
|
||||
FbKinChip, FbKinRelation, FbLedgerBook, FbLedgerEntry, FbMovieItem,
|
||||
FbKinChip, FbLedgerBook, FbLedgerEntry, FbMovieItem,
|
||||
FbNoteItem, FbNotificationItem, FbResourceItem, FbRosterItem, FbSession,
|
||||
FbTaskItem, FbTripItem, FbTripNode } from './Types';
|
||||
|
||||
@@ -91,18 +91,6 @@ export const FB_DEMO_ROSTER: FbRosterItem[] = [
|
||||
{ id: 12, name: '赵鹏', kinship: '朋友', group: '亲友', tags: ['邻居'], avatarColor: '#3D6B8E', roster: 'friend', birthday: '04-17', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [], org: '', title: '' },
|
||||
];
|
||||
|
||||
/** 家谱连线(父母 / 配偶 / 子女) */
|
||||
export const FB_DEMO_KIN_RELATIONS: FbKinRelation[] = [
|
||||
{ id: 1, fromId: 1, fromName: '杨永全', toId: 5, toName: '杨建国', kind: 'parent', label: '父亲' },
|
||||
{ id: 2, fromId: 1, fromName: '杨永全', toId: 6, toName: '王秀兰', kind: 'parent', label: '母亲' },
|
||||
{ id: 3, fromId: 1, fromName: '杨永全', toId: 2, toName: '李慧', kind: 'spouse', label: '配偶' },
|
||||
{ id: 4, fromId: 1, fromName: '杨永全', toId: 3, toName: '杨小满', kind: 'child', label: '儿子' },
|
||||
{ id: 5, fromId: 1, fromName: '杨永全', toId: 4, toName: '杨小禾', kind: 'child', label: '女儿' },
|
||||
{ id: 6, fromId: 1, fromName: '杨永全', toId: 7, toName: '杨永强', kind: 'child', label: '弟弟' },
|
||||
{ id: 7, fromId: 2, fromName: '李慧', toId: 8, toName: '李国栋', kind: 'parent', label: '岳父' },
|
||||
{ id: 8, fromId: 2, fromName: '李慧', toId: 9, toName: '陈静', kind: 'parent', label: '岳母' }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 观影
|
||||
// ============================================================
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
/** 一个功能模块的静态定义 */
|
||||
export interface FbModuleDef {
|
||||
/** 模块 key,等于 Web 端路由名(home / kin / people / …) */
|
||||
/** 模块 key,等于 Web 端路由名(home / people / movies / …) */
|
||||
key: string;
|
||||
/** 抽屉里显示的名字 */
|
||||
name: string;
|
||||
|
||||
@@ -332,61 +332,6 @@ export interface FbRosterItem {
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 家谱里的一个人(`GET /api/v1/kin`)。
|
||||
*
|
||||
* ⚠️ 与 `FbRosterItem`(名册平铺名单)**不是一个东西**:
|
||||
* 名册是"我名册里的人"(可能上千),家谱是"**以我为中心的关系网**"
|
||||
* (只含上下两代,十来人),关键是带**代数**与**亲属边**。
|
||||
*/
|
||||
export interface FbKinPerson {
|
||||
id: number;
|
||||
name: string;
|
||||
/** 「我怎么叫他」(昵称优先) */
|
||||
shown: string;
|
||||
/** 「我该怎么称呼他」(服务端按图谱推导,如"岳父""外甥女") */
|
||||
label: string;
|
||||
/** 代数:0 = 我,负 = 长辈,正 = 晚辈 */
|
||||
gen: number;
|
||||
avatarColor: string;
|
||||
/**
|
||||
* 头像(服务端 OSS 相对路径,如 `avatars/xxx.jpg`;空 = 这个人没传过头像)。
|
||||
*
|
||||
* ⚠️ v73 加:家谱卡片改成"头像在上"之后要用真图。
|
||||
* 服务端 `/api/v1/kin` 一直就返回这个字段,只是端上以前画圆点用不着、没解析。
|
||||
* 与 `FbRosterItem.avatar` **同一个含义**,拼地址同样走 `fbImageSrc()`。
|
||||
*/
|
||||
avatar: string;
|
||||
/** 户(夫妻单元)号 */
|
||||
unit: number;
|
||||
parents: number[];
|
||||
children: number[];
|
||||
spouses: number[];
|
||||
siblings: number[];
|
||||
}
|
||||
|
||||
/** 家谱整体 */
|
||||
export interface FbKinGraph {
|
||||
meId: number;
|
||||
people: FbKinPerson[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 家谱连线(kin/relate:父母 / 配偶 / 子女) */
|
||||
export interface FbKinRelation {
|
||||
id: number;
|
||||
/** 从谁出发 */
|
||||
fromId: number;
|
||||
fromName: string;
|
||||
/** 到谁 */
|
||||
toId: number;
|
||||
toName: string;
|
||||
/** 关系类型:parent / spouse / child */
|
||||
kind: string;
|
||||
/** 称谓(Kinship::label 算出,如「爷爷」「外甥」) */
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 观影(movies)
|
||||
// ============================================================
|
||||
|
||||
@@ -647,7 +647,7 @@ struct Index {
|
||||
moduleContent() {
|
||||
if (this.app.activeModule === 'home') {
|
||||
FbHomePage({ app: this.app, topBar: this.topBarSlot })
|
||||
} else if (this.app.activeModule === 'people' || this.app.activeModule === 'kin') {
|
||||
} else if (this.app.activeModule === 'people') {
|
||||
FbPeoplePage({ app: this.app, topBar: this.topBarSlot })
|
||||
} else if (this.app.activeModule === 'movies') {
|
||||
FbMoviesPage({ app: this.app, topBar: this.topBarSlot })
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 名册 / 家谱(鸿蒙端)
|
||||
* 家书 FamilyBook · 名册(鸿蒙端)
|
||||
*
|
||||
* 对应 Web 端 `templates/people/index.php`(名册)与 `templates/kin/tree.php`(家谱)。
|
||||
* 对应 Web 端 `templates/people/index.php`。
|
||||
*
|
||||
* 这两页在 Web 端是两个模块,但**看的是同一批人**:名册是「平铺的名单」(通讯录视角),
|
||||
* 家谱是「以我为中心的连线」(关系视角)。端上放在同一个文件里,用内部分段切换 ——
|
||||
* 手机上在两页之间来回跳没有意义,切一下就能对照看。
|
||||
*
|
||||
* ⚠️ 家谱的树形图(Web 端是 HTML 折线)在手机窄屏上排不下。
|
||||
* 端上改成**「以我为中心的三圈」**:我 → 直系(父母/配偶/子女)→ 其他亲属,
|
||||
* 每圈一张卡,卡里按人列。关系表达上等价,宽度可控。
|
||||
* 🔴 v74(用户 2026-10-21):「这个功能直接从鸿蒙端去掉吧,不要了。
|
||||
* 这样名册这个功能,进来以后,上面就是搜索和添加按钮……
|
||||
* 家谱和名册的标签,都不要了。家谱的相关功能,都删除。」
|
||||
* ⇒ **家谱在端上彻底删除**(页面内分段切换、图谱视图、`/api/v1/kin` 取数、
|
||||
* 同步清单里的 `kin` 项全都没了)。现在这一页只有名册。
|
||||
* ⚠️ Web 端的家谱页**不受影响**(用户说的是"鸿蒙端")——
|
||||
* 服务端 `templates/kin/`、`Kinship` 那套仍然照常工作。
|
||||
*/
|
||||
|
||||
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
|
||||
import { FbAppState, fbAppState } from '../../common/Store';
|
||||
import { FbKinGraph, FbKinPerson, FbRosterItem } from '../../model/Types';
|
||||
import { 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';
|
||||
@@ -183,8 +180,6 @@ export struct FbPeoplePage {
|
||||
@BuilderParam topBar: () => void;
|
||||
@Param app: FbAppState = fbAppState();
|
||||
|
||||
/** 子视图:名册(roster)/ 家谱(kin) */
|
||||
@Local subView: string = 'roster';
|
||||
/** 搜索词(本地过滤) */
|
||||
@Local keyword: string = '';
|
||||
/**
|
||||
@@ -300,23 +295,6 @@ export struct FbPeoplePage {
|
||||
*/
|
||||
private rawById: Map<number, FbRosterPerson> = new Map<number, FbRosterPerson>();
|
||||
|
||||
/**
|
||||
* 家谱里的人(**来自接口** `/api/v1/kin`)。
|
||||
*
|
||||
* ⚠️ 与名册的 `all` **是两批数据**:
|
||||
* 名册是"我名册里的人"(可上千),家谱是"以我为中心的关系网"(上下两代、十来人)。
|
||||
* 所以家谱要**单独拉**,不能从 `all` 里挑。
|
||||
*/
|
||||
@Local kinPeople: FbKinPerson[] = [];
|
||||
/** 家谱的同步提示(与名册分开:两批数据可能一个成功一个失败) */
|
||||
@Local kinSyncNote: string = '';
|
||||
/**
|
||||
* ⚠️ v73:原来这里有 `kinMode`('graph' / 'list' 两种视图)。
|
||||
* 用户 2026-10-21 要求去掉列表、只留图谱 ⇒ 状态本身也不用了。
|
||||
* 「列表」那套是按代数分组的通讯录,名册页已经有同样的能力。
|
||||
*/
|
||||
/** 我在家谱里的 id(图谱里要标出「我」) */
|
||||
@Local kinMeId: number = 0;
|
||||
private repo: FbPersonRepo = FbPersonRepo.inst();
|
||||
|
||||
/**
|
||||
@@ -378,12 +356,7 @@ export struct FbPeoplePage {
|
||||
this.quickFromDetail = null;
|
||||
return true;
|
||||
}
|
||||
// ④ 家谱 ⇒ 回名册(同一页的两个子视图,"返回上一层"就是回到默认那个)
|
||||
if (this.subView === 'kin') {
|
||||
this.subView = 'roster';
|
||||
return true;
|
||||
}
|
||||
// ⑤ 本页没有可关的东西 ⇒ 交给 `Index`(换回上一个模块)
|
||||
// ④ 本页没有可关的东西 ⇒ 交给 `Index`(换回上一个模块)
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -491,13 +464,6 @@ export struct FbPeoplePage {
|
||||
* "这次没更新",用户手上的数据仍然可用。只在页面上留一行小字。
|
||||
*/
|
||||
private async syncInBackground(): Promise<void> {
|
||||
// 🔴 家谱与名册**并行拉**:两者是**独立的两批数据**
|
||||
// (名册 = 我名册里的人,可上千;家谱 = 以我为中心的上下两代,十来人)。
|
||||
// ⚠️ 并行(不是串行):串行会让"切到家谱"多等一个往返。
|
||||
// ⚠️ 家谱**不 await 在名册后面**:名册走实体表、家谱走通用缓存,
|
||||
// 两条路互不依赖。
|
||||
this.loadKin();
|
||||
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
if (token === '') {
|
||||
this.syncNote = '未登录,显示的是本地数据';
|
||||
@@ -613,25 +579,6 @@ export struct FbPeoplePage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉家谱(`/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 文件头。
|
||||
@@ -840,13 +787,9 @@ export struct FbPeoplePage {
|
||||
// ⇒ 解法:**把顶栏与页头塞进 `List` 的第一个 item**,
|
||||
// 它们就成了"列表内容的一部分",随列表一起滚走;
|
||||
// 同时 `List` 仍是唯一滚动容器 ⇒ 按需渲染、性能不丢。
|
||||
// (见 rosterView / kinView 里的 `this.pageHeaderItem()`)
|
||||
// (见 rosterView 里的 `this.pageHeaderItem()`)
|
||||
Column() {
|
||||
if (this.subView === 'roster') {
|
||||
this.rosterView()
|
||||
} else {
|
||||
this.kinView()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@@ -880,45 +823,57 @@ export struct FbPeoplePage {
|
||||
* 页头(分段切换 + 搜索 + 标签筛选条)。
|
||||
*
|
||||
* ⚠️ 从 `rosterView` 里提出来放在**页面级**:
|
||||
* 这样"名册/家谱"与搜索框在两个子视图下都常驻,
|
||||
* 切换子视图时不会跟着列表一起滚走。
|
||||
* 这样搜索框在滚动中常驻在列表内容里。
|
||||
*
|
||||
* 🔴 v74(用户 2026-10-21):
|
||||
* · 「家谱和名册的标签,都不要了」⇒ 整条分段切换删掉,
|
||||
* 连同"当前子视图"那个状态与它的切换按钮生成器一并移除。
|
||||
* · 「进来以后,上面就是搜索和添加按钮」⇒ 页头**第一行就是搜索 + 添加**,
|
||||
* 不再有任何东西排在它前面。
|
||||
* · 「搜索,做的醒目一点,现在看不清」⇒ 见下面 `searchBox` 的说明:
|
||||
* 高度 36→44、底色从 `inputBg`(几乎与页面同色)改成**白底 + 主题色描边**、
|
||||
* 图标 15→18 并改用主题色、字号 fontSmall→fontBody、提示词去掉冗长的能力罗列。
|
||||
*/
|
||||
@Builder
|
||||
peopleHeader() {
|
||||
Column() {
|
||||
// ---- 分段切换(名册 / 家谱)----
|
||||
// ---- 搜索 + 添加(页头第一行,上面不再有任何东西)----
|
||||
Row({ space: 10 }) {
|
||||
// ============================================================
|
||||
// 搜索框:**白底 + 主题色细描边 + 略深的内阴影观感**
|
||||
// ============================================================
|
||||
//
|
||||
// 🔴 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') {
|
||||
// ---- 搜索 + 添加 ----
|
||||
// 🔴🔴 v74(用户 2026-10-21):「搜索,做的醒目一点,现在看不清」。
|
||||
//
|
||||
// 原来为什么"看不清"(三个原因叠在一起):
|
||||
// ① 底色用 `FbColors.inputBg` —— 它和页面米色 `FbColors.paper`
|
||||
// 几乎同色,搜索框**没有边界**,看上去就是一段空白;
|
||||
// ② 高度只有 36,图标 15、字 fontSmall —— 在一屏 2444px 的真机上
|
||||
// 它占的比例很小,视线扫过去是"灰灰的一条";
|
||||
// ③ 提示词写着「搜索:姓名 / 拼音首字母(yyq)/ 手机 / 单位」,
|
||||
// 20 多字塞进 36 高的框里 ⇒ 强制缩小字号 / 显示不全,
|
||||
// 信息过载反而更难读。
|
||||
//
|
||||
// ⇒ 四件事一起改(缺一件都还是不够醒目):
|
||||
// · **白底**(`#FFFFFF`)—— 页面是米色,白底天然"浮"起来;
|
||||
// · **1.5 描边 + 主题色**(`#4A7C82` 青)—— 有明确的边界;
|
||||
// · **高度 44**(≈ 44×3.125 = 138px),接近系统搜索框的舒适高度;
|
||||
// · **字号 fontBody + 图标 18 且染主题色** —— 一眼能认出"这是搜索"。
|
||||
// ⚠️ 提示词收短成「搜索姓名 / 首字母 / 手机」,能力说明挪到
|
||||
// `searchHint()` 的注释里 —— 框里只留能一眼读完的东西。
|
||||
Row({ space: 8 }) {
|
||||
Row({ space: 6 }) {
|
||||
Image($r('app.media.fb_ic_search'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.width(18)
|
||||
.height(18)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.colorFilter(fbTint(FbTint.faint))
|
||||
TextInput({ placeholder: '搜索:姓名 / 拼音首字母(yyq)/ 手机 / 单位', text: this.keyword })
|
||||
.placeholderColor(FbColors.faint)
|
||||
.placeholderFont({ size: FbMetric.fontSmall })
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
// 图标用**名册主题色**(不是原来的 faint 灰)—— 让"搜索"这个动作有颜色。
|
||||
// ⚠️ 用 `this.themeBrand()` 而不是 `FbTint.brand`:后者是品牌砖红
|
||||
// (#A84A28),与名册的青色主题不是一个色系(v59c 踩过同样的问题)。
|
||||
.colorFilter(fbTint(this.themeBrand()))
|
||||
TextInput({ placeholder: '搜索姓名 / 首字母 / 手机', text: this.keyword })
|
||||
.placeholderColor(FbColors.muted)
|
||||
.placeholderFont({ size: FbMetric.fontBody })
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontColor(FbColors.ink)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.padding(0)
|
||||
@@ -928,16 +883,17 @@ export struct FbPeoplePage {
|
||||
})
|
||||
/* 🔴 v59e:搜索框右侧补一个「清空」——
|
||||
名册上千人,搜错了要逐字删掉很烦(而且手机上退格容易连删)。
|
||||
⚠️ 命中区给 24×24(图标只有 12):手机上 12vp 的点击区域点不中。 */
|
||||
⚠️ 命中区给 28×28(图标只有 14):手机上 14vp 的点击区域点不中。
|
||||
v74 跟着搜索框放大一档(24→28),否则在 44 高的框里显得太小。 */
|
||||
if (this.keyword !== '') {
|
||||
Row() {
|
||||
Image($r('app.media.fb_ic_close'))
|
||||
.width(12)
|
||||
.height(12)
|
||||
.colorFilter(fbTint(FbTint.faint))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.colorFilter(fbTint(FbTint.muted))
|
||||
}
|
||||
.width(24)
|
||||
.height(24)
|
||||
.width(28)
|
||||
.height(28)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick((): void => {
|
||||
this.keyword = '';
|
||||
@@ -945,15 +901,23 @@ export struct FbPeoplePage {
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height(36)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.height(44)
|
||||
.padding({ left: 14, right: 10 })
|
||||
.backgroundColor('#FFFFFF')
|
||||
// radiusCard=14:比内嵌小块(10)圆润、比区块卡(16)收得住,
|
||||
// 正好是"一个独立的输入控件"该有的圆角。
|
||||
.borderRadius(FbMetric.radiusCard)
|
||||
.border({ width: 1.5, color: this.themeBrand() })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
/* 添加按钮:跟着搜索框一起放到 44 高,两颗控件等高才不显歪。
|
||||
⚠️ `FbPrimaryButton` 自己按内容高度走(约 34),
|
||||
这里用 `height(44)` 的容器包一层把它撑到同高 ——
|
||||
直接把 44 塞给组件会破坏它内部的 padding 比例。 */
|
||||
Column() {
|
||||
FbPrimaryButton({
|
||||
label: '添加',
|
||||
bg: '#4A7C82',
|
||||
bg: this.themeBrand(),
|
||||
icon: $r('app.media.fb_ic_plus'),
|
||||
// 🔴 v52:接上(之前这颗按钮**没有回调**,点了什么都不发生 ——
|
||||
// "全功能客户端"不能有这样的摆设)。
|
||||
@@ -962,14 +926,16 @@ export struct FbPeoplePage {
|
||||
}
|
||||
})
|
||||
}
|
||||
.height(44)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
|
||||
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 14, bottom: 10 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// ---- 标签筛选条 ----
|
||||
this.tagBar()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@@ -1372,18 +1338,18 @@ export struct FbPeoplePage {
|
||||
* ⚠️ 用 `this.detailToken` 防止"快速连点两个人,前一个的响应把后一个覆盖"。
|
||||
*/
|
||||
private openDetail(id: number): void {
|
||||
/* 🔴🔴 v73 真 bug:点家谱卡片**静默没反应**。
|
||||
/* 🔴🔴 v73 真 bug:点卡片**静默没反应**(当时是家谱卡片,现在名册卡片同理)。
|
||||
根因:这里原来只从 `rawById` 取,而 `rawById` 来自 `repo.list()`
|
||||
—— 那条 SQL 带 `JOIN person_roster`,**只包含"我挑进名册的人"**。
|
||||
家谱里的人(父母、岳父母、配偶)**不一定在我的名册里** ⇒ 查不到 ⇒ 直接 return。
|
||||
名册外的人(从分享/搜索进来的人)**不一定在我的名册里** ⇒ 查不到 ⇒ 直接 return。
|
||||
字面上就是"点了没反应",而且不打日志的话 hilog 一个字都抓不到。
|
||||
|
||||
修法分两段(与 `byId()` 的注释同一条思路):
|
||||
① 名册里有 ⇒ 立刻用本地那份开浮层(不等网络,点开就有内容);
|
||||
② 名册里没有 ⇒ 先空着把浮层**打开**(`detailPerson` 用一个占位),
|
||||
② 名册里没有 ⇒ 先开一个**只有 id** 的占位把浮层打开,
|
||||
再异步 `byId()` / 网络详情补上 —— **不能**因为本地没缓存就不响应点击。
|
||||
⚠️ 注意 `detailPerson` 是 `FbRosterPerson`,不能塞 `null` 当作"只开框",
|
||||
所以第 ② 种情况必须先构造一个最小可渲染对象(只有 id + 姓名)。
|
||||
所以第 ② 种情况必须先构造一个最小可渲染对象。
|
||||
*/
|
||||
const cached: FbRosterPerson | undefined = this.rawById.get(id);
|
||||
hilog.info(DOMAIN, TAG, 'openDetail(%{public}d):名册命中=%{public}s(名册共 %{public}d 人)',
|
||||
@@ -1395,14 +1361,13 @@ export struct FbPeoplePage {
|
||||
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);
|
||||
/* ② 本地名册里没有 —— 仍然立刻把浮层打开,先去 `byId()` 捞一份。
|
||||
🔴 v74:家谱已从端上删除,原来"用家谱那份拼占位"的兜底没有了;
|
||||
这里退化成**纯占位**(只有 id),随后 `repo.byId()` / `fetchDetail()`
|
||||
任一路回来都会整份替换掉它。
|
||||
⚠️ 不能因为"本地没缓存"就 return —— 那正是 v73 那个"点了没反应"的病。 */
|
||||
hilog.info(DOMAIN, TAG, 'openDetail(%{public}d):本地名册未命中,先开占位浮层', id);
|
||||
this.detailPerson = placeholderPerson(id);
|
||||
}
|
||||
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
@@ -1502,126 +1467,21 @@ export struct FbPeoplePage {
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 家谱
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 家谱的说明条(「以我为中心」+ 人数)。
|
||||
*
|
||||
* 🔴 v68:这里原来还有一颗「补关系」按钮,**从来没有 `onTap`** ——
|
||||
* 点了一点反应都没有(全仓库扫过,只剩这一颗)。
|
||||
* 它要的是"选一个还没有亲属关系的人 → 给他挂上父/母/配偶",
|
||||
* 而服务端 `relations` 表**只有读**(没有写接口),网页版也没有这个功能
|
||||
* ⇒ 与其留一个永远点不动的按钮,先撤掉(免得被当成"坏了")。
|
||||
* 要做得先加 `POST /api/v1/persons/<id>/relations`,那是独立一批活。
|
||||
*
|
||||
* 🔴 v73(用户 2026-10-21):右侧的「图谱 / 列表」切换**整块撤掉**。
|
||||
* 原话是"手机端这个家谱实在是很难看",而那个"列表"视图
|
||||
* 本质就是个按代数分组的通讯录 —— 名册页早就有同样的能力
|
||||
* (还能搜索、筛选标签),留着它只会让人以为家谱有两种画法。
|
||||
* 现在家谱只有一种形态:**图**。
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
kinView() {
|
||||
Column() {
|
||||
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 {
|
||||
// ---- 家谱图(Canvas 画线 + 原生卡片,见 components/FbKinGraph.ets)----
|
||||
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 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一代人(家谱里的一层)。
|
||||
*
|
||||
* ⚠️ v73:这个 Builder 与 `kinRow` 一起**已删除** —— 它们只服务
|
||||
* 「列表」视图,而用户 2026-10-21 要求去掉列表、只留图谱。
|
||||
* 原来这里按代数分组、每组一个白卡片 + 行分隔线,
|
||||
* 本质就是个通讯录;名册页已经有同样的能力(还能搜索/筛标签)。
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 共用小件
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 分段切换的一个按钮(名册 / 家谱)。
|
||||
* 名册模块的主题色。
|
||||
*
|
||||
* 🔴 v59c(用户 2026-10-18):「名册、家谱这两个标签,用的不是主题色。」
|
||||
* ⇒ 颜色**不再由调用方传字面量**,改成方法内部自己取名册模块的主题色。
|
||||
* 之前两个调用点各写一遍 `'#A84A28'`(品牌砖红,与名册的青色主题不符),
|
||||
* 而且"两处各写一遍"本身就是漂移源(改一处忘一处)。
|
||||
* 🔴 原来叫「选中态实底」,服务的是「名册 / 家谱」分段切换;
|
||||
* v74 分段切换删掉了,这个方法**留着**是因为搜索框的描边、搜索图标染色、
|
||||
* 添加按钮底色都用它 —— 一处取值,三处不漂。
|
||||
* ⚠️ 取色走 `fbThemeVars(名册模块色).brand`(统一算法),
|
||||
* 不是直接抄 `FB_MODULES` 里那个 `#4A7C82` —— 见调用点上方那段说明。
|
||||
* 不是直接抄 `FB_MODULES` 里那个 `#4A7C82`:
|
||||
* `fbThemeVars` 会把色相拉到可读的饱和度/亮度区间,
|
||||
* 直接抄模块色当实底、白字压上去会偏亮发飘。
|
||||
*/
|
||||
@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;
|
||||
@@ -1665,14 +1525,6 @@ export struct FbPeoplePage {
|
||||
代数分层的信息现在由**图谱本身的纵向位置**表达(长辈在上、晚辈在下),
|
||||
不再需要「父母辈 / 我这一辈」这种文字小标题。 */
|
||||
|
||||
/** 家谱顶部的人数说明 */
|
||||
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);
|
||||
@@ -1681,27 +1533,28 @@ export struct FbPeoplePage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用「家谱里的那个人」拼一个**最小可渲染**的 `FbRosterPerson`。
|
||||
* 拼一个**最小可渲染**的 `FbRosterPerson`(只有 id + 姓名)。
|
||||
*
|
||||
* 🔴 为什么需要(v73 真 bug):家谱里的人**不一定在我名册里**
|
||||
* (`FbPersonRepo.list()` 带 `JOIN person_roster`,只含"我挑进名册的人"),
|
||||
* 那时点卡片以前是**静默 return ⇒ 点了没反应**。
|
||||
* 这里先拼一个只够画浮层头部的对象,**让浮层立刻打开**,
|
||||
* 🔴 为什么需要(v73 真 bug,v74 保留):`FbPersonRepo.list()` 带 `JOIN person_roster`,
|
||||
* 只包含"我挑进名册的人";而详情接口能取到**名册外**的人(例如从别处跳进来的人)。
|
||||
* 以前本地查不到就直接 return ⇒ 字面上"点了没反应",hilog 里一个字都没有。
|
||||
* ⇒ 先拼一个只够画浮层头部的对象,**让浮层立刻打开**,
|
||||
* 随后 `repo.byId()` / `fetchDetail()` 到了再原地升级。
|
||||
*
|
||||
* ⚠️ 只填家谱真有的那几项,其余给空值 —— 不要瞎猜(填错比空着更糟:
|
||||
* 用户会以为"系统里有这个资料")。本地库/接口补上来后会被整份替换掉。
|
||||
* ⚠️ v74 起**不再从家谱借字段**(家谱功能已从端上删除):只填 id 与姓名,
|
||||
* 其余一律给空值 —— 不要瞎猜(填错比空着更糟:用户会以为"系统里有这个资料")。
|
||||
* 本地库/接口补上来后会被整份替换掉。
|
||||
*/
|
||||
function placeholderPerson(k: FbKinPerson): FbRosterPerson {
|
||||
function placeholderPerson(id: number): FbRosterPerson {
|
||||
const p: FbRosterPerson = {
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
shown: k.shown !== '' ? k.shown : k.name,
|
||||
// 家谱那份的称谓是服务端算好的,这里能用
|
||||
label: k.label,
|
||||
id: id,
|
||||
name: '',
|
||||
shown: '',
|
||||
// 称谓由服务端按家族关系算(端上没有家谱了,取不到)⇒ 留空
|
||||
label: '',
|
||||
tags: [],
|
||||
avatarColor: k.avatarColor,
|
||||
avatar: k.avatar,
|
||||
avatarColor: '#A84A28',
|
||||
avatar: '',
|
||||
birthdaySolar: '',
|
||||
birthdayFull: '',
|
||||
birthdayLunar: '',
|
||||
|
||||
@@ -682,7 +682,6 @@ export struct FbSettingsPage {
|
||||
private iconOf(key: string): Resource {
|
||||
switch (key) {
|
||||
case 'home': return $r('app.media.fb_ic_home');
|
||||
case 'kin': return $r('app.media.fb_ic_kin');
|
||||
case 'people': return $r('app.media.fb_ic_people');
|
||||
case 'movies': return $r('app.media.fb_ic_movies');
|
||||
case 'trips': return $r('app.media.fb_ic_trips');
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user