Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbPersonSheet.ets
T
laoyang 4a063114c5 v62:修 P0 登录被踢 / 关闭按钮统一右上角 / 观影三处 / 影院管理补齐 / 名册三处
用户 2026-10-19 第二批八条(原话逐条对应):

═══ ⑨ P0:「现在还是不稳定,会退出,提示:这台设备已经被退出登录,请重新登录。」

真因**是我 v61 自己埋的**:「个人设置」页(`FbAccountPage`)里两处 `fbApi`
调用**取了 `token` 变量却没传进请求选项** ⇒ 没有 Authorization 头 ⇒ 服务端 401
⇒ `FbHttp` 的全局回调 `unauthorizedCb` **清掉本地令牌 + 踢回登录页**。
而本页在 `aboutToAppear` 里就调它 ⇒ **点一下右上角头像,App 就退出登录**。

⚠️ 我上一轮把同一现象归因成"残留旧令牌 / 设备会话被顶掉"(还去线上查了
   `api_tokens` 表,一切正常)—— **那个结论是错的**,等于把 bug 归给了环境。

修法(两处调用 + **两道架构防线**,缺一不可):
  ① 修两处调用,把 token 传进去;
  ② `fbApi` 内部:**本地有令牌、调用方又没传时自动补上**
     (大部分接口本来就要鉴权,补上是"纠正";`/login`、`/ping` 带了也无害);
  ③ **401 只在"确实带了令牌"时才算被踢** —— 否则"没带令牌"也会触发全局清令牌,
     那正是这次事故的机制。

═══ ④「你这个按钮到底是在弹框的右上角关闭,还是在下面关闭……全放在右上角。」

选**右上角 ✕**(底部已被 编辑/保存/删除 占满,再塞关闭会堆三四个按钮,分不清主操作)。
新增 `components/FbSheetClose.ets`:
  · `FbSheetClose`  —— 32vp 圆形 ✕(热区 40+,小图标在手机上点不中);
  · `FbSheetTitleBar` —— 左标题 + 右收尾(设置类给 `confirmText` 显示「完成」)。
9 个浮层全接;名册详情 / 日程详情 / 简要卡**底部不再有「关闭」**。
⚠️ 浅色底走 `fbAlpha`,**不能拼 `+ '14'`** —— ArkUI 八位色是 `#AARRGGBB`
   (本项目曾因写 `#00000066` 导致遮罩**从来没显示过**)。

═══ ①「观影……弹出的框左上角有一条灰色的线,不知道是什么。」

那是浮层顶部的**抽屉把手**(36×4 的 `line2` 浅灰短线)。本意是"可下拉关掉"的提示,
但 ① 它落在左上角、旁边没有文字;② 这个浮层的关闭本来就不靠下拉。
⇒ 用户认不出它 = 它没起作用,**删掉**(不改成居中 —— 那只是把灰线挪个位置)。

═══ ②「然后这个观影人的头像并没有带进来。」

同看人的 `FbAvatar` **漏传 `imageUrl`** —— 服务端 `Movie::attendees()` 本来就
`SELECT p.avatar`、端上 `parseAttendees` 也解析进了 `a.avatar`,
纯粹是**渲染时忘了用它** ⇒ 有头像的人也退化成色块。
⚠️ 与名册详情 2026-10-15 那个是同一个 bug(当时是 `FbPersonSheet` 漏传)。

═══ ③「点击这个图片呢,不能左右滑动看所有的这个图片。」

全屏查看器原来**只有底部「上一张/下一张」文字按钮**。现在整张大图左右滑翻页。
⚠️ 用 `.gesture(PanGesture(Horizontal))` 而不是 `SwipeGesture`:
   `SwipeGesture` 要"快速划动"才识别,在有滚动容器的页面容易被先吃掉
   (日程页实测:`gesture`/`priorityGesture` 都不触发)。`PanGesture` 看**累计位移**。
⚠️ 判定要求 `|dx| >= 40 && |dx| > |dy|`,否则竖划会被误判成翻页。

═══ ⑤「影院管理……增加影院呢、修改影院呢、高级搜索、统计、年度报告,这些全部都应该补充进去。」

