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:
2026-10-21 23:28:19 +08:00
parent 8cabc35400
commit 6e7cdaf5fc
14 changed files with 150 additions and 1615 deletions
@@ -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);
})
}
}