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)。
This commit is contained in:
2026-10-18 03:36:04 +08:00
parent 7eaa641437
commit 776c95ba0b
64 changed files with 18297 additions and 1519 deletions
+234 -51
View File
@@ -8,33 +8,69 @@
*
* 🔴 形态照**鸿蒙 6 官方设计规范**的「悬浮导航(Float Navigation)」:
* · 脱离底部边界,以「**四周留白 + 圆角卡片**」形态**悬浮在内容之上**;
* · 参考实现参数:`height(56) + margin({left:16,right:16,bottom:16}) + borderRadius(28)`;
* · 材质用系统提供的沉浸材质(`uiMaterial.ImmersiveMaterial`,
* `ImmersiveStyle.ULTRA_THIN` 对应设计规范里的"超薄"档),
* 它自带模糊/边框/阴影 —— ⚠️ 用了它**不要**再叠加 backgroundBlurStyle,
* 否则会双重模糊、发灰。
* · 与系统手势条保持间距,减少误触(所以就 `bottom:16` 而不是贴底)。
* · 参考实现参数:`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 覆盖 ⇒ 内容从它下面穿过、它是浮在上面的。这是这个组件唯一正确的摆法。
*
* ⚠️ "隐藏时不影响观感":内容底部要留出 `DOCK_RESERVE` 的空白,
* ⚠️ 内容底部要留出 `FB_DOCK_RESERVE` 的空白,
* 否则最后一块内容会被悬浮栏压住(悬浮 ≠ 可以挡内容)。
*/
import { uiMaterial } from '@kit.ArkUI';
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
import { FbAppState, fbAppState } from '../common/Store';
import { FbModuleDef, fbModuleOf } from '../model/Modules';
/** 内容区为悬浮栏预留的高度(vp):栏高 56 + 下边距 16 + 一点余量 */
export const FB_DOCK_RESERVE: number = 88;
/** 快捷栏高度(vp)—— 取设计规范的 56 */
const DOCK_H: number = 56;
/** 四周留白(vp)—— 设计规范给的是 16 */
const DOCK_PAD: number = 16;
/**
* 下边距(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;
/**
* 悬浮快捷栏。
@@ -49,63 +85,143 @@ export struct FbFloatDock {
* 快捷模块 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() {
ForEach(this.keys, (k: string) => {
// 当前模块在快捷栏里高亮,让用户知道"我在哪"
this.dockItem(k, k === this.app.activeModule);
}, (k: string): string => 'dock_' + k)
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%')
.height(DOCK_H)
// 四周留白 ⇒ 悬浮感(不贴屏幕边)
.margin({ left: DOCK_PAD, right: DOCK_PAD, bottom: DOCK_PAD })
.padding({ left: 6, right: 6 })
.borderRadius(DOCK_H / 2) // 全圆角胶囊形(28 = 56/2)
.alignItems(VerticalAlign.Center)
// 🔴 悬浮材质(鸿蒙 6 设计规范的"超薄沉浸"档)。
// ⚠️ 这里**只**用 systemMaterial,不加 backgroundColor/backgroundBlurStyle ——
// 材质自带模糊与描边,叠加会双重模糊。
// ⚠️ 若设备/版本不支持该 API,会在运行时被忽略(不崩),
// 所以下面再给一层极浅的描边兜底,保证"有边界的悬浮卡片"这个观感不丢。
.systemMaterial(new uiMaterial.ImmersiveMaterial({
style: uiMaterial.ImmersiveStyle.ULTRA_THIN
}))
.border({ width: 1, color: FbColors.line2 })
// 浮在内容之上:给一点投影增强"悬浮"层次
.shadow({ radius: 18, color: '#1A2C231D', offsetY: 6 })
// 🔴 左右内缩 40vp(比内容区块的 16vp 更收):
// 用户要求「宽度有点太宽了,不要顶到两边……也往里面收一收」。
.padding({ left: DOCK_SIDE, right: DOCK_SIDE, bottom: DOCK_PAD })
}
/** 一个快捷项:图标 + 文字(竖排,紧凑) */
/**
* 中间那颗 logo(点它回首页)。
*
* ⚠️ 宽度给 `layoutWeight` 之外还要**略窄一点**:两侧各有 2 个带文字的项,
* 中间这格只有图、没有字,等宽会让它显得很空。
* 用固定宽 + `justifyContent(Center)`,视觉上就是个"居中的品牌钉"。
*/
@Builder
dockItem(key: string, active: boolean) {
homeButton() {
Column({ space: 2 }) {
Image(FbIcon.ui(this.iconNameOf(key)))
.width(20)
.height(20)
.renderMode(ImageRenderMode.Template)
// ⚠️ fillColor 必须是同一类型:选中时用模块色(string),
// 未选中时用 `$r` 资源也可以,但**三目里两侧类型必须一致**
// (Resource 与 string 混用会报 "Type 'string | Resource' is not assignable to type 'string'")。
// 这里统一走 string 色值,省掉类型打架。
.fillColor(active ? this.accentOf(key) : '#7E6B5A')
Text(this.labelOf(key))
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(active ? this.accentOf(key) : '#7E6B5A')
.fontColor(this.app.activeModule === 'home' ? '#A84A28' : '#4A3F35')
.maxLines(1)
}
.layoutWeight(1) // 四等分
.width(52)
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
// 切模块(switchModule 内部会顺手收起抽屉)
this.app.switchModule(key);
.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);
@@ -142,4 +258,71 @@ export struct FbFloatDock {
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();
})
}
}