Files
FamilyBook_harmonyos/entry/src/main/ets/components/TopBar.ets
T
laoyang 7858de27e3 v0.8.4 简要卡观感 / 分享 / 名册搜索 / 全局返回手势(v59e)
八条需求的落地与真因:

1. 浮层与白底"糊在一起"、看不清 —— 根因不是配色,是**遮罩从来没画出来过**:
   ArkUI 的八位色值是 #AARRGGBB(alpha 在前),而全应用写的是 '#00000066'
   (本意"黑 40%")⇒ alpha = 0x00 ⇒ 全透明。同型还有 brand + '1A'(拼到了蓝通道)
   与 shadow('#00000033')。⇒ 17 个文件统一改走 fbAlpha() / FB_MASK / FB_SHADOW /
   FB_HAIRLINE;真机像素采样复验:遮罩 (148,146,140)、卡片白、浅底 (237,242,243)。
2. 简要卡外圈加**主题色描边环**(外层铺色 + padding,不是 border —— border 会被
   内层圆角切出白缝)。
3. 简要卡**去掉删除按钮**,删除挪到完整详情卡最下面(Divider + 红字块)。
   顺手修一处静默失败:confirmDelete() 原来只取 quickSource(),
   从家谱直接开详情时 quickPerson 为 null ⇒ 点删除毫无反应。
4. 信息行间距 10 → 16(每行再各加 3vp 上下内边距,实测行距 134/118px)。
5. 主电话整行可点**直接拨号**(call.makeCall,落到系统拨号盘)。
6. 按钮改为 详情 / 编辑 / 分享 / 关闭;分享走系统分享面板,
   内容为纯文本「姓名 / 电话 / 邮箱」三项(用户点名的就是这三项,
   不多带单位),面板拉不起来时兜底复制到剪贴板并提示。
7. 名册搜索"完全不能用"有两个独立真因:
   ① 搜索框是 List 的第一个 item,无结果时整个 List 被空态替换 ⇒ 输入框被卸载、
      焦点丢失 ⇒ 输不进第二个字。改:List 无条件渲染,空态自身做成 ListItem。
   ② rowToPerson 把 initials 写死成空串,服务端下发的拼音首字母从未落库 ⇒
      拼音搜索恒 0 结果。改:FB_DB_VERSION 6→7 + PRAGMA 探测补列 + 同步时落库;
      匹配同时遍历全部电话(原来只比主号)。
   另加搜索框「清空」(24×24 命中区)。
8. 全局返回手势:应用是单页 + 模块分发、没有路由栈 ⇒ 自建 FB_BACK 登记表
   (倒序询问)+ moduleHistory + applyModule()(不记历史,否则 A/B 两页来回弹)。
   9 个页面登记/注销;Index.onBackPress 先关浮层 → 回上一模块 → 才 return false
   交给系统退桌面(onBackPress 必须声明 boolean,返回 void 会被当成没接管)。

验证:
- 静态守卫 417 / 0(新增 ⑤阴影·描边·色环 ⑥透明度色 ⑦返回手势 ⑧搜索 ⑨分享)
- 反向验证 10 / 0(9 条注入全部能让守卫变红;其中"遮罩换纯黑"那条暴露了
  一条存在型假绿,已改成数个数 + 禁止 Color.Black)
- 建表/迁移列一致性守卫 12 / 0
- 真机全模块遍历 14 / 14;真机逐项复核 12 / 0 与 7 / 0

工具侧顺带修好两个过期的探针:
- 遍历探针仍用"应用内横滑"打开抽屉,而该手势早已按用户要求去掉、入口改为顶栏图标
  ⇒ 一次全量遍历里 12 个模块全报"抽屉里没找到"(像应用坏了,其实是探针过期)。
- 资源/笔记两页写死了早已不存在的条目(户口本/房产证/阳台改造清单)⇒ 改成
  结构性文案(副标题 + 常驻筛选胶囊),不随用户数据变化而恒红。
