Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbPersonSheet.ets
T
laoyang 776c95ba0b v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据
从「演示数据壳子」走到「真的能连家里的服务器用」。

登录(用户 2026-10-17「完整的登录功能」)
  · 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
  · 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
  · FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
    设备名取机型(marketName),并上报 platform / app_version

设备会话(v57,用户 2026-10-17)
  · 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
  · 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
    (离线超 24h → 只锁界面、不清数据、联网自动恢复)——
    混成一种就会违背用户要的"一直联网就不用每天输密码"
  · 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
    让登录/校验类请求跳过,避免"两个地方同时跳登录页")
  · 退出登录:先清本地令牌(主防线),再尽力通知服务端
  · 设置页显示版本号与「这台设备」

数据层
  · FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
  · 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
  · 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致

验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
2026-10-18 03:36:04 +08:00

481 lines
19 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 } 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)
}
}