Files
FamilyBook_harmonyos/entry/src/main/ets/pages/people/FbPeoplePage.ets
T
laoyang 0816e25988 v0.4.0 顶栏改白色 + 通知栏同色 + 顶栏随页面滚动
用户 08:56 的三条要求:
  1. 家书标题栏用浅色(下面色块的背景色);
  2. 上面通知栏也是浅色,两者融为一体;
  3. 向上滑动时标题栏跟着向上滑,不固定在原位。
(追问确认:顶栏底色 = 白;「品牌条 + 页面标题条」一起随滚动消失。)

## ① 系统栏改「不透明 + 与顶栏同色」,弃用透明方案
原来走「系统栏全透明 + 让紧邻元素透出同底色」。这版顶栏要用白色,
若仍走透明方案就得再造一层"白色压条"去刷白状态栏,太绕。
⇒ 直接 `statusBarColor = surface`(白)、`navigationBarColor = page_bg`(纸色)。
这是**配置出来的同色**,比"指望紧邻元素恰好同色"稳得多(后者顶栏一改底色就出色带)。
副产品:不再需要 `setWindowBackgroundColor` ⇒ 连那条 unavailable 告警也消失,
编译回到 **0 error / 0 warning**。

## ② 顶栏随滚动:整页包进 Scroll,顶栏作为内容第一个元素
原来 `Column { 页头; List/Grid(layoutWeight 1) }` —— 顶栏在滚动区**外面**,
所以固定不动。改为 `Scroll { Column { this.topBar(); 页头; 列表 } }`。
顶栏由 Index 以 `@BuilderParam topBar` 插槽下发(`topBarSlot`),13 个页面全部接入。

⚠️ 同步必须处理的两点:
· 列表的 `.layoutWeight(1)` 要删 —— Scroll>Column 里高度由内容决定,
  layoutWeight 会去分"剩余空间"(=0) ⇒ 列表塌成 0 高、整页只剩顶栏和页头;
· List/Grid 不能自己再滚动(去 scrollBar/edgeEffect)—— 两层滚动容器会抢手势。
⚠️ ArkUI 限制:带尾随 lambda 的组件只能有 1 个 `@BuilderParam`
(首页原有闲置的 topActions 已删)。

## ③ 顶栏白色 + logo 可辨认
logo 是「米色底(74%) + 砖红图案(24%)」的实心方图,放在米色上底板隐形、
只剩图案发飘(用户说"看不清")。今加一层极浅描边把底板"托"出来。
顶栏高度 56→48vp。

## 验证(真机,全部通过)
· 沉浸式:状态栏色 `#FFFFFF` == 顶栏色 `#FFFFFF` ✓
· 顶栏随滚动:「家书」标题 y=140 → 滑动后**已滚出可视区** ✓
· 顶栏滚走后状态栏仍为 `#FFFFFF`(未露出页面内容)✓
· 14 个页面遍历 **14/14**;深验(每页特征文本)**12/12**
· 编译 **0 error / 0 warning**

## 过程记录:批量改 12 个文件失败两次(已回滚重做)
第 1 版按缩进正则切分 ⇒ 闭合括号错乱;第 2 版跳字符时**没消费 Column 的
修饰符链** ⇒ 吃掉 `@Builder` 声明、**636 个报错**。
第 3 版改为**按行 + 括号深度解析**,且**改完立刻做结构断言**
(括号配平、`this.topBar()` 恰好 +1),不满足就不写入;先单页试跑再推广。
教训:批量改多文件时正则不可靠,必须配结构自检 + 单页试点。

配套修掉 `_tooling/familybook_harmonyos_run.sh` 一个"假成功":
`hdc list targets` 无设备时输出字面量 `[Empty]`,原来"非空即通过"
⇒ 拿 `[Empty]` 当序列号去装、还打印「✓ 设备:[Empty]」。已显式排除并 die。
2026-10-12 10:06:55 +08:00