2026-10-18 23:12:57 +08:00

619 lines
28 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 · 顶栏(鸿蒙端)
*
* 用户 2026-10-12 定的形态:
* · **应用图标 + 「家书」标题**贴左上(下行小字显示当前模块);
* · **点图标 = 开抽屉**(用户要求:汉堡按钮去掉后,把这个功能挪到图标上);
* · 顶栏底色**白色**(与下面的个人信息卡同色,连成一片);
* · 顶栏**并入页面滚动流**,向上滑动时跟着内容一起滑走。
*
* 🔴 开抽屉的三个入口:
* ① **点顶栏图标**(本组件)—— 用户新增要求;
* ② **应用内水平滑动**(向右滑→左边出、向左滑→右边出,见 `Drawer.ets`);
* ③ 底部**悬浮快捷栏**(首页几个常用模块直达,见 `Pages` 里的 FloatDock)。
* ⚠️ 屏幕边缘是**系统手势区**,应用收不到,所以不做边缘滑动。
*
* ⚠️ 顶栏**不放**「设置」—— 与 Web 端一致:设置收在抽屉底部的账号区里。
*/
import { FbColors, FbIcon, FbMetric, FbTint, fbTint } from '../common/Theme';
import { FbAppState, fbAppState } from '../common/Store';
import { FbSyncState, SYNC_DONE, SYNC_FAIL, SYNC_RUNNING, fbSyncState } from '../common/FbSyncStore';
import { fbModuleOf } from '../model/Modules';
import { FbAvatar } from './Widgets';
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';
import { fbMediaUrl } from '../data/FbHttp';
import { syncAll } from '../data/FbSyncAll';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
@ComponentV2
export struct FbTopBar {
@Param app: FbAppState = fbAppState();
/** 右侧额外操作(各模块页自己往里塞「新建」「搜索」等按钮) */
@BuilderParam actions: () => void;
/**
* 同步状态(顶栏图标 + 面板共用)。
*
* ⚠️ 用 `@Local` 持有单例引用(而不是每处都调 `fbSyncState()`):
* `AppStorageV2.connect` 每次调用都会做一次连接查找,在 build 里反复调用很浪费;
* 而且拿到的本来就是同一个实例。
*/
@Local sync: FbSyncState = fbSyncState();
/** 同步面板是否打开 */
@Local syncSheet: boolean = false;
/** 「我的详情」浮层是否打开(点头像触发) */
@Local myDetailSheet: boolean = false;
/** 「我的详情」里的人(null = 还没取到) */
@Local myDetail: FbRosterPerson | null = null;
/** 「我的详情」是否在加载 */
@Local myDetailLoading: boolean = false;
/** 「我的详情」的兜底提示(拿不到资料时显示) */
@Local myDetailNote: string = '';
build() {
// 🔴 顶栏横向镜像 —— 「质感握姿」(用户 2026-10-16 第 4 条):
// 左手握时家书图标在**左**,右手握时它跑到**右**,方便单手点它开抽屉。
// ⚠️ 用 `Flex(direction: RowReverse)` 整体镜像,而不是逐个改 padding/margin ——
// 后者要同时动 4 处、还会把"标题靠左"这类布局搞乱。
// ⚠️ 但**图标不能真的反着画**(`RowReverse` 只改主轴顺序、不翻转图形本身),
// 所以镜像后位置对、图形仍正。
Flex({
direction: this.app.drawerSide === 'right' ? FlexDirection.RowReverse : FlexDirection.Row,
alignItems: ItemAlign.Center
}) {
// ---- 应用图标(**点它开抽屉**;左右握姿时自动换边)----
// ⚠️ 必须给描边:logo 是「米色底(74%) + 砖红图案(24%)」的实心方图,
// 放在白色/米色上时底板与背景几乎同色,只剩砖红图案发飘、看不清。
// 一层极浅的描边就能把底板"托"出来(本机范本 SyncCalendar 也给了描边)。
//
// 🔴 点击事件直接挂在 Image 上,**不套 Button** ——
// Button 自带最小尺寸与内边距,会把图标挤离边线(用户要"图标靠边")。
Image($r('app.media.fb_logo'))
.width(30)
.height(30)
.borderRadius(8)
.border({ width: 1, color: FbColors.line })
.flexShrink(0)
.onClick(() => {
// 点图标 ⇒ 开抽屉(方向由**握姿**定,见 common/FbGrip.ets)
this.app.drawerOpen = true;
})
Column({ space: 0 }) {
Text('家书')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(FbColors.ink)
.maxLines(1)
// 副标题显示当前模块名 —— "品牌 + 当前位置"一行说清
Text(this.moduleName())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10, right: 10 })
.layoutWeight(1)
// ---- 右:各模块自己的操作 ----
this.actions()
/* 🔴 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)
/* ⚠️ 图标染色必须走 `colorFilter` —— 本工程图标全是 PNG,
而 `fillColor` **只对 SVG 生效**(SDK image.d.ts 原话)。
详见 `Theme.fbTint` 的说明。 */
.colorFilter(fbTint(FbTint.muted))
}
}
.width(36)
.height(36)
.backgroundColor(Color.Transparent)
.stateEffect(true)
// 与右侧铃铛拉开(用户 2026-10-18:「提醒的按钮距离头像的按钮太近了」,
// 三颗按同一个间距排,见 FbMetric.gapTopActions 的说明)
.margin({ left: FbMetric.gapTopActions })
.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;
}
})
// 通知铃铛(未读角标)
//
// 🔴 v59(用户 2026-10-18):「现在提醒的按钮距离头像的按钮太近了。」
// ⇒ 两个按钮之间给一段**明确的**间距(原来它们各自 36 宽、紧挨着,
// 角标又挂在铃铛右上角、视觉上直接压到头像上)。
// ⚠️ 用 `margin.left/right` 拉开而不是塞一个 `Blank()`:
// 镜像布局(右手握时整行反序)下 Blank 的弹性会跑到另一侧,
// 间距就没了;固定 margin 两边对称、镜像后仍然成立。
Stack({ alignContent: Alignment.TopEnd }) {
Button({ type: ButtonType.Circle }) {
Image(FbIcon.ui('bell'))
.width(18)
.height(18)
/* ⚠️ 同上:PNG 图标染色必须用 colorFilter(fillColor 对 PNG 无效)。 */
.colorFilter(fbTint(FbTint.muted))
}
.width(36)
.height(36)
.backgroundColor(Color.Transparent)
.stateEffect(true)
.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 })
}
}
// 与左边的同步图标、右边的头像各拉开一段(用户:太近了)
.margin({ left: FbMetric.gapTopActions, right: FbMetric.gapTopActions })
// 头像 —— 点它打开**我自己的详情**
//
// 🔴🔴 v59b(用户 2026-10-18):「右上角,显示的应该是登录者的头像,
// **点击头像进入我的详情页面**。」
//
// ⚠️ 原来这里**根本没有 onClick**(只有一句写着"点开账号菜单"的注释)——
// 用户点了没任何反应,而代码里连一个"账号菜单"都不存在。
// ⇒ 现在接的是**详情浮层**(与名册里点某个人同一个组件、同一套数据源)。
//
// 🔴 头像**已经是登录者本人的**(`session.me.avatarUrl` 由 `/login` 与
// `/me` 两处给,见 FbLogin/FbSession)—— 用户这条要求上一轮就已满足,
// 真正缺的是"点了能进去"。
//
// ⚠️ 数据取自**本地库**(`FbPersonRepo.inst().byId`),不是名册列表里那份:
// "我"常常**不在自己的名册**里(名册是"我挑出来的那一小撮"),
// 从名册那批里找会静默落空 ⇒ 点了没反应(最难查的那种失败)。
/* ⚠️ 用一层 40×40 的透明容器包住 32 的头像,**不要**直接给 `FbAvatar`
加 `.width(40)`:
`FbAvatar` 的根节点就是那个 `Image/Text`(自己已设 `width(dia)`),
外层尺寸修饰会把它拉到 40×40,而圆角仍是 `dia/2 = 16`
⇒ 头像被拉成**椭圆**(视觉 bug,且编译不报错)。
包一层则:热区 40、头像仍是正圆 32、居中。
(32 直接当热区在真机上偏小、手指容易点空 —— 这就是要 40 的原因。) */
Row() {
FbAvatar({
name: this.app.session.me.name,
color: this.app.session.me.avatarColor,
dia: 32,
fontSizeVp: 14,
/* 🔴🔴 v59c(用户 2026-10-18):「右上角的个人头像没显示。」
*
* 真根因:`/me` 与 `/login` 返回的 `avatar_url` 是
* **相对地址**(`media.php?k=avatars%2Fxxx.jpg` —— 不带域名)——
* 那是给浏览器用的(同源 + cookie),而 `Image()` 要完整 URL
* ⇒ 直接加载失败。真机日志:`GetAsset failed: media.php?k=...`
*
* ⇒ 用 `fbMediaUrl()` 归一化成本机可访问的完整地址
* (它同时认"绝对地址 / media.php 相对式 / 裸 key"三种形态)。
*
* ⚠️ 不要用 `fbImageSrc()`(那是给**附件 key** 用的,走本地缓存):
* 这里拿到的是 URL,不是 key —— 传进去会把整条 URL 当 key 编码,
* 变成必然 404 的地址。
*/
imageUrl: fbMediaUrl(this.myAvatarRaw(), FB_TOKEN_STORE.get())
})
}
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.margin({ left: FbMetric.gapTopActions })
.onClick((): void => {
this.openMyDetail();
})
// 「我的详情」浮层 —— 复用名册那个人详情组件(同一个组件、同一套字段)
//
// ⚠️ 用 `bindSheet` 而不是自己在 `build` 里叠一层遮罩:
// 这条顶栏在**每个模块页**都会渲染(`topBarSlot`),
// 自己叠遮罩要处理"盖住悬浮栏 / 盖住抽屉 / 手势返回"一堆事;
// bindSheet 是系统能力,这些它都管(同步面板也是这么做的)。
//
// ⚠️ `onDisappear` 必须复位 `myDetailSheet`(点遮罩/下滑关闭都会走它),
// 否则下次点头像**打不开**(状态位还是 true 但面板已经不在)。
.bindSheet(this.myDetailSheet, this.myDetailPanel(), {
// ⚠️ 用 `LARGE`(= 大高度、内容自适应)而不是 `FIT_CONTENT`:
// 详情卡片有 `maxHeight: '72%'` 的内部约束,FIT_CONTENT 下
// 系统会试图把 sheet 压到"刚好包住内容",与那个百分比约束打架。
height: SheetSize.LARGE,
dragBar: true,
showClose: false,
backgroundColor: FbColors.surface,
onDisappear: (): void => {
this.myDetailSheet = false;
}
})
}
.width('100%')
// 🔴 高度 = **状态栏高度 + 顶栏本身高度**(用户 2026-10-13 要"全沉浸")。
//
// 为什么必须自己加这一段:
// 应用开了 `setWindowLayoutFullScreen(true)`(见 EntryAbility)——
// 窗口**不再为状态栏让位**,内容会真的画到状态栏底下。
// ⇒ 顶栏若不自己让出这一截,"家书"标题会被时间/信号图标压住。
//
// ⚠️ 让位用 **padding.top**(不是 margin):这样**白底会一并覆盖状态栏区域**,
// 静止时看起来通知栏与顶栏是一整块;而向上滑动时,
// 这一整块(含白底)随内容滚走,下面的内容直接顶到屏幕最上沿 ——
// 这就是用户要的"内容从手机边缘滑上去"的观感。
// ⚠️ `statusBarH` 是 `@Trace` 的,量出来(异步)后顶栏会**自动重排**;
// 量不到时为 0,标题贴到最上面(仍可读),不会把内容推到屏幕中间。
.height(FbMetric.topBarH + this.app.statusBarH)
// 左内边距=pagePad(与下面各区块的左边线对齐);右侧 10(图标可略贴边)
.padding({
left: FbMetric.pagePad,
right: 10,
top: this.app.statusBarH + 4,
bottom: 4
})
// 顶栏底色 = 白色(与下面的个人信息卡同色)。
// ⚠️ 它会覆盖上面 `padding.top` 让出来的状态栏区域 —— 这是**有意为之**:
// 静止时通知栏与顶栏连成一整块白(用户 2026-10-12 定的"融为一体"),
// 滚动时再一起滑走。**不要**改成"顶栏不设背景色、透出页面底色",
// 那样静止时会变成"米色通知栏 + 白色顶栏"上下拼接(踩过这个坑)。
.backgroundColor(FbColors.surface)
// 底线:与下方内容(同是白色卡)之间给一条极浅的分隔,避免"两块白糊在一起"
.border({ width: { bottom: 1 }, color: FbColors.line2 })
}
private moduleName(): string {
const m = fbModuleOf(this.app.activeModule);
return m !== undefined ? m.name : '家书';
}
/**
* 手动同步(面板里的按钮)。
*
* ⚠️ 已经在跑就直接返回:`syncAll` 自己不做并发保护,
* 连点两下会并发拉同一批数据(浪费流量、还可能把水位写乱)。
*/
private runSync(): void {
if (this.sync.isRunning()) {
return;
}
syncAll().catch((err: Object): void => {
hilog.warn(0x0000, 'FamilyBook', '手动同步失败:%{public}s', JSON.stringify(err));
});
}
/**
* 我的原始头像地址(服务端给的那个,可能是相对式)。
*
* ⚠️ 单独抽成方法而不是在组件参数里写三目:`@Builder`/组件参数的括号里
* 塞多行表达式会让 ArkTS 编译器解析错乱(本项目踩过,报的却是无关的
* `comma operator` / `standalone this`)。
*/
private myAvatarRaw(): string {
return this.app.session.me.avatarUrl !== undefined
? this.app.session.me.avatarUrl
: '';
}
/**
* 打开「我」的详情浮层(点右上角头像触发)。
*
* 🔴 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,
/* 🔴🔴 必须传 embedded(2026-10-18 真机踩出来的真 bug):
`FbPersonSheet` 的根节点是"铺满全屏 + 内部居中"的浮层形态
(它的设计宿主是"页面里叠遮罩 + Stack 居中",名册页就这么用)。
直接塞进 `bindSheet`(内容要自适应高度)会**测量互相打架**:
真机症状是**每次点击 sheet 都创建了、紧接着
`AceDrag: Drag gesture has been canceled` —— 起来立刻被关**,
界面上看就是"点了没反应"(极易误判成"点击没接上")。 */
embedded: true,
/* 🔴 不给「删除」:这是"我自己的详情"。
删除自己既没意义又危险(服务端是物理删除,删完账号都进不去)。
名册里的详情卡才显示删除(那里 `canDelete` 取默认值 true)。 */
canDelete: false,
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})`:
sheet 的高度由 `SheetSize.LARGE` 决定,卡片自己已有 `maxHeight:72%`;
再叠一层百分比约束只会让"高度该多高"变成三方打架
(真机症状就是浮层起来立刻被关)。 */
}
/**
* 同步面板(点顶栏图标弹出)。
*
* 🔴 面板要回答用户三个问题:**现在在干什么 / 上次什么时候 / 有没有出问题**。
* 以及一个他一定会问的:**为什么这次这么快就同步完了** ——
* 所以"跳过 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';
}