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)。
This commit is contained in:
2026-10-18 03:36:04 +08:00
parent 7eaa641437
commit 776c95ba0b
64 changed files with 18297 additions and 1519 deletions
@@ -0,0 +1,481 @@
/**
* 家书 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)
}
}