用户这一批九条,逐条对应:
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 组全过);像素采样确认浮层主题色环实测存在
631 lines
28 KiB
Plaintext
631 lines
28 KiB
Plaintext
/**
|
||
* 家书 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)
|
||
}
|
||
} |