/** * 家书 FamilyBook · 人员详情浮层(鸿蒙端) * * 点名册里的一个人 → 弹出他的完整资料(头像 / 姓名 / 称谓 / 单位职务 / * 手机 / 生日(阳历+农历)/ 邮箱 / 地址 / 座右铭 / 标签 / 备注)。 * * 🔴 为什么做成"浮层"而不是新页面: * 与观影的详情浮层同一个理由(见 `FbMovieDetailSheet` 的文件头): * · 用户在名册里"随手点开看看",看完就关 —— 跳页面会丢失列表的滚动位置, * 回来还得重新找; * · 浮层是**页面内的覆盖层**,不动导航栈,关掉即回到原处。 * ⚠️ 但它**不是**系统弹窗(`bindSheet`/`AlertDialog`)—— * 那些会带系统边框与动画,且塞不进我们要的自定义布局。 * * 数据来源(🔴 v52 重写,用户 2026-10-15): * 用户要求「**完全脱离电脑端**,所有信息都显示出来」⇒ 详情不再"用列表那份凑合": * · 列表(`/api/v1/roster`)只给画一栏要用的字段; * · 详情**单独拉 `GET /api/v1/people/`** —— 它多带 * **工作经历 / 补充联系方式(按类型)/ 私人备注 / 结构化地址五级 / * 身份证(打码) / 头像 URL / 能不能编辑**。 * ⚠️ 旧注释里写"`/people/` 字段并不比 roster 多" —— 那是当时的事实, * v52 起**已经不成立**(服务端这次把详情接口补全了)。 * ⚠️ 断开网络时退回"用列表里那份"(`person` 本来就是列表那份), * 只是缺那几项详情专属字段,界面不会空。 */ import { FbColors, FbMetric } from '../common/Theme'; import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo'; import { FbAvatar, FbTag } from './Widgets'; import { fbImageUrl } from '../data/FbHttp'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; /** * 贴底卡片的手势区缓冲(vp)。 * * 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)—— * 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段, * 否则最后一排按钮会被"从下往上滑"误触。 */ const FB_SHEET_BOTTOM_GAP: number = 16; @ComponentV2 export struct FbPersonSheet { /** 要看的人(null = 没打开) */ @Param person: FbRosterPerson | null = null; /** 关闭回调 */ @Event onClose: () => void = (): void => { }; /** * 「编辑」回调(用户要求:悬浮卡片上要有一个"编辑")。 * * ⚠️ 这里**只发事件、不自己做编辑** —— 编辑是另一个浮层(`FbPersonEditSheet`), * 由页面决定"关掉详情、打开编辑"。浮层之间不互相引用, * 免得出现"详情嵌编辑、编辑又嵌详情"的循环。 */ @Event onEdit: () => void = (): void => { }; /** 一行的图标资源 */ 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 自己的包名上 ⇒ 必须用 `fbImageUrl(key, token)` 拼成 * 带令牌的绝对地址(与列表同一套,见 `fbImageUrl` 的说明)。 */ private avatarUrl(): string { if (this.person === null) { return ''; } return fbImageUrl(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) .fillColor(FbColors.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() { 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-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 // ⇒ 不再贴底满宽:左右留边(92%)、四角圆角、垂直居中(由宿主的 Stack 对齐)。 .width('92%') // 最多占屏幕 72%:上方留出可见列表,暗示"这是浮层、下面还有内容" .constraintSize({ maxHeight: '72%' }) .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radius) .alignItems(HorizontalAlign.Start) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .hitTestBehavior(HitTestMode.Transparent) } }