Files
FamilyBook_harmonyos/entry/src/main/ets/components/TopBar.ets
T
laoyang 853b0e0b20 v61:顶栏标题=模块名 / 抽屉瘦身 + 头像进个人设置 / 删除按钮红底 / 浮层动画 / 日程卡片与手势
用户这一批九条,逐条对应:

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 组全过);像素采样确认浮层主题色环实测存在
2026-10-19 12:19:54 +08:00

504 lines
22 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 定的形态:
* · **应用图标 + 当前模块名**贴左上(用户 2026-10-19 改:不再是固定的「家书」);
* · **点图标 = 开抽屉**(用户要求:汉堡按钮去掉后,把这个功能挪到图标上);
* · 顶栏底色**白色**(与下面的个人信息卡同色,连成一片);
* · 顶栏**并入页面滚动流**,向上滑动时跟着内容一起滑走。
*
* 🔴 开抽屉的三个入口:
* ① **点顶栏图标**(本组件)—— 用户新增要求;
* ② **应用内水平滑动**(向右滑→左边出、向左滑→右边出,见 `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 { fbModuleColor, fbModuleOf } from '../model/Modules';
import { FbAvatar } from './Widgets';
// ⚠️ `inst()` 拿单例(本工程所有 repo 都是这个约定,见 FbPersonRepo 文件头);
// `FB_TOKEN_STORE` 在 **FbTokenStore.ets** 里(不是在 FbDeviceStore)。
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;
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 }) {
// 🔴 标题 = **当前模块名**(用户 2026-10-19:「不再是家书了,
// 而是切换到那个模块,就显示哪个模块的标题」)。
// ⇒ 模块名在页面内容部**不再重复出现**(各页头的大标题已删)。
// ⚠️ 文字色用**模块主题色**(同一条要求:「出现文字的时候,
// 例如观影模块,在左上角出现的时候,颜色也需要和主题色保持一致」)。
// ⇒ 走 `fbModuleColor()` 而不是各页自己传色:顶栏只有一个,
// 在这里算一次全模块都一致(也避免了 11 个页面各写一遍)。
Text(this.moduleName())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(this.moduleColor())
.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 => {
/* 🔴 v61(用户 2026-10-19):「左侧抽屉,把通知消息、个人设置、
修改密码、系统设置,都去掉,放在个人设置页,就是在点击右侧头像那里。」
⇒ 点头像**进个人设置页**(整页),不再弹「我的详情」浮层。
⚠️ 换的是 `switchModule` 而不是自己开浮层:整页才有地方放
"改密码 / 系统设置 / 退出登录",而那三样都是页面级的。
⚠️ 顶栏在**每个模块页**都存在 ⇒ 这里只切状态位,渲染由 Index 分发。 */
this.app.switchModule('__account');
})
}
.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 })
}
/**
* 顶栏标题 = 当前模块名。
*
* 🔴 两个非模块路由(通知 / 设置)也要有名字 —— 它们不在 `FB_MODULES` 里,
* 不认就会回落「家书」,而那一页明明不是首页。
*/
private moduleName(): string {
const k: string = this.app.activeModule;
if (k === '__notifications') {
return '通知消息';
}
if (k === '__settings') {
return '设置';
}
if (k === '__account') {
return '个人设置';
}
const m = fbModuleOf(k);
return m !== undefined ? m.name : '家书';
}
/** 顶栏标题色 = 当前模块的主题色(认不出回落到品牌砖红) */
private moduleColor(): string {
return fbModuleColor(this.app.activeModule);
}
/**
* 手动同步(面板里的按钮)。
*
* ⚠️ 已经在跑就直接返回:`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
: '';
}
/**
* 同步面板(点顶栏图标弹出)。
*
* 🔴 面板要回答用户三个问题:**现在在干什么 / 上次什么时候 / 有没有出问题**。
* 以及一个他一定会问的:**为什么这次这么快就同步完了** ——
* 所以"跳过 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';
}