Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbPersonSheet.ets
T
laoyang 853b0e0b20 v61:顶栏标题=模块名 / 抽屉瘦身 + 头像进个人设置 / 删除按钮红底 / 浮层动画 / 日程卡片与手势
用户这一批九条,逐条对应:

1)「页面上左上角的标题,不再是家书了,而是切换到那个模块,就显示哪个模块的标题。
   这样在页面内容部,就不再出现模块的名字标题了。」
   → TopBar 主标题改读 moduleName()(通知/设置/个人设置三个非模块路由也有名字);
     观影/纪念日/读书/笔记/通知五处页内大标题删掉。

2)「按钮,也需要和主题色保持一致,出现文字的时候,例如观影模块,
   在左上角出现的时候,颜色也需要和主题色保持一致。」
   → 标题色走 fbModuleColor(app.activeModule)(一处算,11 个页面不用各写)。

3)「弹框的外圈颜色,跟模块的主题色保持一致。」
   → 日程详情原来传 bookColor(日历本色)⇒ 改 accentColor();
     7 个模块设置浮层补传 accent: this.accent。

4)「删除按钮全部用红色背景。」
   → 日程编辑/人员编辑从红描边改红底白字;详情「删除联系人」从浅红底改红底白字;
     两处确认框统一 FbColors.red。

5)「做几个动画,例如,点击名册列表,应该有一个过渡动画出现弹框,
   点击详情,框变大了,也应该有一个过渡动画,点编辑,也有动画。」
   → 新建 common/FbAnim.ets(fbMaskTransition / fbSheetTransition /
     fbSheetGrowTransition / fbViewTransition),9 个页面共 17 遮罩 + 16 浮层挂上;
     简要卡→详情用 0.96→1 的"继续长大"(0.94 会先缩一下)。

6)「左侧抽屉,把通知消息、个人设置、修改密码、系统设置,都去掉,
   放在个人设置页,就是在点击右侧头像那里。消息通知不用放进去,
   直接在头像旁边那里就可以了。」
   → 抽屉底部那四行整块删除(抽屉只剩品牌区 + 模块列表);
     新建 pages/account/FbAccountPage.ets(整页,__account 分发),
     点头像 switchModule('__account');改密码真打服务端新接口。

7)「日程……日期下面:12本日历可见,本月124条,这句话去掉。
   点击日程弹出的框,编辑按钮也颜色也要修改。
   全天日程,例如农历,上面显示无标题,读不到这条日程,这个有问题。」
   → 删统计行;编辑按钮从写死名册青 #4A7C82 改模块主题色;
     真根因:衍生条目(农历/节气)的 detailFull **一直是 null**
     (它们没有 /events/<id> 可拉,原来直接 return)⇒ 垫 shellOf(),
     并把"整卡报错"改成"照常画内容 + 一行小字";
     空标题兜底从「(无标题)」改成日历本名。

8)「再做几个卡片,先做日程的,这个参考 synccalendar。」
   → FbDayEventPanel.eventRow 照 SyncCalendar:左侧 4vp 圆头色条 + 标题 +
     时间/全天 + 「↻ 重复」徽标 + 右端日历本名(限宽 30%、同色);
     一行一张独立圆角卡(12)+ 描边,卡间 space 8,不再用 Divider 连成一片。

9)「日程,有很多手势,你参考 synccalendar。」
   → 月历上滑→周视图、周条下划→月视图、周条左右滑→换周。
     真机实测两次踩空后定下写法:**竖向必须 parallelGesture(PanGesture)**
     (外层是 Scroll,SwipeGesture 的"快速划动"会被它的内置滚动先吃掉,
      gesture/priorityGesture 都不行);**横滑用 gesture(PanGesture)**
     (同一节点两条方向不同的 parallelGesture 会互相压制);
     方向按累计位移判、并要求 |dy| > |dx|。切视图走 animateTo + fbViewTransition。

验证:
· 编译 BUILD SUCCESSFUL
· ArkTS 守卫 493/0;Schema 列守卫 12/0;反向验证 39/0(新增 22 条注入逐条确认能变红)
· 服务端接口 e2e 20/0(独立副本 + 真 MariaDB,5 种错误路径 + 原令牌仍有效)
· 真机 _fb_v61_verify.py 21/0(6 组全过);像素采样确认浮层主题色环实测存在
2026-10-19 12:19:54 +08:00