444 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · 名册 / 家谱(鸿蒙端)
*
* 对应 Web 端 `templates/people/index.php`(名册)与 `templates/kin/tree.php`(家谱)。
*
* 这两页在 Web 端是两个模块,但**看的是同一批人**:名册是「平铺的名单」(通讯录视角),
* 家谱是「以我为中心的连线」(关系视角)。端上放在同一个文件里,用内部分段切换 ——
* 手机上在两页之间来回跳没有意义,切一下就能对照看。
*
* ⚠️ 家谱的树形图(Web 端是 HTML 折线)在手机窄屏上排不下。
* 端上改成**「以我为中心的三圈」**:我 → 直系(父母/配偶/子女)→ 其他亲属,
* 每圈一张卡,卡里按人列。关系表达上等价,宽度可控。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbKinRelation, FbRosterItem } from '../../model/Types';
import { FB_DEMO_KIN_RELATIONS, FB_DEMO_ROSTER, fbGroupRoster } from '../../model/DemoData';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
/** 家谱里一个人一圈的分组 */
interface FbKinRing {
/** 圈名:我 / 直系 / 亲属 */
title: string;
/** 说明 */
hint: string;
/** 圈内的关系条目 */
rels: FbKinRelation[];
}
@ComponentV2
export struct FbPeoplePage {
/**
* 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。
* 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。
*/
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
/** 子视图:名册(roster)/ 家谱(kin) */
@Local subView: string = 'roster';
/** 名册范围:member(家里的人)/ friend(亲友) */
@Local rosterScope: string = 'member';
/** 搜索词(本轮只做界面,不过滤) */
@Local keyword: string = '';
private all: FbRosterItem[] = FB_DEMO_ROSTER;
private rels: FbKinRelation[] = FB_DEMO_KIN_RELATIONS;
build() {
Scroll() {
Column() {
this.topBar()
// ---- 分段切换(名册 / 家谱)----
Row({ space: 4 }) {
this.segBtn('名册', 'roster', '#A84A28')
this.segBtn('家谱', 'kin', '#A84A28')
}
.padding(3)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
if (this.subView === 'roster') {
this.rosterView()
} else {
this.kinView()
}
}
.width('100%')
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
// ============================================================
// 名册
// ============================================================
@Builder
rosterView() {
Column() {
// 操作条
Row({ space: 8 }) {
Row({ space: 6 }) {
Image($r('app.media.fb_ic_search'))
.width(15)
.height(15)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
TextInput({ placeholder: '搜索姓名、手机、备注', 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')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// 范围切换(家里的人 / 亲友)
Row({ space: 8 }) {
this.scopeChip('家里的人', 'member')
this.scopeChip('亲友', 'friend')
Blank()
Text(this.byScope().length.toString() + ' 人')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
.alignItems(VerticalAlign.Center)
// 分组列表
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbRosterItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count + ' 人') }) {
ForEach(g.items, (p: FbRosterItem) => {
ListItem() {
this.personRow(p)
}
}, (p: FbRosterItem): string => 'pr_' + p.id.toString())
}
}, (g: FbGroup<FbRosterItem>): string => 'rg_' + g.key)
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.layoutWeight(1)
}
/** 一个人一行:头像 + 名字/称谓 + 标签 + 生日 */
@Builder
personRow(p: FbRosterItem) {
Row({ space: 12 }) {
FbAvatar({ name: p.name, color: p.avatarColor, dia: 40, fontSizeVp: 16 })
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(p.name)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.fontWeight(FontWeight.Medium)
.maxLines(1)
if (p.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
if (p.kinship !== p.name) {
FbTag({ text: p.kinship, tone: 'muted' })
}
}
.alignItems(VerticalAlign.Center)
Row({ space: 8 }) {
if (p.tags.length > 0) {
ForEach(p.tags, (t: string) => {
FbTag({ text: t, tone: 'blue' })
}, (t: string): string => 'tg_' + p.id.toString() + '_' + t)
}
if (p.birthday.length > 0) {
Text('生日 ' + p.birthday)
.fontSize(10)
.fontColor(FbColors.faint)
}
}
.alignItems(VerticalAlign.Center)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.fb_ic_forward'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
}
.width('100%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
// ============================================================
// 家谱
// ============================================================
@Builder
kinView() {
Column() {
// 说明条
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('以我为中心')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.rels.length.toString() + ' 条连线 · 父母 / 配偶 / 子女填上就能长出来')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
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)
Scroll() {
Column({ space: 0 }) {
ForEach(this.rings(), (r: FbKinRing) => {
this.ringCard(r)
}, (r: FbKinRing): string => 'ring_' + r.title)
// 完整连线表(家谱的「原始数据」,便于核对填得对不对)
Column({ space: 0 }) {
Row() {
Text('全部连线')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.rels.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, bottom: 8 })
Column({ space: 0 }) {
ForEach(this.rels, (r: FbKinRelation, idx: number) => {
Row({ space: 10 }) {
Text(r.fromName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.maxLines(1)
Image($r('app.media.fb_ic_forward'))
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text(r.toName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
FbTag({ text: r.label, tone: 'muted' })
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (idx < this.rels.length - 1) {
Divider().color(FbColors.line2)
}
}, (r: FbKinRelation): string => 'rel_' + r.id.toString())
}
.width('100%')
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.layoutWeight(1)
}
/** 一圈亲属卡 */
@Builder
ringCard(r: FbKinRing) {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(r.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(r.hint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
Text(r.rels.length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
if (r.rels.length === 0) {
Text('还没有连线')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} else {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(r.rels, (rel: FbKinRelation) => {
Row({ space: 6 }) {
FbAvatar({ name: rel.toName, color: this.colorOf(rel.toId), dia: 28, fontSizeVp: 12 })
Column({ space: 1 }) {
Text(rel.toName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
Text(rel.label)
.fontSize(10)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
}
.padding({ left: 6, right: 12, top: 5, bottom: 5 })
.backgroundColor(FbColors.surfaceAlt)
.borderRadius(FbMetric.radiusPill)
.margin({ right: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (rel: FbKinRelation): string => 'kr_' + rel.id.toString())
}
.width('100%')
}
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
.alignItems(HorizontalAlign.Start)
}
// ============================================================
// 共用小件
// ============================================================
@Builder
segBtn(label: string, key: string, color: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontWeight(this.subView === key ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.subView === key ? Color.White : FbColors.muted)
.textAlign(TextAlign.Center)
.height(30)
.layoutWeight(1)
.backgroundColor(this.subView === key ? color : Color.Transparent)
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.subView = key;
})
}
@Builder
scopeChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(this.rosterScope === key ? Color.White : FbColors.muted)
.backgroundColor(this.rosterScope === key ? '#4A7C82' : FbColors.inputBg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.rosterScope = key;
})
}
@Builder
groupHeader(title: string, count: string) {
Row({ space: 8 }) {
Text(title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(count)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
.backgroundColor(FbColors.paper)
}
private byScope(): FbRosterItem[] {
return this.all.filter((p: FbRosterItem): boolean => p.roster === this.rosterScope);
}
private groups(): FbGroup<FbRosterItem>[] {
return fbGroupRoster(this.byScope());
}
/** 三圈:我 / 直系 / 其他亲属 */
private rings(): FbKinRing[] {
const direct: string[] = ['parent', 'spouse', 'child'];
return [
{ title: '直系', hint: '父母 / 配偶 / 子女', rels: this.rels.filter((r: FbKinRelation): boolean => direct.indexOf(r.kind) >= 0) },
{ title: '其他亲属', hint: '兄弟姐妹与姻亲', rels: this.rels.filter((r: FbKinRelation): boolean => direct.indexOf(r.kind) < 0) }
];
}
/** 取某个人的头像色(从名册里查,查不到用默认砖红) */
private colorOf(id: number): string {
const hit: FbRosterItem | undefined = this.all.find((p: FbRosterItem): boolean => p.id === id);
return hit !== undefined ? hit.avatarColor : '#A84A28';
}
}