/** * 家书 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'; }