Files
FamilyBook_harmonyos/entry/src/main/ets/components/FloatDock.ets
T
laoyang 776c95ba0b 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)。
2026-10-18 03:36:04 +08:00

328 lines
15 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 要求:
* 「页面正下方设置几个浮动按钮,放三、四个常见功能。比如日程、名册、任务和笔记,
* 这四个应该比较常用,放在下面。这样用户不用拉抽屉,就可以直接点击使用。
* 但一定要注意,使用浮动的方式,**不要固定在下面**,而是**浮动着的**。」
*
* 🔴 形态照**鸿蒙 6 官方设计规范**的「悬浮导航(Float Navigation)」:
* · 脱离底部边界,以「**四周留白 + 圆角卡片**」形态**悬浮在内容之上**;
* · 参考实现参数:`height(56) + 四周留白 + borderRadius(28)`;
* · 与系统手势条保持间距,减少误触(所以 `bottom:16` 而不是贴底)。
*
* 🔴 两处按用户实机反馈调过(2026-10-12):
* · **宽度收窄**:「太宽了,不要顶到两边」⇒ 左右留白 40vp(比内容区块的 16vp 更收)。
* ⚠️ 收窄靠 `margin`,**千万别再写 `.width('100%')`** —— 它会让 margin 失效、变满宽。
* · **降低透明度**:「透明度太高,下面的内容糊在一起看不清」⇒
* 底色改 92% 白(`EBFFFFFF`)+ `backgroundBlurStyle(COMPONENT_THIN)`。
*
* 🔴 为什么用 `backgroundBlurStyle` 而不用 `systemMaterial`(鸿蒙 6 的沉浸材质):
* `systemMaterial` / `uiMaterial` 是 **SDK 26.0.0** 起的新 API,
* 而本工程 `compatibleSdkVersion = 6.1.1(24)`(编译器会警告版本不匹配)
* ⇒ 在 API 24 设备上有失效风险。`backgroundBlurStyle` 是 **API 9** 就有的,
* 视觉几乎等价、且没有版本风险。**将来把兼容版本提到 26 再换回沉浸材质。**
*
* ⚠️ 为什么用 `Stack(alignContent: Bottom)` 覆盖在内容上,而不是在 Column 里占一行:
* 用户明确说"不要固定在下边,要浮着"。占一行就是"固定的底部栏"(老样式);
* 用 Stack 覆盖 ⇒ 内容从它下面穿过、它是浮在上面的。这是这个组件唯一正确的摆法。
*
* ⚠️ 内容底部要留出 `FB_DOCK_RESERVE` 的空白,
* 否则最后一块内容会被悬浮栏压住(悬浮 ≠ 可以挡内容)。
*/
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
import { FbAppState, fbAppState } from '../common/Store';
import { FbModuleDef, fbModuleOf } from '../model/Modules';
/** 快捷栏高度(vp)—— 取设计规范的 56 */
const DOCK_H: number = 56;
/**
* 下边距(vp)。
*
* 🔴 从 16 加到 **24**(2026-10-13 改"全沉浸"时)。
* 原因:系统**手势条已被隐藏**(用户要求"下面没有任何的条")——
* 手势区没有任何视觉提示了,悬浮栏贴得太近时,
* "从下往上滑唤回内容"这个手势很容易先按到它上面。
* 多加 8vp 拉开距离,是给**手势区**留的缓冲,不是视觉上的调整。
*/
const DOCK_PAD: number = 24;
/**
* 左右留白(vp)。
*
* 🔴 用户 2026-10-12:「宽度有点太宽了,不要顶到两边……
* 我们那个白色的那种背景框,它不都是两边都有空隙的吗?所以这个宽度也往里面收一收。」
* ⇒ 收得比内容区块的留边(16vp)更窄:40vp。
* 这样悬浮栏明显比内容窄一圈,「浮着的一块」的感觉更强,
* 也符合"页面里的卡片都不贴边"这套语言。
*/
const DOCK_SIDE: number = 40;
/** 内容区为悬浮栏预留的高度(vp):栏高 + 下边距 + 一点余量 */
export const FB_DOCK_RESERVE: number = DOCK_H + DOCK_PAD + 16;
/**
* 收起时向下平移的距离(vp)——**必须让整个胶囊完全移出可视区**。
*
* 🔴 供 `Index.ets` 做"随滚动滑走"的位移动画用(用户 2026-10-13 要的 IT 之家那套)。
* ⚠️ 导出来复用而不是在 `Index` 里写死一个数字:
* 它与栏高、下边距是**同一个几何量**,分散在两处必然漂移 ——
* 一旦下移量小于"高 + 边距",收起后底下会**露出半截胶囊边**。
* +12 是余量(阴影半径 18、偏移 6,留一点避免阴影边缘仍可见)。
*/
export const FB_DOCK_HIDE_Y: number = DOCK_H + DOCK_PAD + 12;
/**
* 悬浮快捷栏。
*
* @param app 全局状态(切模块用)
* @param keys 要放的模块 key(默认四个常用:日程 / 名册 / 任务 / 笔记)
*/
@ComponentV2
export struct FbFloatDock {
@Param app: FbAppState = fbAppState();
/**
* 快捷模块 key 列表。
* 🔴 用户点名要这四个("应该比较常用"):日程、名册、任务、笔记。
* 顺序按用户说的来。
* ⚠️ v54b:这四项现在是**两两分居 logo 两侧**(日程 名册 [家书] 任务 笔记),
* 见下面 build 的左右两段。
*/
@Param keys: string[] = ['calendar', 'people', 'tasks', 'notes'];
build() {
// 🔴 用**两层**解决"收窄":外层 Row 负责左右留白,内层 Row 是胶囊本体。
//
// 为什么不用更直接的两个写法(都实测失败过):
// ① `.width('100%')` + `.margin({left,right})` 并存 ⇒ width 先占满、
// margin 失效 ⇒ 栏体顶到屏幕两侧(实测 x=0..1079 满宽);
// ② 去掉 width、只留 margin ⇒ **仍不够**:子项用了 `.layoutWeight(1)`
// 四等分,含 layoutWeight 子项的容器会撑满父级可用宽度,把 margin 顶开
// (实测留白只有 17vp,而不是设的 40vp)。
// ③ `calc(100% - 80vp)` 也不行 —— SDK 明确写着
// "**width** and **height** do not support **calc()** dynamic calculations"。
// ⇒ 外层 padding 是唯一稳稳生效的办法:外层通栏、只做左右内缩,
// 内层胶囊再用 layoutWeight(1) 吃满剩余宽度。语义清晰、不受子项布局影响。
Row() {
Row() {
// ---- 左半:日程 / 名册 ----
ForEach(this.leftKeys(), (k: string) => {
FbDockItem({
icon: this.iconNameOf(k),
label: this.labelOf(k),
active: k === this.app.activeModule,
accent: this.accentOf(k),
onTap: (): void => {
this.app.switchModule(k);
}
})
}, (k: string): string => 'dock_l_' + k)
// ---- 正中间:**应用图标**(点它回首页)----
// 🔴 用户 2026-10-17:「下面悬浮的 4 个图标**中间**,放一个**我们应用的图标**,
// 就是那个 **logo**,点击之后**是首页**。」
// ⚠️ 用真实 logo 图(`fb_logo`,与顶栏同一张),**不要**换成 home 图标 ——
// 用户说的是"我们应用的图标/logo",那是品牌标识,不是"首页"这个功能图标。
// ⚠️ 也不加"首页"两个字:中间那格比两侧窄,加字会挤(它的语义由 logo 本身承担)。
this.homeButton()
// ---- 右半:任务 / 笔记 ----
ForEach(this.rightKeys(), (k: string) => {
FbDockItem({
icon: this.iconNameOf(k),
label: this.labelOf(k),
active: k === this.app.activeModule,
accent: this.accentOf(k),
onTap: (): void => {
this.app.switchModule(k);
}
})
}, (k: string): string => 'dock_r_' + k)
}
// 内层胶囊:吃满外层扣掉内缩后的宽度
.layoutWeight(1)
.height(DOCK_H)
.padding({ left: 6, right: 6 })
.borderRadius(DOCK_H / 2) // 全圆角胶囊形(28 = 56/2)
.alignItems(VerticalAlign.Center)
// ---- 悬浮底:白色 + 毛玻璃 ----
//
// 🔴 底色:**92% 白**。
// 用户反馈「透明度太高,下面的内容和标签糊在一起,看不清了」
// ⇒ 先把「读得清」这条底线守住:底色几乎实心,只留 8% 通透。
.backgroundColor('EBFFFFFF')
//
// 🔴 毛玻璃用 `backgroundBlurStyle(COMPONENT_THIN)`,**不用 `systemMaterial`**:
// · `systemMaterial` / `uiMaterial` 是 **SDK 26.0.0** 起的新 API,
// 而本工程 `compatibleSdkVersion = 6.1.1(24)` ⇒ 编译器会警告
// "current compatible SDK version is 6.1.1(24)",在 API 24 设备上有失效风险;
// · `backgroundBlurStyle` 是 **API 9** 就有的老 API,兼容性完全没问题,
// 且同样能做出"半透 + 模糊"的悬浮观感。
// ⇒ 在"视觉几乎等价"的前提下,选**没有版本风险**的那个。
// (将来工程把 compatibleSdkVersion 提到 26 再换回沉浸材质。)
.backgroundBlurStyle(BlurStyle.COMPONENT_THIN)
.border({ width: 1, color: FbColors.line2 })
// 浮在内容之上:给一点投影增强"悬浮"层次
.shadow({ radius: 18, color: '#1A2C231D', offsetY: 6 })
}
.width('100%')
// 🔴 左右内缩 40vp(比内容区块的 16vp 更收):
// 用户要求「宽度有点太宽了,不要顶到两边……也往里面收一收」。
.padding({ left: DOCK_SIDE, right: DOCK_SIDE, bottom: DOCK_PAD })
}
/**
* 中间那颗 logo(点它回首页)。
*
* ⚠️ 宽度给 `layoutWeight` 之外还要**略窄一点**:两侧各有 2 个带文字的项,
* 中间这格只有图、没有字,等宽会让它显得很空。
* 用固定宽 + `justifyContent(Center)`,视觉上就是个"居中的品牌钉"。
*/
@Builder
homeButton() {
Column({ space: 2 }) {
Image($r('app.media.fb_logo'))
.width(26)
.height(26)
.borderRadius(7)
// 描边:logo 是「米色底 + 砖红图案」,放在白底胶囊上底板会"化掉"
//(与顶栏那颗 logo 同一个理由,见 TopBar 的注释)
.border({ width: 1, color: FbColors.line })
// 当前就在首页 ⇒ 给一行小字标出来(与两侧项的"图标+文字"节奏也对齐)
Text('首页')
.fontSize(FbMetric.fontTiny)
.fontColor(this.app.activeModule === 'home' ? '#A84A28' : '#4A3F35')
.maxLines(1)
}
.width(52)
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick((): void => {
this.app.switchModule('home');
})
}
/** 左半段的 key(前一半;四项时 = 前 2 个) */
private leftKeys(): string[] {
const n: number = Math.floor(this.keys.length / 2);
const out: string[] = [];
for (let i = 0; i < n; i++) {
out.push(this.keys[i]);
}
return out;
}
/** 右半段的 key(后一半) */
private rightKeys(): string[] {
const n: number = Math.floor(this.keys.length / 2);
const out: string[] = [];
for (let i = n; i < this.keys.length; i++) {
out.push(this.keys[i]);
}
return out;
}
/** 模块名(取注册中心,避免各处写死) */
private labelOf(key: string): string {
const m: FbModuleDef | undefined = fbModuleOf(key);
return m !== undefined ? m.name : key;
}
/** 模块主题色(返回 **string 色值**,供 fillColor/fontColor 用) */
private accentOf(key: string): string {
const m: FbModuleDef | undefined = fbModuleOf(key);
return m !== undefined ? m.color : '#A84A28';
}
/**
* 模块 → 图标名。
*
* ⚠️ 这里必须显式映射,不能用 `fb_ic_ + key`:模块 key 与图标文件名并不一一对应
* (people → fb_ic_people 对得上,但 notes → fb_ic_notes 也对得上……
* 而将来若有 key 与文件名不一致的,拼串就会拿到 undefined 资源而崩)。
* 显式写死更稳。
*/
private iconNameOf(key: string): string {
switch (key) {
case 'calendar': return 'calendar';
case 'people': return 'people';
case 'tasks': return 'tasks';
case 'notes': return 'notes';
case 'home': return 'home';
case 'movies': return 'movies';
case 'trips': return 'trips';
case 'resources': return 'resources';
case 'days': return 'days';
case 'ledger': return 'ledger';
case 'books': return 'books';
default: return 'home';
}
}
}
/**
* 悬浮栏里的一个快捷项(图标 + 文字,竖排,紧凑)。
*
* 🔴🔴 v54b(用户 2026-10-17)—— 为什么必须是**组件**:
* 用户原话:「日程、名册、任务、笔记这几个图标,应该点击后,在它们自己的页面中,
* **是变颜色的**,颜色就是它们自己的设定颜色,**图标和文字,都变颜色**。」
* —— 也就是说:点了「日程」进日程页后,那颗图标与文字应当变成**日程的琥珀色**。
*
* ⚠️ 原来这里是 `@Builder dockItem(key, active)` —— **两个参数**,
* SDK 规则 `r/quick-rules/05-state.md`:
* > **两个及以上参数不触发刷新**
* ⇒ 切模块后 `this.app.activeModule` 变了、`build()` 重跑了,
* 但**这个多值 @Builder 的子树不重建** ⇒ 高亮**永远停在初始那一项**上。
* 这与本轮"概述卡数字永远是 0"是**同一个根因**(本项目第 6、7 次)。
*
* ⇒ 抽成 `@ComponentV2` + `@Param`(@Param 参与观察)。
* ⚠️ 别改回 `@Builder`,哪怕"只有一个参数"也别用(见 FbStatCell 的说明)。
*/
@ComponentV2
export struct FbDockItem {
/** 图标名(`FbIcon.ui()` 的 key) */
@Param icon: string = 'home';
/** 下面那行文字(模块名) */
@Param label: string = '';
/**
* 是不是当前所在模块。
* 🔴 由父组件**算好传进来**(父组件读 `this.app.activeModule`)——
* 组件自己拿不到父的状态,这正是"@Param 会被观察"的价值。
*/
@Param active: boolean = false;
/** 该模块的**设定色**(当前模块时,图标与文字都用它) */
@Param accent: string = '#A84A28';
/** 点击回调(切模块) */
@Event onTap: () => void = (): void => {
};
build() {
Column({ space: 2 }) {
Image(FbIcon.ui(this.icon))
.width(20)
.height(20)
.renderMode(ImageRenderMode.Template)
// ⚠️ fillColor 必须同一类型:选中时用模块色(string),未选中也用 string。
// 三目里两侧类型不一致(Resource 与 string 混用)会报
// "Type 'string | Resource' is not assignable to type 'string'"。
//
// 🔴 未选中色从 muted(#7E6B5A) 加深到 #4A3F35:
// 原先在白底上的对比度只有约 2.3:1(远低于 WCAG 的 4.5:1),
// 加上材质半透,用户就觉得"和下面的内容糊在一起、看不清"。
// 🔴 选中色 = **该模块自己的设定色**(用户 2026-10-17 明确要求),
// 不是统一的品牌红 —— 这样"我在日程"和"我在任务"一眼能分清。
.fillColor(this.active ? this.accent : '#4A3F35')
Text(this.label)
.fontSize(FbMetric.fontTiny)
.fontColor(this.active ? this.accent : '#4A3F35')
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.maxLines(1)
}
.layoutWeight(1) // 等分
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick((): void => {
this.onTap();
})
}
}