631 lines
28 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 · 人员详情浮层(鸿蒙端)
*
* 点名册里的一个人 → 弹出他的完整资料(头像 / 姓名 / 称谓 / 单位职务 /
* 手机 / 生日(阳历+农历)/ 邮箱 / 地址 / 座右铭 / 标签 / 备注)。
*
* 🔴 为什么做成"浮层"而不是新页面:
* 与观影的详情浮层同一个理由(见 `FbMovieDetailSheet` 的文件头):
* · 用户在名册里"随手点开看看",看完就关 —— 跳页面会丢失列表的滚动位置,
* 回来还得重新找;
* · 浮层是**页面内的覆盖层**,不动导航栈,关掉即回到原处。
* ⚠️ 但它**不是**系统弹窗(`bindSheet`/`AlertDialog`)——
* 那些会带系统边框与动画,且塞不进我们要的自定义布局。
*
* 数据来源(🔴 v52 重写,用户 2026-10-15):
* 用户要求「**完全脱离电脑端**,所有信息都显示出来」⇒ 详情不再"用列表那份凑合":
* · 列表(`/api/v1/roster`)只给画一栏要用的字段;
* · 详情**单独拉 `GET /api/v1/people/<id>`** —— 它多带
* **工作经历 / 补充联系方式(按类型)/ 私人备注 / 结构化地址五级 /
* 身份证(打码) / 头像 URL / 能不能编辑**。
* ⚠️ 旧注释里写"`/people/<id>` 字段并不比 roster 多" —— 那是当时的事实,
* v52 起**已经不成立**(服务端这次把详情接口补全了)。
* ⚠️ 断开网络时退回"用列表里那份"(`person` 本来就是列表那份),
* 只是缺那几项详情专属字段,界面不会空。
*/
import { FbColors, FbMetric, fbTint, FbTint, fbAlpha } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
import { FbAvatar, FbTag } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { fbImageSrc } from '../data/FbAttachQueue';
/**
* 贴底卡片的手势区缓冲(vp)。
*
* 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)——
* 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段,
* 否则最后一排按钮会被"从下往上滑"误触。
*/
const FB_SHEET_BOTTOM_GAP: number = 16;
/**
* 「删除」用的红 —— 与 `resources/base/element/color.json` 的 `red` **同值**。
*
* ⚠️ 不能直接用 `FbColors.red`(那是 `Resource`):本文件要拿它拼
* `fbAlpha(RED, 0.08)`(透明度后缀)做底色,`Resource` 拼不了字符串。
* ⚠️ color.json 改色时这里要同步改(只有这一个值,风险可控)。
* 🔴 2026-10-19 起删除按钮统一**红底白字**(`FbColors.red`),
* 这个常量只剩"拼浅红底"的历史用途;没有那种用法时它就是个死常量,
* 留着只为对照 color.json —— 新代码请直接用 `FbColors.red`。
*/
const RED: string = '#C0533F';
@ComponentV2
export struct FbPersonSheet {
/** 要看的人(null = 没打开) */
@Param person: FbRosterPerson | null = null;
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
/**
* 「编辑」回调(用户要求:悬浮卡片上要有一个"编辑")。
*
* ⚠️ 这里**只发事件、不自己做编辑** —— 编辑是另一个浮层(`FbPersonEditSheet`),
* 由页面决定"关掉详情、打开编辑"。浮层之间不互相引用,
* 免得出现"详情嵌编辑、编辑又嵌详情"的循环。
*/
@Event onEdit: () => void = (): void => {
};
/**
* 「删除」回调。
*
* 🔴🔴 用户 2026-10-18 原话:
* 「点击按钮后弹出这个简单框,不要加删除按钮了,删除按钮太容易误触。
* 应该在**详情页面的最下面**加一个删除按钮。」
*
* ⇒ 「删除」从"简要卡"(`FbPersonQuickSheet`)挪到了这里,并且放在
* **整张卡的最下面**、与编辑/关闭之间隔一条分隔线 ——
* 这是"危险区"的通用做法:多一层遮挡、手指不会顺手扫到。
*
* ⚠️ 与别的事件一样:**只发事件**。二次确认与调接口都在页面侧
* (那里才知道"删完怎么刷新列表")。
*/
@Event onDelete: () => void = (): void => {
};
/**
* 是否显示「删除」按钮。
*
* ⚠️ 顶栏那个「我的详情」(`TopBar.myDetailPanel`)要传 `false`:
* 删除自己既没意义、又很危险(服务端是物理删除,删完连账号都进不去)。
* 默认 `true` = 名册里的详情卡(那里的删除是正常的)。
*/
@Param canDelete: boolean = true;
/**
* 嵌入模式:宿主**自己已经是浮层**(如 `bindSheet`)时为 true。
*
* 🔴🔴 为什么必须有这个开关(2026-10-18 真机踩出来的真 bug):
* 本组件的根节点是 `Column().width('100%').height('100%')`(**铺满全屏**、
* 内部再垂直居中那张 92% 宽的卡)—— 因为它的设计宿主是
* "页面里叠一层遮罩 + Stack 居中"(名册页就是这么用的)。
*
* 而顶栏点头像想复用这个组件时,我把它塞进了 `bindSheet`:
* **sheet 要做"高度自适应内容"(`SheetSize.FIT_CONTENT`),
* 内容却要求"铺满 100% 高度"** ⇒ 测量互相打架。
* 真机症状:**每次点击 `AceSheet` 都创建了,却紧接着
* `AceDrag: Drag gesture has been canceled` —— 浮层起来立刻被关掉**
* (界面看上去"点了没反应",最容易被误判成"点击没接上")。
*
* ⇒ `embedded = true` 时:去掉 `width/height 100%` 与内层居中,
* 只留那张卡(宽度交给 sheet、高度自适应),sheet 自己负责
* 底部手势区与遮罩。
* ⚠️ 默认 `false` = 老行为,**名册/日程那几个调用点一个字都不用改**。
*/
@Param embedded: boolean = false;
/**
* 卡片外圈描边环的颜色(hex)。
*
* 不传则用**名册模块**的主题色 —— 本组件展示的是"人的资料",
* 不管从哪个页面调起(名册 / 顶栏「我的资料」),环色都该是同一个色系。
*
* ⚠️ 必须是字符串:`FbSheetRing` 的 `ringColor` 要直接当背景色用,
* 而 `FbColors.brand` 是 `Resource`(不能做 `@Param` 默认值参与运算)。
*/
@Param accent: string = '';
/** 环色:显式给了就用给的,否则取名册模块色 */
private accentColor(): string {
if (this.accent !== '') {
return this.accent;
}
const m: FbModuleDef | undefined = fbModuleOf('people');
return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand;
}
/** 一行的图标资源 */
private iconOf(name: string): Resource {
switch (name) {
case 'phone': return $r('app.media.fb_ic_user');
case 'org': return $r('app.media.fb_ic_people');
case 'birth': return $r('app.media.fb_ic_days');
case 'mail': return $r('app.media.fb_ic_notes');
case 'addr': return $r('app.media.fb_ic_resources');
case 'card': return $r('app.media.fb_ic_notes');
default: return $r('app.media.fb_ic_dot');
}
}
/**
* 生日合并成一行:`阳历 11-16(农历 十月初五)`。
*
* ⚠️ 阳历/农历**分开两个字段**,但并成一行更好读(两行都只有月日、很短)。
* 只有农历没阳历时也能显示。
*/
private birthdayLine(): string {
if (this.person === null) {
return '';
}
const solar: string = this.person.birthdaySolar;
const lunar: string = this.person.birthdayLunar;
if (solar !== '' && lunar !== '') {
return solar + '(农历 ' + lunar + ')';
}
if (solar !== '') {
return solar;
}
if (lunar !== '') {
return '农历 ' + lunar;
}
return '';
}
/**
* 多行文本块(标题 + 正文)。
*
* ⚠️ 为什么不用 `infoRow`:工作经历/地址/备注这类**多行等宽信息**
* 配一个小图标反而挤,标题一行、正文一段更清楚。
*/
@Builder
blockText(label: string, body: string) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(body)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.lineHeight(21)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 12 })
}
/** 工作经历:每段一行(单位|职务 · 职称(起止)) */
@Builder
jobsBlock() {
Column({ space: 6 }) {
Text('工作经历')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
if (this.person !== null) {
ForEach(this.person.jobs, (j: FbJobRow, idx: number) => {
Column({ space: 2 }) {
Text(this.jobText(j))
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
if (j.note !== '') {
Text(j.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}, (j: FbJobRow, idx: number): string => 'job_' + idx.toString())
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 12 })
}
/**
* 除主号之外的其它电话(逗号拼接)。
*
* ⚠️ `phones[0]` 是主号,已经在「手机号」那一行单独显示过 ⇒ 这里从下标 1 开始,
* 否则同一个号码会显示两遍。
* ⚠️ 服务端已保证"主号在前、去重",这里不再做去重(保持简单)。
*/
private otherPhones(): string {
if (this.person === null) {
return '';
}
const out: string[] = [];
for (let i = 1; i < this.person.phones.length; i++) {
out.push(this.person.phones[i]);
}
return out.join('、');
}
/**
* 头像图 URL(**空 = 用色块字回落**)。
*
* 🔴🔴 v52 修的真 bug(用户 2026-10-15:「点击详情,弹出的卡片里面**为什么没有头像**呢?」):
* 列表里画了一整天的头像,**详情浮层却没传 `imageUrl`** ——
* `FbAvatar` 的 `imageUrl` 缺省是空串,于是**所有人在详情里都退化成色块字**
* (有头像的人也显示不出来)。这是纯粹的漏传参数,不是数据问题。
* ⚠️ `avatar_url`(服务端下发的那个)是**相对路径**,`Image()` 直接用会
* 打到 App 自己的包名上 ⇒ 必须用 `fbImageSrc(key, token)` 拼成
* 带令牌的绝对地址(与列表同一套,见 `fbImageUrl` 的说明)。
*/
private avatarUrl(): string {
if (this.person === null) {
return '';
}
return fbImageSrc(this.person.avatar, FB_TOKEN_STORE.get());
}
/** 拼多行联系方式的显示串(`label: value`,label 空就只显示 value) */
private contactText(rows: FbContactRow[]): string {
const out: string[] = [];
for (const r of rows) {
out.push(r.label !== '' ? (r.label + ':' + r.value) : r.value);
}
return out.join('\n');
}
/** 地址一整行(国家 省 市 区 + 详细;空段自动跳过) */
private fullAddress(): string {
if (this.person === null) {
return '';
}
const parts: string[] = [
this.person.addrCountry, this.person.addrProvince,
this.person.addrCity, this.person.addrDistrict
];
const out: string[] = [];
for (const s of parts) {
if (s !== '') {
out.push(s);
}
}
if (this.person.address !== '') {
out.push(this.person.address);
}
return out.join(' ');
}
/** 一段工作经历的显示串(`单位 · 职务/职称(起止)`) */
private jobText(j: FbJobRow): string {
const head: string[] = [];
if (j.org !== '') {
head.push(j.org);
}
// 职务与职称可能都有("副院长 / 副教授"),都可能没有
const roles: string[] = [];
if (j.title !== '') {
roles.push(j.title);
}
if (j.rankName !== '' && j.rankName !== j.title) {
roles.push(j.rankName);
}
if (roles.length > 0) {
head.push(roles.join(' · '));
}
let line: string = head.join('|');
const span: string = this.jobSpan(j);
if (span !== '') {
line += '(' + span + ')';
}
return line;
}
/** 一段工作经历的起止(`2020-09 至今` / `2015-07 ~ 2020-08`) */
private jobSpan(j: FbJobRow): string {
const start: string = j.startYm;
const end: string = j.endYm !== '' ? j.endYm : '至今';
if (start === '' && j.endYm === '') {
return '';
}
if (start === '') {
return end;
}
return start + ' ~ ' + end;
}
/**
* 信息行(图标 + 标签 + 值)。
*
* ⚠️ `@Builder` 里**不加返回值**、参数要显式类型 —— 见项目里 ArkTS 约束。
*/
@Builder
infoRow(icon: string, label: string, value: string) {
Row({ space: 10 }) {
Image(this.iconOf(icon))
.width(15)
.height(15)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
Column({ space: 2 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Top)
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
// ⚠️ 2026-10-19:**卡片外面那一圈主题色环**由共用组件 `FbSheetRing` 给
// (用户要求"所有弹框都加线,保持一致")。
// 这一层只负责**尺寸**(92% 宽 / 72% 高上限)—— 尺寸与外观分开,
// 环组件里就不必再堆一堆 `if (embedded)`。
Column() {
FbSheetRing({
radius: FbMetric.radius,
ringColor: this.accentColor(),
/* embedded 是"塞进 bindSheet"的形态:宿主自己已有容器与投影,
再叠一层阴影会显脏(真机上看着像卡片边缘发黑)。 */
shadowRadius: this.embedded ? 0 : 24
}) {
Column({ space: 0 }) {
// ---- 顶部:头像 + 姓名 + 称谓 ----
// ⚠️ 这一块**不参与滚动**(它跟着卡片固定在顶部):
// 用户往下滑看资料时,"这是谁"始终可见,不用滚回去确认。
Row({ space: 14 }) {
FbAvatar({
name: this.person !== null ? this.person.shown : '',
color: this.person !== null ? this.person.avatarColor : '#A84A28',
dia: 60,
fontSizeVp: 24,
// 🔴 v52 补上(之前漏传 ⇒ 详情里永远看不到头像,见 avatarUrl 的说明)
imageUrl: this.avatarUrl()
})
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(this.person !== null ? this.person.shown : '')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
if (this.person !== null && this.person.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
}
.alignItems(VerticalAlign.Center)
// 本名(昵称与不同名时才有意义)
if (this.person !== null && this.person.name !== this.person.shown) {
Text('本名 ' + this.person.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
// 称谓(服务端按家族图谱推导)
if (this.person !== null && this.person.label !== '') {
FbTag({ text: this.person.label, tone: 'muted' })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 标签 ----
// ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的
// 信息,跟着资料一起滚走反而不方便。详情里的"长内容"是资料行(见下面)。
if (this.person !== null && this.person.tags.length > 0) {
Row({ space: 6 }) {
ForEach(this.person.tags, (t: string) => {
FbTag({ text: t, tone: 'blue' })
}, (t: string): string => 'pt_' + t)
}
.width('100%')
.margin({ top: 12 })
}
Divider()
.color(FbColors.line)
.margin({ top: 14, bottom: 4 })
// 🔴 **资料区可滚动**(用户 2026-10-15:「显示详细信息,并且可以往下滑」)。
//
// 为什么必须给它一个 `Scroll`:
// 卡片整体限了 `maxHeight: 78%`,而资料行(手机/单位/职务/生日阴阳历/
// 邮箱/地址/座右铭/备注)加起来很容易超过这个高度 ——
// 没有 `Scroll` 时**超出的部分直接被裁掉、看不到也够不着**。
// ⚠️ 用 `layoutWeight(1)` 让 Scroll 吃掉"头部与底部按钮之间的剩余高度",
// 而不是给固定高度 —— 内容长短不同时都能正好铺满。
// ⚠️ 底部按钮**留在 Scroll 外面**:它们是"动作",不该跟着内容滚走
// (要滑到底才能点"编辑"是很糟的体验)。
Scroll() {
Column({ space: 0 }) {
// ---- 资料 ----
// ⚠️ 每条都**只在有值时显示** —— 空行会让浮层显得"很多没填",
// 而实际上大部分人的手机/邮箱/地址本来就是空的。
if (this.person !== null && this.person.phone !== '') {
this.infoRow('phone', '手机号', this.person.phone)
}
// 🔴 补充电话(服务端下发的 `phones`,主号已在上面单独显示)
if (this.person !== null && this.person.phones.length > 1) {
this.infoRow('phone', '其他电话', this.otherPhones())
}
if (this.person !== null && this.person.org !== '') {
this.infoRow('org', '单位', this.person.org)
}
if (this.person !== null && this.person.title !== '') {
this.infoRow('org', '职务', this.person.title)
}
// 家中排行(只在有值时显示;1 = 老大)
if (this.person !== null && this.person.birthRank !== '') {
this.infoRow('org', '家中排行', '第 ' + this.person.birthRank + ' 个')
}
// 生日:阳历 + 农历(农历是服务端算的)—— 合并成一行显示更好读
if (this.person !== null && this.birthdayLine() !== '') {
this.infoRow('birth', '生日', this.birthdayLine())
}
if (this.person !== null && this.person.email !== '') {
this.infoRow('mail', '邮箱', this.person.email)
}
// 地址(结构化五级 + 详细;旧数据只有 `addrCity` 也能显示)
if (this.person !== null && this.fullAddress() !== '') {
this.infoRow('addr', '地址', this.fullAddress())
}
// 身份证(打码)—— 只显示,编辑时可改
if (this.person !== null && this.person.idCardMasked !== '') {
this.infoRow('card', '身份证', this.person.idCardMasked)
}
// ---- 补充联系方式(vCard 对齐:邮箱/地址/网址,可多行)----
if (this.person !== null && this.person.contacts.email.length > 0) {
this.blockText('其它邮箱', this.contactText(this.person.contacts.email))
}
if (this.person !== null && this.person.contacts.address.length > 0) {
this.blockText('其它地址', this.contactText(this.person.contacts.address))
}
if (this.person !== null && this.person.contacts.url.length > 0) {
this.blockText('网址', this.contactText(this.person.contacts.url))
}
// ---- 工作经历(可多段)----
if (this.person !== null && this.person.jobs.length > 0) {
this.jobsBlock()
}
// ---- 座右铭 / 公共备注 / 我的私人备注 ----
if (this.person !== null && this.person.slogan !== '') {
this.blockText('座右铭', '「' + this.person.slogan + '」')
}
if (this.person !== null && this.person.note !== '') {
this.blockText('备注', this.person.note)
}
// 🔴 「我的私人备注」与公共"备注"**是两块**(只有我看得到)
if (this.person !== null && this.person.myNote !== '') {
this.blockText('我的备注(仅自己可见)', this.person.myNote)
}
// 底部一点余量:最后一行不贴着按钮
Row().height(8)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// ---- 底部按钮:编辑 / 关闭 ----
Row({ space: 10 }) {
Text('编辑')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor('#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onEdit();
})
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.margin({ top: 18 })
/* ---- 危险区:删除联系人 ----
*
* 🔴 用户 2026-10-18:「应该在详情页面的最下面加一个删除按钮。」
* ⇒ 位置在**编辑/关闭之下**、并且与它们之间隔一条分隔线:
* 手指从上往下扫时不会顺手点到;想删必须**再往下够**一次。
* 🔴 用户 2026-10-19:「删除按钮全部用红色背景。」
* ⇒ 从"浅红底 + 红字"改成**红底白字**(与编辑/删除浮层里的
* 删除按钮同一规格;位置仍在最底部 + 有二次确认兜底)。
* ⚠️ 确认框在页面侧(`FbPeoplePage.confirmDelete`)—— 见 `onDelete` 的说明。 */
if (this.canDelete) {
Divider()
.color(FbColors.line)
.margin({ top: 14 })
Row({ space: 6 }) {
Image($r('app.media.fb_ic_trash'))
.width(14)
.height(14)
/* ⚠️ PNG 图标必须 colorFilter(fillColor 只对 SVG 生效) */
.colorFilter(fbTint(FbTint.white))
Text('删除联系人')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.fontWeight(FontWeight.Medium)
}
.width('100%')
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(FbColors.red)
.borderRadius(FbMetric.radiusS)
.margin({ top: 10 })
.onClick((): void => {
this.onDelete();
})
}
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边(92%)、四角圆角、垂直居中(由宿主的 Stack 对齐)。
//
// ⚠️ 宽度由**这一层**给(不是卡片自己):卡片在环里必须是 100%,
// 否则 0.92×0.92 ≈ 84.6%,会比别的浮层窄一圈。
// ⚠️ `embedded`(宿主自己就是 sheet)时宽度交给 sheet 决定 ——
// 再用 92% 会在 sheet 里又缩一次,看上去"卡片比容器窄一圈"。
.width('100%')
/* 最多占屏幕 72%:上方留出可见列表,暗示"这是浮层、下面还有内容"。
⚠️ 这个限制**必须写在卡片自己身上**(不能交给外面的环层):
卡片内部有 `Scroll().layoutWeight(1)`,它要靠**父容器的确定高度**
才能算出"剩下多少可以滚"。把 maxHeight 挪到外面那层,
内层高度就变成"由内容决定"⇒ `layoutWeight` 拿到 0 ⇒
**资料区直接塌成一条线**(编辑卡同理,别一起挪)。 */
.constraintSize({ maxHeight: this.embedded ? '100%' : '72%' })
// ⚠️ embedded 时**去掉底部手势区那段留白**:那 16 是给
// "贴底遮罩 + 隐藏手势条"的场景补的(见 FB_SHEET_BOTTOM_GAP),
// 而 bindSheet 自己已经把手势区让出来了 ⇒ 再补就是白白多一截空白。
.padding({
left: 18, right: 18, top: 18,
bottom: this.embedded ? 20 : 20 + FB_SHEET_BOTTOM_GAP
})
.alignItems(HorizontalAlign.Start)
}
}
.width(this.embedded ? 'auto' : '92%')
}
/* 🔴 embedded(宿主自己就是 sheet)时**不能再铺满 + 居中**:
内容是"按自适应高度"交给 sheet 的,铺 100% 高会与
`SheetSize.FIT_CONTENT` 互相打架 ⇒ 浮层起来立刻被关(真机实测)。 */
.width(this.embedded ? 'auto' : '100%')
.height(this.embedded ? 'auto' : '100%')
.justifyContent(this.embedded ? FlexAlign.Start : FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}