2026-10-11 22:07:48 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 FamilyBook · 顶栏(鸿蒙端)
|
|
|
|
|
|
*
|
2026-10-12 10:06:55 +08:00
|
|
|
|
* 用户 2026-10-12 定的形态:
|
|
|
|
|
|
* · **应用图标 + 「家书」标题**贴左上(下行小字显示当前模块);
|
2026-10-12 12:06:00 +08:00
|
|
|
|
* · **点图标 = 开抽屉**(用户要求:汉堡按钮去掉后,把这个功能挪到图标上);
|
2026-10-12 10:06:55 +08:00
|
|
|
|
* · 顶栏底色**白色**(与下面的个人信息卡同色,连成一片);
|
2026-10-12 12:06:00 +08:00
|
|
|
|
* · 顶栏**并入页面滚动流**,向上滑动时跟着内容一起滑走。
|
2026-10-11 22:07:48 +08:00
|
|
|
|
*
|
2026-10-12 12:06:00 +08:00
|
|
|
|
* 🔴 开抽屉的三个入口:
|
|
|
|
|
|
* ① **点顶栏图标**(本组件)—— 用户新增要求;
|
|
|
|
|
|
* ② **应用内水平滑动**(向右滑→左边出、向左滑→右边出,见 `Drawer.ets`);
|
|
|
|
|
|
* ③ 底部**悬浮快捷栏**(首页几个常用模块直达,见 `Pages` 里的 FloatDock)。
|
|
|
|
|
|
* ⚠️ 屏幕边缘是**系统手势区**,应用收不到,所以不做边缘滑动。
|
2026-10-11 23:20:49 +08:00
|
|
|
|
*
|
2026-10-12 10:06:55 +08:00
|
|
|
|
* ⚠️ 顶栏**不放**「设置」—— 与 Web 端一致:设置收在抽屉底部的账号区里。
|
2026-10-11 22:07:48 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-18 20:14:40 +08:00
|
|
|
|
import { FbColors, FbIcon, FbMetric, FbTint, fbTint } from '../common/Theme';
|
2026-10-11 22:07:48 +08:00
|
|
|
|
import { FbAppState, fbAppState } from '../common/Store';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { FbSyncState, SYNC_DONE, SYNC_FAIL, SYNC_RUNNING, fbSyncState } from '../common/FbSyncStore';
|
2026-10-11 22:07:48 +08:00
|
|
|
|
import { fbModuleOf } from '../model/Modules';
|
|
|
|
|
|
import { FbAvatar } from './Widgets';
|
2026-10-18 20:58:40 +08:00
|
|
|
|
import { FbPersonSheet } from './FbPersonSheet';
|
|
|
|
|
|
// ⚠️ `inst()` 拿单例(本工程所有 repo 都是这个约定,见 FbPersonRepo 文件头);
|
|
|
|
|
|
// `FB_TOKEN_STORE` 在 **FbTokenStore.ets** 里(不是在 FbDeviceStore)。
|
|
|
|
|
|
import { FbPersonRepo, FbRosterPerson } from '../data/FbPersonRepo';
|
|
|
|
|
|
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { syncAll } from '../data/FbSyncAll';
|
2026-10-18 20:58:40 +08:00
|
|
|
|
import { BusinessError } from '@kit.BasicServicesKit';
|
2026-10-18 16:01:41 +08:00
|
|
|
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
2026-10-11 22:07:48 +08:00
|
|
|
|
|
|
|
|
|
|
@ComponentV2
|
|
|
|
|
|
export struct FbTopBar {
|
|
|
|
|
|
@Param app: FbAppState = fbAppState();
|
|
|
|
|
|
/** 右侧额外操作(各模块页自己往里塞「新建」「搜索」等按钮) */
|
|
|
|
|
|
@BuilderParam actions: () => void;
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 同步状态(顶栏图标 + 面板共用)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 用 `@Local` 持有单例引用(而不是每处都调 `fbSyncState()`):
|
|
|
|
|
|
* `AppStorageV2.connect` 每次调用都会做一次连接查找,在 build 里反复调用很浪费;
|
|
|
|
|
|
* 而且拿到的本来就是同一个实例。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Local sync: FbSyncState = fbSyncState();
|
|
|
|
|
|
/** 同步面板是否打开 */
|
|
|
|
|
|
@Local syncSheet: boolean = false;
|
|
|
|
|
|
|
2026-10-18 20:58:40 +08:00
|
|
|
|
/** 「我的详情」浮层是否打开(点头像触发) */
|
|
|
|
|
|
@Local myDetailSheet: boolean = false;
|
|
|
|
|
|
/** 「我的详情」里的人(null = 还没取到) */
|
|
|
|
|
|
@Local myDetail: FbRosterPerson | null = null;
|
|
|
|
|
|
/** 「我的详情」是否在加载 */
|
|
|
|
|
|
@Local myDetailLoading: boolean = false;
|
|
|
|
|
|
/** 「我的详情」的兜底提示(拿不到资料时显示) */
|
|
|
|
|
|
@Local myDetailNote: string = '';
|
|
|
|
|
|
|
2026-10-11 22:07:48 +08:00
|
|
|
|
build() {
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// 🔴 顶栏横向镜像 —— 「质感握姿」(用户 2026-10-16 第 4 条):
|
|
|
|
|
|
// 左手握时家书图标在**左**,右手握时它跑到**右**,方便单手点它开抽屉。
|
|
|
|
|
|
// ⚠️ 用 `Flex(direction: RowReverse)` 整体镜像,而不是逐个改 padding/margin ——
|
|
|
|
|
|
// 后者要同时动 4 处、还会把"标题靠左"这类布局搞乱。
|
|
|
|
|
|
// ⚠️ 但**图标不能真的反着画**(`RowReverse` 只改主轴顺序、不翻转图形本身),
|
|
|
|
|
|
// 所以镜像后位置对、图形仍正。
|
|
|
|
|
|
Flex({
|
|
|
|
|
|
direction: this.app.drawerSide === 'right' ? FlexDirection.RowReverse : FlexDirection.Row,
|
|
|
|
|
|
alignItems: ItemAlign.Center
|
|
|
|
|
|
}) {
|
|
|
|
|
|
// ---- 应用图标(**点它开抽屉**;左右握姿时自动换边)----
|
2026-10-12 10:06:55 +08:00
|
|
|
|
// ⚠️ 必须给描边:logo 是「米色底(74%) + 砖红图案(24%)」的实心方图,
|
|
|
|
|
|
// 放在白色/米色上时底板与背景几乎同色,只剩砖红图案发飘、看不清。
|
|
|
|
|
|
// 一层极浅的描边就能把底板"托"出来(本机范本 SyncCalendar 也给了描边)。
|
2026-10-12 12:06:00 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 🔴 点击事件直接挂在 Image 上,**不套 Button** ——
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// Button 自带最小尺寸与内边距,会把图标挤离边线(用户要"图标靠边")。
|
2026-10-12 08:43:32 +08:00
|
|
|
|
Image($r('app.media.fb_logo'))
|
|
|
|
|
|
.width(30)
|
|
|
|
|
|
.height(30)
|
|
|
|
|
|
.borderRadius(8)
|
2026-10-12 10:06:55 +08:00
|
|
|
|
.border({ width: 1, color: FbColors.line })
|
2026-10-12 08:43:32 +08:00
|
|
|
|
.flexShrink(0)
|
2026-10-12 12:06:00 +08:00
|
|
|
|
.onClick(() => {
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// 点图标 ⇒ 开抽屉(方向由**握姿**定,见 common/FbGrip.ets)
|
2026-10-12 12:06:00 +08:00
|
|
|
|
this.app.drawerOpen = true;
|
|
|
|
|
|
})
|
2026-10-12 08:43:32 +08:00
|
|
|
|
|
2026-10-11 22:07:48 +08:00
|
|
|
|
Column({ space: 0 }) {
|
2026-10-12 08:43:32 +08:00
|
|
|
|
Text('家书')
|
|
|
|
|
|
.fontSize(19)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
2026-10-11 22:07:48 +08:00
|
|
|
|
.fontColor(FbColors.ink)
|
2026-10-11 23:20:49 +08:00
|
|
|
|
.maxLines(1)
|
2026-10-12 10:06:55 +08:00
|
|
|
|
// 副标题显示当前模块名 —— "品牌 + 当前位置"一行说清
|
2026-10-12 08:43:32 +08:00
|
|
|
|
Text(this.moduleName())
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
2026-10-12 10:06:55 +08:00
|
|
|
|
.fontColor(FbColors.muted)
|
2026-10-12 08:43:32 +08:00
|
|
|
|
.maxLines(1)
|
2026-10-11 22:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
2026-10-18 03:35:39 +08:00
|
|
|
|
.margin({ left: 10, right: 10 })
|
2026-10-11 22:07:48 +08:00
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 右:各模块自己的操作 ----
|
|
|
|
|
|
this.actions()
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/* 🔴 v59(用户 2026-10-17):「应该在那个右上角的那个头像和提醒前面加一个
|
|
|
|
|
|
同步的进度的这样一个按钮或者这样一个图示」。
|
|
|
|
|
|
⇒ 就放在这里:模块操作之后、铃铛之前。三态一眼可辨:
|
|
|
|
|
|
· 同步中 → 转圈
|
|
|
|
|
|
· 有失败 → 红色感叹号
|
|
|
|
|
|
· 其余 → 灰色对勾("已是最新"或"还没同步",点开面板看详情)
|
|
|
|
|
|
⚠️ 进度/状态**直接读 `this.sync` 的 @Trace 字段**,不要写成
|
|
|
|
|
|
`this.syncTitle()` 这种方法调用 —— 方法里读 @Trace 不建立观察关系,
|
|
|
|
|
|
图标会一直停在初始态(本项目在 @Builder 多值参数上栽过 6 次)。 */
|
|
|
|
|
|
Button({ type: ButtonType.Circle }) {
|
|
|
|
|
|
if (this.sync.phase === SYNC_RUNNING) {
|
|
|
|
|
|
LoadingProgress()
|
|
|
|
|
|
.width(20)
|
|
|
|
|
|
.height(20)
|
|
|
|
|
|
.color(FbColors.brand)
|
|
|
|
|
|
} else if (this.sync.phase === SYNC_FAIL) {
|
|
|
|
|
|
Text('!')
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor(FbColors.red)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Image(FbIcon.ui('check'))
|
|
|
|
|
|
.width(17)
|
|
|
|
|
|
.height(17)
|
2026-10-18 20:14:40 +08:00
|
|
|
|
/* ⚠️ 图标染色必须走 `colorFilter` —— 本工程图标全是 PNG,
|
|
|
|
|
|
而 `fillColor` **只对 SVG 生效**(SDK image.d.ts 原话)。
|
|
|
|
|
|
详见 `Theme.fbTint` 的说明。 */
|
|
|
|
|
|
.colorFilter(fbTint(FbTint.muted))
|
2026-10-18 16:01:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width(36)
|
|
|
|
|
|
.height(36)
|
|
|
|
|
|
.backgroundColor(Color.Transparent)
|
|
|
|
|
|
.stateEffect(true)
|
2026-10-18 20:14:40 +08:00
|
|
|
|
// 与右侧铃铛拉开(用户 2026-10-18:「提醒的按钮距离头像的按钮太近了」,
|
|
|
|
|
|
// 三颗按同一个间距排,见 FbMetric.gapTopActions 的说明)
|
|
|
|
|
|
.margin({ left: FbMetric.gapTopActions })
|
2026-10-18 16:01:41 +08:00
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.syncSheet = true;
|
|
|
|
|
|
})
|
|
|
|
|
|
.bindSheet(this.syncSheet, this.syncPanel(), {
|
|
|
|
|
|
height: SheetSize.FIT_CONTENT,
|
|
|
|
|
|
dragBar: true,
|
|
|
|
|
|
showClose: false,
|
|
|
|
|
|
backgroundColor: FbColors.surface,
|
|
|
|
|
|
onDisappear: (): void => {
|
|
|
|
|
|
// 点遮罩/下滑关闭时复位,否则下次点不开
|
|
|
|
|
|
this.syncSheet = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-11 22:07:48 +08:00
|
|
|
|
// 通知铃铛(未读角标)
|
2026-10-18 20:14:40 +08:00
|
|
|
|
//
|
|
|
|
|
|
// 🔴 v59(用户 2026-10-18):「现在提醒的按钮距离头像的按钮太近了。」
|
|
|
|
|
|
// ⇒ 两个按钮之间给一段**明确的**间距(原来它们各自 36 宽、紧挨着,
|
|
|
|
|
|
// 角标又挂在铃铛右上角、视觉上直接压到头像上)。
|
|
|
|
|
|
// ⚠️ 用 `margin.left/right` 拉开而不是塞一个 `Blank()`:
|
|
|
|
|
|
// 镜像布局(右手握时整行反序)下 Blank 的弹性会跑到另一侧,
|
|
|
|
|
|
// 间距就没了;固定 margin 两边对称、镜像后仍然成立。
|
2026-10-11 22:07:48 +08:00
|
|
|
|
Stack({ alignContent: Alignment.TopEnd }) {
|
|
|
|
|
|
Button({ type: ButtonType.Circle }) {
|
|
|
|
|
|
Image(FbIcon.ui('bell'))
|
|
|
|
|
|
.width(18)
|
|
|
|
|
|
.height(18)
|
2026-10-18 20:14:40 +08:00
|
|
|
|
/* ⚠️ 同上:PNG 图标染色必须用 colorFilter(fillColor 对 PNG 无效)。 */
|
|
|
|
|
|
.colorFilter(fbTint(FbTint.muted))
|
2026-10-11 22:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width(36)
|
|
|
|
|
|
.height(36)
|
|
|
|
|
|
.backgroundColor(Color.Transparent)
|
2026-10-11 23:20:49 +08:00
|
|
|
|
.stateEffect(true)
|
2026-10-11 22:07:48 +08:00
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.app.switchModule('__notifications');
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (this.app.unread > 0) {
|
|
|
|
|
|
Text(this.app.unread > 99 ? '99+' : this.app.unread.toString())
|
|
|
|
|
|
.fontSize(10)
|
|
|
|
|
|
.fontColor(Color.White)
|
|
|
|
|
|
.backgroundColor(FbColors.red)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
|
|
|
|
|
.margin({ top: 2, right: 0 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-18 20:14:40 +08:00
|
|
|
|
// 与左边的同步图标、右边的头像各拉开一段(用户:太近了)
|
|
|
|
|
|
.margin({ left: FbMetric.gapTopActions, right: FbMetric.gapTopActions })
|
2026-10-11 22:07:48 +08:00
|
|
|
|
|
2026-10-18 20:58:40 +08:00
|
|
|
|
// 头像 —— 点它打开**我自己的详情**
|
2026-10-18 20:14:40 +08:00
|
|
|
|
//
|
2026-10-18 20:58:40 +08:00
|
|
|
|
// 🔴🔴 v59b(用户 2026-10-18):「右上角,显示的应该是登录者的头像,
|
|
|
|
|
|
// **点击头像进入我的详情页面**。」
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 原来这里**根本没有 onClick**(只有一句写着"点开账号菜单"的注释)——
|
|
|
|
|
|
// 用户点了没任何反应,而代码里连一个"账号菜单"都不存在。
|
|
|
|
|
|
// ⇒ 现在接的是**详情浮层**(与名册里点某个人同一个组件、同一套数据源)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 🔴 头像**已经是登录者本人的**(`session.me.avatarUrl` 由 `/login` 与
|
|
|
|
|
|
// `/me` 两处给,见 FbLogin/FbSession)—— 用户这条要求上一轮就已满足,
|
|
|
|
|
|
// 真正缺的是"点了能进去"。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 数据取自**本地库**(`FbPersonRepo.inst().byId`),不是名册列表里那份:
|
|
|
|
|
|
// "我"常常**不在自己的名册**里(名册是"我挑出来的那一小撮"),
|
|
|
|
|
|
// 从名册那批里找会静默落空 ⇒ 点了没反应(最难查的那种失败)。
|
2026-10-11 22:07:48 +08:00
|
|
|
|
FbAvatar({
|
|
|
|
|
|
name: this.app.session.me.name,
|
|
|
|
|
|
color: this.app.session.me.avatarColor,
|
|
|
|
|
|
dia: 32,
|
2026-10-18 20:14:40 +08:00
|
|
|
|
fontSizeVp: 14,
|
|
|
|
|
|
imageUrl: this.app.session.me.avatarUrl !== undefined
|
|
|
|
|
|
? this.app.session.me.avatarUrl
|
|
|
|
|
|
: ''
|
2026-10-11 22:07:48 +08:00
|
|
|
|
})
|
2026-10-18 20:58:40 +08:00
|
|
|
|
// ⚠️ `FbAvatar` 的布局尺寸在画布内(`Image` 自己撑圆),外层要显式
|
|
|
|
|
|
// 给一个可点区域 —— 32 的头像直接当热区在真机上偏小(手指容易点空)。
|
|
|
|
|
|
.width(40)
|
|
|
|
|
|
.height(40)
|
|
|
|
|
|
.margin({ left: FbMetric.gapTopActions })
|
|
|
|
|
|
.onClick((): void => {
|
|
|
|
|
|
this.openMyDetail();
|
|
|
|
|
|
})
|
|
|
|
|
|
// 「我的详情」浮层 —— 复用名册那个人详情组件(同一个组件、同一套字段)
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 用 `bindSheet` 而不是自己在 `build` 里叠一层遮罩:
|
|
|
|
|
|
// 这条顶栏在**每个模块页**都会渲染(`topBarSlot`),
|
|
|
|
|
|
// 自己叠遮罩要处理"盖住悬浮栏 / 盖住抽屉 / 手势返回"一堆事;
|
|
|
|
|
|
// bindSheet 是系统能力,这些它都管(同步面板也是这么做的)。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ `onDisappear` 必须复位 `myDetailSheet`(点遮罩/下滑关闭都会走它),
|
|
|
|
|
|
// 否则下次点头像**打不开**(状态位还是 true 但面板已经不在)。
|
|
|
|
|
|
.bindSheet(this.myDetailSheet, this.myDetailPanel(), {
|
|
|
|
|
|
height: SheetSize.FIT_CONTENT,
|
|
|
|
|
|
dragBar: true,
|
|
|
|
|
|
showClose: false,
|
|
|
|
|
|
backgroundColor: FbColors.surface,
|
|
|
|
|
|
onDisappear: (): void => {
|
|
|
|
|
|
this.myDetailSheet = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-10-11 22:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// 🔴 高度 = **状态栏高度 + 顶栏本身高度**(用户 2026-10-13 要"全沉浸")。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 为什么必须自己加这一段:
|
|
|
|
|
|
// 应用开了 `setWindowLayoutFullScreen(true)`(见 EntryAbility)——
|
|
|
|
|
|
// 窗口**不再为状态栏让位**,内容会真的画到状态栏底下。
|
|
|
|
|
|
// ⇒ 顶栏若不自己让出这一截,"家书"标题会被时间/信号图标压住。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 让位用 **padding.top**(不是 margin):这样**白底会一并覆盖状态栏区域**,
|
|
|
|
|
|
// 静止时看起来通知栏与顶栏是一整块;而向上滑动时,
|
|
|
|
|
|
// 这一整块(含白底)随内容滚走,下面的内容直接顶到屏幕最上沿 ——
|
|
|
|
|
|
// 这就是用户要的"内容从手机边缘滑上去"的观感。
|
|
|
|
|
|
// ⚠️ `statusBarH` 是 `@Trace` 的,量出来(异步)后顶栏会**自动重排**;
|
|
|
|
|
|
// 量不到时为 0,标题贴到最上面(仍可读),不会把内容推到屏幕中间。
|
|
|
|
|
|
.height(FbMetric.topBarH + this.app.statusBarH)
|
2026-10-12 11:37:45 +08:00
|
|
|
|
// 左内边距=pagePad(与下面各区块的左边线对齐);右侧 10(图标可略贴边)
|
2026-10-18 03:35:39 +08:00
|
|
|
|
.padding({
|
|
|
|
|
|
left: FbMetric.pagePad,
|
|
|
|
|
|
right: 10,
|
|
|
|
|
|
top: this.app.statusBarH + 4,
|
|
|
|
|
|
bottom: 4
|
|
|
|
|
|
})
|
|
|
|
|
|
// 顶栏底色 = 白色(与下面的个人信息卡同色)。
|
|
|
|
|
|
// ⚠️ 它会覆盖上面 `padding.top` 让出来的状态栏区域 —— 这是**有意为之**:
|
|
|
|
|
|
// 静止时通知栏与顶栏连成一整块白(用户 2026-10-12 定的"融为一体"),
|
|
|
|
|
|
// 滚动时再一起滑走。**不要**改成"顶栏不设背景色、透出页面底色",
|
|
|
|
|
|
// 那样静止时会变成"米色通知栏 + 白色顶栏"上下拼接(踩过这个坑)。
|
2026-10-12 10:06:55 +08:00
|
|
|
|
.backgroundColor(FbColors.surface)
|
|
|
|
|
|
// 底线:与下方内容(同是白色卡)之间给一条极浅的分隔,避免"两块白糊在一起"
|
|
|
|
|
|
.border({ width: { bottom: 1 }, color: FbColors.line2 })
|
2026-10-11 22:07:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private moduleName(): string {
|
|
|
|
|
|
const m = fbModuleOf(this.app.activeModule);
|
|
|
|
|
|
return m !== undefined ? m.name : '家书';
|
|
|
|
|
|
}
|
2026-10-18 16:01:41 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 手动同步(面板里的按钮)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 已经在跑就直接返回:`syncAll` 自己不做并发保护,
|
|
|
|
|
|
* 连点两下会并发拉同一批数据(浪费流量、还可能把水位写乱)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private runSync(): void {
|
|
|
|
|
|
if (this.sync.isRunning()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
syncAll().catch((err: Object): void => {
|
|
|
|
|
|
hilog.warn(0x0000, 'FamilyBook', '手动同步失败:%{public}s', JSON.stringify(err));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 20:58:40 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 打开「我」的详情浮层(点右上角头像触发)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v59b(用户 2026-10-18):「点击头像进入我的详情页面。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么**先打开浮层、再补数据**(而不是等网络回来才开):
|
|
|
|
|
|
* 本地优先是本应用的立身之本 —— 点一下要**立刻**有东西出来,
|
|
|
|
|
|
* 断网时也必须能看。本地库那份已经够画详情的前几段,
|
|
|
|
|
|
* 网络回来后再原地补全(`fetchDetail`)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 失败时**不弹错误**:`me.id` 拿不到(未登录/数据还没同步下来)就只弹一句
|
|
|
|
|
|
* "资料还没同步下来",而不是让用户对着一个空白浮层猜。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async openMyDetail(): Promise<void> {
|
|
|
|
|
|
const meId: number = this.app.session.me.id;
|
|
|
|
|
|
if (meId <= 0) {
|
|
|
|
|
|
this.myDetailNote = '还没拿到你的资料,请先同步一次';
|
|
|
|
|
|
this.myDetailSheet = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* ⚠️ 先开浮层(`myDetailSheet = true`)再 await:
|
|
|
|
|
|
否则网络慢时用户点了一下**几十毫秒内毫无反应**,会以为按钮坏了。
|
|
|
|
|
|
`loading` 由浮层自己显示,观感上"点开就有东西"。 */
|
|
|
|
|
|
this.myDetailLoading = true;
|
|
|
|
|
|
this.myDetail = null;
|
|
|
|
|
|
this.myDetailSheet = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const local: FbRosterPerson | null = await FbPersonRepo.inst().byId(meId);
|
|
|
|
|
|
this.myDetail = local;
|
|
|
|
|
|
this.myDetailLoading = false;
|
|
|
|
|
|
// 本地有 ⇒ 顺手用网络那份补全(口径与名册里点某人完全一致)
|
|
|
|
|
|
const token: string = FB_TOKEN_STORE.get();
|
|
|
|
|
|
if (token !== '' && local !== null) {
|
|
|
|
|
|
const full: FbRosterPerson | null = await FbPersonRepo.inst().fetchDetail(meId, token);
|
|
|
|
|
|
// ⚠️ 浮层可能已经被关掉、或期间换了人 ⇒ 只在"还开着同一个人"时才覆盖
|
|
|
|
|
|
if (full !== null && this.myDetailSheet
|
|
|
|
|
|
&& this.myDetail !== null && this.myDetail.id === meId) {
|
|
|
|
|
|
this.myDetail = full;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const e: BusinessError = err as BusinessError;
|
|
|
|
|
|
hilog.warn(0x0000, 'FamilyBook', '取我的详情失败:%{public}s',
|
|
|
|
|
|
e.message !== undefined ? e.message : JSON.stringify(err));
|
|
|
|
|
|
this.myDetailLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 「我的详情」面板(点头像弹出)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 三种状态都要有话说(用户 2026-10-18 报过"点了没反应"这类问题):
|
|
|
|
|
|
* · 加载中 ⇒ 「正在读取…」(不要让浮层空着)
|
|
|
|
|
|
* · 拿到了 ⇒ 复用 `FbPersonSheet`(姓名/称呼/电话/生日/单位/标签…)
|
|
|
|
|
|
* · 拿不到 ⇒ 显式一句 `myDetailNote`(而不是空白)
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
myDetailPanel() {
|
|
|
|
|
|
Column() {
|
|
|
|
|
|
if (this.myDetailLoading) {
|
|
|
|
|
|
Row({ space: 8 }) {
|
|
|
|
|
|
LoadingProgress().width(18).height(18).color(FbColors.brand)
|
|
|
|
|
|
Text('正在读取我的资料…')
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.justifyContent(FlexAlign.Center)
|
|
|
|
|
|
.padding({ top: 28, bottom: 28 })
|
|
|
|
|
|
} else if (this.myDetail !== null) {
|
|
|
|
|
|
FbPersonSheet({
|
|
|
|
|
|
person: this.myDetail,
|
|
|
|
|
|
onClose: (): void => {
|
|
|
|
|
|
this.myDetailSheet = false;
|
|
|
|
|
|
},
|
|
|
|
|
|
// ⚠️ 这里**不给编辑入口**:编辑要跳名册页那套编辑浮层(含标签/电话增删),
|
|
|
|
|
|
// 而顶栏在**每个模块页**都存在 —— 在这里接编辑会变成
|
|
|
|
|
|
// "在日程页里编辑家庭成员的资料",职责与页面上下文都不对。
|
|
|
|
|
|
// 要看/改详细资料,从名册进(那是它的主场)。
|
|
|
|
|
|
onEdit: (): void => {
|
|
|
|
|
|
this.myDetailSheet = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Text(this.myDetailNote !== ''
|
|
|
|
|
|
? this.myDetailNote
|
|
|
|
|
|
: '暂时拿不到你的资料,下拉同步一次再试')
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ top: 28, bottom: 28, left: 24, right: 24 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.constraintSize({ maxHeight: '82%' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 16:01:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 同步面板(点顶栏图标弹出)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 面板要回答用户三个问题:**现在在干什么 / 上次什么时候 / 有没有出问题**。
|
|
|
|
|
|
* 以及一个他一定会问的:**为什么这次这么快就同步完了** ——
|
|
|
|
|
|
* 所以"跳过 N 个没有变化的模块"必须显式写出来,
|
|
|
|
|
|
* 否则增量同步会被误认为"没同步"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 全部用表达式直接读 `this.sync.xxx`,不调用方法(见文件里那两处说明)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
syncPanel() {
|
|
|
|
|
|
Column({ space: 12 }) {
|
|
|
|
|
|
// ---- 标题 + 状态徽标 ----
|
|
|
|
|
|
Row({ space: 8 }) {
|
|
|
|
|
|
Text('同步')
|
|
|
|
|
|
.fontSize(18)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
Blank()
|
|
|
|
|
|
if (this.sync.phase === SYNC_RUNNING) {
|
|
|
|
|
|
Text('同步中')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.surface)
|
|
|
|
|
|
.backgroundColor(FbColors.brand)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
|
|
|
|
|
} else if (this.sync.phase === SYNC_FAIL) {
|
|
|
|
|
|
Text('有失败')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.surface)
|
|
|
|
|
|
.backgroundColor(FbColors.red)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
|
|
|
|
|
} else if (this.sync.phase === SYNC_DONE) {
|
|
|
|
|
|
Text('已是最新')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.green)
|
|
|
|
|
|
.backgroundColor(FbColors.surfaceAlt)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Text('还没同步')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
.backgroundColor(FbColors.surfaceAlt)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusPill)
|
|
|
|
|
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 进度条(只在同步中显示)----
|
|
|
|
|
|
if (this.sync.phase === SYNC_RUNNING) {
|
|
|
|
|
|
Progress({
|
|
|
|
|
|
value: this.sync.done,
|
|
|
|
|
|
total: this.sync.total > 0 ? this.sync.total : 1,
|
|
|
|
|
|
type: ProgressType.Linear
|
|
|
|
|
|
})
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height(4)
|
|
|
|
|
|
.color(FbColors.brand)
|
|
|
|
|
|
Text('正在同步:' + (this.sync.current !== '' ? this.sync.current : '准备中')
|
|
|
|
|
|
+ '(' + this.sync.done.toString() + ' / ' + this.sync.total.toString() + ')')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 人话说明(上次结果 / 失败原因)----
|
|
|
|
|
|
if (this.sync.lastNote !== '') {
|
|
|
|
|
|
Text(this.sync.lastNote)
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(this.sync.phase === SYNC_FAIL ? FbColors.red : FbColors.ink)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 统计 ----
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
Text('上次同步')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
Text(this.sync.lastAt > 0 ? fbFmtTime(this.sync.lastAt) : '还没同步过')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
Blank()
|
|
|
|
|
|
// 🔴 增量生效的证据:这次有多少模块"因为没变"而没拉
|
|
|
|
|
|
Text(this.sync.skipped > 0
|
|
|
|
|
|
? (this.sync.skipped.toString() + ' 个模块没有变化')
|
|
|
|
|
|
: '全部检查过')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
|
|
|
|
|
|
if (this.sync.attachTotal > 0 || this.sync.attachRunning) {
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
Text('附件')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
Text(this.sync.attachDone.toString() + ' / ' + this.sync.attachTotal.toString()
|
|
|
|
|
|
+ (this.sync.attachRunning ? ',正在下载…' : ',已下载'))
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.ink)
|
|
|
|
|
|
Blank()
|
|
|
|
|
|
Text(fbFmtBytes(this.sync.attachBytes))
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.muted)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 失败清单 ----
|
|
|
|
|
|
if (this.sync.failed.length > 0) {
|
|
|
|
|
|
Column({ space: 4 }) {
|
|
|
|
|
|
ForEach(this.sync.failed, (name: string) => {
|
|
|
|
|
|
Text('· ' + name + ' 没同步成功')
|
|
|
|
|
|
.fontSize(FbMetric.fontTiny)
|
|
|
|
|
|
.fontColor(FbColors.red)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}, (name: string) => name)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 按钮 ----
|
|
|
|
|
|
Button(this.sync.phase === SYNC_RUNNING ? '正在同步…' : '立即同步')
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height(42)
|
|
|
|
|
|
.fontSize(FbMetric.fontSmall)
|
|
|
|
|
|
.fontColor(FbColors.surface)
|
|
|
|
|
|
.backgroundColor(this.sync.phase === SYNC_RUNNING ? FbColors.faint : FbColors.brand)
|
|
|
|
|
|
.borderRadius(FbMetric.radiusS)
|
|
|
|
|
|
.enabled(this.sync.phase !== SYNC_RUNNING)
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.runSync();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 4, bottom: 20 })
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 毫秒时间戳 → `MM-DD HH:mm`。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么不显示年份:面板上只有"上次同步"这一处,
|
|
|
|
|
|
* 一眼要看的是"多久以前",不是"哪一年"。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function fbFmtTime(ms: number): string {
|
|
|
|
|
|
const d: Date = new Date(ms);
|
|
|
|
|
|
const two = (n: number): string => (n < 10 ? '0' + n.toString() : n.toString());
|
|
|
|
|
|
return two(d.getMonth() + 1) + '-' + two(d.getDate()) + ' '
|
|
|
|
|
|
+ two(d.getHours()) + ':' + two(d.getMinutes());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 字节 → 人话(`12.3 MB` / `860 KB` / `0 B`) */
|
|
|
|
|
|
function fbFmtBytes(n: number): string {
|
|
|
|
|
|
if (n <= 0) {
|
|
|
|
|
|
return '0 B';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (n < 1024) {
|
|
|
|
|
|
return n.toString() + ' B';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (n < 1024 * 1024) {
|
|
|
|
|
|
return (n / 1024).toFixed(0) + ' KB';
|
|
|
|
|
|
}
|
|
|
|
|
|
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
|
|
|
|
}
|