端上原来**只有一个只读列表**(连"新增"都没有)。补成四个页签:
  · **影院**:新增 / 修改 / 删除(服务端 CRUD 早就有,端上一直没接)+ 删除二次确认;
  · **统计**:总览(看过/影院/人数/人次)+ 按影院/按年/按月的条形图;
  · **搜索**:片名 / 影院 / 起止日期(条件全空时不查,与网页 `hasCond` 同行为);
  · **年报**:按年切换(第一场/最后一场、最常去的影院、一起看最多的人、每月分布)。
⚠️ **统计/年报必须联网**(`/movies/stats|report`):口径要与网页**完全一致**
   (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分",
   少同步一次就少一部 ⇒ 用户会发现"网页 124 部、App 121 部"。
   ⇒ 断网时明说"需要联网",而不是给一个偏低的数字。
⚠️ 影院写操作后**立刻回写本地库**(`refreshCinemas`),否则刚建的那家要等下次同步
   才出现,看着像失败;并用 `INSERT OR REPLACE` 而非"清空再插"
   (中途失败会留下一个空表 = 用户的影院列表整个消失)。

═══ ⑥「名册搜索,搜索后,下面有一个白色的框,什么内容都没有,删除。」

那是列表**末尾给悬浮栏留高度的占位块**(`height(FB_DOCK_RESERVE)`),
底色是 `FbColors.surface`(**白**)而列表容器是 `paper`(**米**)——
列表内容一短(搜索只剩一两条)它就露出来,看着像"什么都没有的白框"。
⇒ 改底色与列表同色。**不能删**:删了最后一张卡片会被悬浮栏压住。

═══ ⑦「如果一个人没头像,在列表是有一个文字的头像的,那么点击后弹框,也应该有。」

`FbAvatar` 原来是 `if/else`:有 `imageUrl` 就**只画 Image**,
而图片**加载失败时 `Image` 什么都不显示**(只露出纯色底)⇒ 看着是个光板。
(`alt()` 只在**加载中**起作用,失败后是空白。)
⇒ 改成**叠加**:底色 + 文字头像**常驻**,图片有就叠在上面
(加载成功即盖住文字,失败则自动露出文字头像)。
⚠️ 之前注释说"图层叠会错位"——那是因为当时想靠 Image 的 intrinsic size 撑尺寸;
   现在两层都显式给 `dia`,尺寸来源唯一,不存在错位。

═══ ⑧「现在名册的列表太空了,应该显示更多内容,例如工作单位、职务。」

列表卡片加一行「单位|职务」(独立成行,避免与标签/生日挤)。
⚠️ 用 `persons.org` / `persons.title` 而**不是** `person_jobs`:
   列表读的是**本地库**,而本地库只存 `persons` 那一行
   (`person_jobs` 是一对多,端上没同步那张表)⇒ 服务端加了字段也到不了列表。
   线上实测 801 人里 192 人有单位、48 人有职务,列表立刻有内容。

验证:
· 编译 BUILD SUCCESSFUL
· ArkTS 守卫 536/0(新增整组 groupV62,含 P0 两道防线)
· 反向验证 51/0(新增 13 条注入,逐条确认能变红)
· Schema 列守卫 12/0
· **真机 22/0**(6 组,含照片横滑 `1/5 → 2/5`、四个页签、点头像不被踢)
2026-10-19 15:27:31 +08:00

635 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 { FbSheetClose } from './FbSheetClose';
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)
.flexShrink(1)
if (this.person !== null && this.person.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
// 本名(昵称与不同名时才有意义)
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)
/* 🔴 关闭统一到**右上角**(用户 2026-10-19:全应用只留一种关闭位置)。
⚠️ 与头像**同一行**:原来底部那个「关闭」按钮已删 ——
它和「编辑」并排等宽,用户反馈"分不清哪个是主操作"。
⚠️ `alignItems` 用 `Top`:头像 60 高,✕ 32 高,
居中会让它离卡片顶边太远(看着像浮在名字旁边)。 */
FbSheetClose({
onClose: (): void => {
this.onClose();
},
color: this.accentColor()
})
}
.width('100%')
.alignItems(VerticalAlign.Top)
// ---- 标签 ----
// ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的
// 信息,跟着资料一起滚走反而不方便。详情里的"长内容"是资料行(见下面)。
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();
})
}
.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)
}
}