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
+3 -3
View File
@@ -2,10 +2,10 @@
"app": {
"bundleName": "familybook.yangyq.net",
"vendor": "yangyq",
"versionCode": 100,
"versionName": "0.1.0",
"versionCode": 700,
"versionName": "0.7.0",
"buildVersion": "1",
"icon": "$media:layered_image",
"icon": "$media:fb_logo",
"label": "$string:app_name"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -1,7 +0,0 @@
{
"layered-image":
{
"background" : "$media:background",
"foreground" : "$media:foreground"
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 家书 FamilyBook · 「质感握姿」(鸿蒙端)
*
* 🔴 用户 2026-10-16 第 4 条:
* 「再加上**质感握姿**,也就是左手边握的时候,把家书图标放在左边;
* 右手边握的时候,它就跑到右边去,这样方便点击并弹出抽屉。
* 也就是说,**左手握就从左边弹出抽屉,右手握就从右边弹出抽屉**。」
*
* 实现:用**官方**的 `@kit.MultimodalAwarenessKit` → `motion` 模块,
* 监听 `holdingHandChanged`(`HoldingHandStatus`:左手/右手/双手/未持)。
* ⚠️ 这**不是**自己拿重力/陀螺仪猜的 —— 猜出来的"左右手"极不可靠,
* 而且华为系统本来就把这个能力作为公开 API 提供。
*
* ⚠️ 权限:`ohos.permission.DETECT_GESTURE`
* (已在 `module.json5` 声明;它是 system_grant + normal ⇒ 安装即得,
* 不会弹授权框)。
* ⚠️ 版本:`@since 20`。低版本设备调 `on()` 会抛 **801(能力不支持)**
* ⇒ 全程 try/catch,拿不到就**保持默认方向**(左),不影响其它功能。
* ⚠️ 必须 `off()` 取消监听:不取消会在页面重建后**叠加多个回调**
* (本项目在别处踩过同类:监听没注销 ⇒ 一次事件触发 N 次)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { motion } from '@kit.MultimodalAwarenessKit';
import { FbAppState, fbAppState } from './Store';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbGrip';
/**
* 握姿监听(全局单例)。
*
* ⚠️ 它是**过程式**的(启动时 `start()` 一次即可),不需要渲染,
* 所以**不做成 @ComponentV2** —— 它不是 UI。
*/
class FbGripWatcherImpl {
private listening: boolean = false;
private started: boolean = false;
/**
* 开始监听握姿(幂等)。
*
* @param app 全局状态;抽屉方向写进它的 `drawerSide`
*/
start(app: FbAppState): void {
if (this.started) {
return;
}
this.started = true;
const cb = (status: motion.HoldingHandStatus): void => {
this.apply(status, app);
};
try {
motion.on('holdingHandChanged', cb);
this.listening = true;
hilog.info(DOMAIN, TAG, '握姿监听已开启');
} catch (err) {
// 801 = 设备/系统不支持(API < 20);201 = 权限没给
// ⇒ 都只是"没有握姿能力",**不能让应用起不来**
this.listening = false;
hilog.warn(DOMAIN, TAG, '握姿监听不可用(将保持默认抽屉方向):%{public}s',
JSON.stringify(err));
}
}
/** 停止监听(页面销毁时调,避免回调叠加) */
stop(): void {
if (!this.listening) {
return;
}
try {
motion.off('holdingHandChanged');
} catch (err) {
hilog.warn(DOMAIN, TAG, '取消握姿监听失败:%{public}s', JSON.stringify(err));
}
this.listening = false;
}
/**
* 握持状态 → 抽屉方向。
*
* ⚠️ **双手握 / 未持 / 未知** 都**不改**方向:这三种情况没有"偏好哪边"的
* 信息量,硬猜一边会让抽屉在双手拿时来回跳。
*/
private apply(status: motion.HoldingHandStatus, app: FbAppState): void {
if (status === motion.HoldingHandStatus.LEFT_HAND_HELD) {
app.drawerSide = 'left';
return;
}
if (status === motion.HoldingHandStatus.RIGHT_HAND_HELD) {
app.drawerSide = 'right';
}
}
}
/** 全局单例 */
export const FB_GRIP: FbGripWatcherImpl = new FbGripWatcherImpl();
/** 便捷入口:拿到全局状态并开始监听 */
export function fbStartGripWatch(): void {
FB_GRIP.start(fbAppState());
}
+213
View File
@@ -0,0 +1,213 @@
/**
* 家书 FamilyBook · 滚动方向跟踪(鸿蒙端)
*
* 🔴 存在的原因(用户 2026-10-13 明确要求):
* 「下面四个悬浮的按钮,当往上滑的时候它就消失了。然后整个就是内容。
* 等到从上往下滑的时候,它就又出现了。这个典型的应用呢,是像这个 IT 之家这种形式的。」
*
* ⇒ 把「向上滚 / 向下滚」写进全局状态(`FbAppState.dockHidden`),
* 由 `Index.ets` 据此把悬浮栏滑走 / 滑回。
*
* ⚠️ 为什么**不用** `@ohos.arkui.observer` 的全局 `'scrollEvent'` 观察者:
* 它只报 `SCROLL_START` / `SCROLL_STOP`(**只有起止、没有方向**),
* 做不了"往上还是往下"(已查 SDK 确认)。
* ⇒ 只能在各页最外层滚动容器上挂 `onDidScroll`(API 12+)。
* 它的 `scrollOffset` 是**每帧的增量**(vp):
* · `> 0` = 内容往上走(手指上滑、看后面的内容);
* · `< 0` = 内容往下走(手指下滑、回看前面的内容)。
*
* ⚠️ 为什么各页要**各写一行** `.onDidScroll(fbOnScroll)`:
* ArkUI 没有"全局绊住所有滚动容器"的钩子。逐页显式挂最直白,
* 而且哪个页面参与这套行为,看代码就知道(没写这行 = 不参与)。
*
* 🔴🔴 2026-10-15 改:**阈值从 48/40 降到 12/8**(真机反馈"其他模块不收起")。
*
* 实测症状:**首页/名册能收起,其他模块(任务/资源/藏书/旅程/设置…)不能**。
* 差别在于**内容高度** —— 那几页内容刚好一屏左右,向上滑只有很小一段可滚,
* 累积量到不了 48vp ⇒ 永远不触发。
* ⇒ 阈值必须**按"最小可感知滑动"来定**,而不是"一整段内容"。
* 12vp 约等于"手指明确地推了一下",既不会误触、
* 又能在内容只有很短可滚行程时正常触发。
* ⚠️ 收/唤两个阈值**不同**(收 12、唤 8):收起要"更确定"、唤回要"更灵敏"——
* 用户往回翻,本意多半就是要找底部入口。
*/
import { fbAppState } from './Store';
/**
* 向上滚多少(vp 累计)才**收起**悬浮栏。
*
* ⚠️ 别再调大:内容短的那些页面(任务/资源/藏书)可滚行程只有几十 vp,
* 阈值一大就永远不会触发(这正是本轮修的 bug)。
*/
const HIDE_AT: number = 12;
/**
* 向下滚多少(vp 累计)才**唤回**悬浮栏。
*
* 比 `HIDE_AT` 更小:收起要"更确定"、唤回要"更灵敏"。
*/
const SHOW_AT: number = 8;
/**
* 横向滚动容器上报(挂在**横向** Scroll 上的 `.onDidScroll`)。
*
* 🔴 用途(用户 2026-10-15 要求"有横滑组件时优先响应组件、别开抽屉"):
* 记一个时间戳,抽屉手势结束时会检查"刚刚有没有横滑在为"。
* ⇒ 在标签条上横滑时,抽屉让位,不再"一不小心滑出来"。
*
* ⚠️ 与 `fbOnScroll` 的区别:那个是**纵向**滚动(管悬浮栏收起),
* 这个是**横向**滚动(管抽屉让位)。两个都挂是正常的。
* ⚠️ 用 `Date.now()` 而不是计数:需要的是"最近有没有"这个时间语义。
*/
export function fbOnHScroll(_offset: number, _state: ScrollState): void {
fbAppState().hScrollAt = Date.now();
}
/**
* 判定"刚刚是否有横滑组件在为"。
*
* @param withinMs 多久之内算"刚刚"。默认 350ms ——
* 横滑停下到手指抬起之间通常有几十到一两百毫秒,
* 抽屉的 `onActionEnd` 也在手指抬起那一刻触发 ⇒ 350 足够覆盖,
* 又不会把"上一次横滑"误判成这次(用户滑完停一会儿再想开抽屉是正常的)。
*/
export function fbHScrollBusy(withinMs: number = 350): boolean {
const at: number = fbAppState().hScrollAt;
return at > 0 && (Date.now() - at) < withinMs;
}
/**
* 各页最外层滚动容器的滚动回调。
*
* 用法(每个模块页的**最外层**滚动容器挂一次):
* ```ts
* Scroll() { ... }
* .width('100%')
* .height('100%')
* .onDidScroll(fbOnScroll)
* ```
* ⚠️ 只挂**最外层**那一个:内层横向滚动(标签条、看板筛选条、账本切换条)
* 要挂的是 `fbOnHScroll`(管抽屉让位),**不是**这个 ——
* 挂错了会让纵向的累积值被横向滚动污染。
*/
export function fbOnScroll(offset: number, state: ScrollState): void {
const app = fbAppState();
// ⚠️ **不跳过 Idle**:`Fling`(惯性)结束时最后那一下会带 Idle,
// 而内容短的页面可能**只在惯性阶段**产生位移 —— 跳过就永远收不起来。
// 但 Idle 的 offset 偶尔是 0(无方向含义),所以下面按 offset 的正负判断,
// 0 自然不产生任何影响。
if (offset === 0) {
return;
}
// ---- 方向法(主判据):一帧偏移足够大 ⇒ 直接按方向切 ----
// 🔴 为什么不只靠累积:内容短的页面可滚行程小、帧数少,
// 累积还没到阈值滑动就结束了(旧实现就是这么漏掉的)。
// 这里加一条"单帧方向足够明确就直接切"的快路径。
if (offset >= HIDE_AT) {
app.scrollAcc = HIDE_AT;
if (!app.dockHidden) {
app.dockHidden = true;
}
return;
}
if (offset <= -SHOW_AT) {
app.scrollAcc = -SHOW_AT;
if (app.dockHidden) {
app.dockHidden = false;
}
return;
}
// ---- 累积法(兜底):小步慢滑时把微小增量攒起来 ----
app.scrollAcc += offset;
if (app.scrollAcc >= HIDE_AT) {
// 钉在阈值上(滞回):反向只需走 SHOW_AT 就能切回来
app.scrollAcc = HIDE_AT;
if (!app.dockHidden) {
app.dockHidden = true;
}
} else if (app.scrollAcc <= -SHOW_AT) {
app.scrollAcc = -SHOW_AT;
if (app.dockHidden) {
app.dockHidden = false;
}
}
}
// ============================================================
// 触摸方向兜底(内容不足一屏的页面用)
// ============================================================
/** 手指按下的位置(**-1 = 当前没有按压**;用哨兵值而不是 null,ArkTS 对可选值更严格) */
let touchStartY: number = -1;
/** 一次滑动至少要移动这么多(vp)才认作"有方向意图"(避免把点击当滑动) */
const TOUCH_MIN: number = 16;
/**
* 触摸方向兜底:在**页面根容器**挂 `.onTouch(fbOnTouch)`。
*
* 🔴 为什么需要它(2026-10-15 真机反馈):
* 「其他模块往上滑动的时候,那四个悬浮条却没有隐藏。」
* 实测根因:**任务/资源/藏书/设置这些页面内容不足一屏,压根滚不动**
* ⇒ 挂在外层滚动容器上的 `onDidScroll` **一次都不会触发**
* ⇒ 悬浮栏永远收不起来(首页/名册内容多,所以正常)。
*
* ⇒ 用**触摸位移方向**做兜底:手指从下往上抹(`dy < 0`)就收起、
* 从上往下抹就唤回。它不依赖"内容能滚"这件事。
*
* ⚠️ 与 `fbOnScroll` 的关系:**两者都放行、互不冲突**(同一个 `dockHidden` 状态,
* 同向重复设置是无害的幂等操作)。滚动能触发时以 `fbOnScroll` 为主
* (它更精确:区分了内容位移与手指位移)。
* ⚠️ 阈值 `TOUCH_MIN = 16vp` 是"手指位移",比滚动阈值大一些 ——
* 因为手指位移总是大于等于内容位移(滚到头时内容不动、手指还在动),
* 取小了会在**点击**时也触发。
* ⚠️ 用 **`windowY`**(窗口坐标)而不是 `y`(组件内坐标):
* `y` 是相对**当前组件**的,手指移出组件后不再更新(可能拿到 0)。
* `windowY` 是窗口坐标、全程连续。
* ⚠️ `globalDisplayY` 在 SDK 里是**可选字段**(`number | undefined`),
* ArkTS 会报类型错 ⇒ 一律用必填的 `windowY`。
*/
export function fbOnTouch(event: TouchEvent): void {
const app = fbAppState();
const t: TouchType = event.type;
if (t === TouchType.Down) {
const arr: TouchObject[] = event.touches;
if (arr.length > 0) {
touchStartY = arr[0].windowY;
}
return;
}
if (t === TouchType.Move) {
if (touchStartY < 0) {
return;
}
const arr: TouchObject[] = event.touches;
if (arr.length === 0) {
return;
}
const dy: number = arr[0].windowY - touchStartY;
// dy < 0 ⇒ 手指往上抹 ⇒ 内容往上走 ⇒ 收起
if (dy <= -TOUCH_MIN) {
if (!app.dockHidden) {
app.dockHidden = true;
}
// ⚠️ **不更新** touchStartY:否则手指继续上抹时会一直在阈值边缘反复触发。
// 保持不动 ⇒ 判断是"相对起点的总位移",更稳。
} else if (dy >= TOUCH_MIN) {
if (app.dockHidden) {
app.dockHidden = false;
}
}
return;
}
if (t === TouchType.Up || t === TouchType.Cancel) {
touchStartY = -1;
}
}
+407
View File
@@ -0,0 +1,407 @@
/**
* 家书 FamilyBook · 日程「时间轴」布局(鸿蒙端)
*
* 🔴 用户 2026-10-16:「首先,**没有时间轴展示**……你这不是说让你把那个
* SyncCalendar 里面的功能搬过来吗?」
* ⇒ 照 `D:\Yongquan\code\SyncCalendar\entry\src\main\ets\common\TimelineUtil.ets`
* 移植(那份是成熟实现,算法经过实测):把一天的日程按**真实时间**
* 铺到 0-24 小时的纵向格子上。
*
* 设计(与 SyncCalendar 一致):
* · 时间粒度:**1 小时 = 1 格**,色块高度按实际时长换算。
* · 同一时间重叠(冲突)的日程**横向平分宽度**:1 条 = 100%、2 条 = 50% …
* · 色块背景色 = 所属日历本颜色。
* · 全天日程**不进时间轴**,单独返回,由视图画在最上方。
* · 视窗:**8:00 ~ 18:00 恒定显示**;某天在 8 点前/18 点后有日程时向外扩。
* · 当前时间红线按"现在"在时间轴上的纵向比例定位。
*
* ⚠️ 与 SyncCalendar 的差别(家书的数据形状不同,不照抄):
* · 它用**毫秒时间戳**;家书的 `FbCalEvent` 给的是 `HH:mm` 字符串
* (`time` / `endTime`)⇒ 统一换算成"当天第几分钟"来算比例。
* · 它的跨天日程要"逐天展开 + 按交集裁切";家书 `/calendar` 接口
* **本来就是按天给出的条目**(同一条重复日程每天一条),
* ⇒ 不需要跨天裁切,逻辑简单一层。
* · 它没有"没有结束时间"的日程;家书允许 ⇒ 缺结束时间时按 **1 小时** 估。
*
* ArkTS 约束:所有"多字段返回值"必须是**命名 class**,
* 不能用内联对象字面量类型(见 r/quick-apis/08-state-decorators.md)。
*/
import { FbCalEvent } from '../model/Types';
/** 时间轴上的一个日程色块(位置/尺寸都用 0~1 比例,视图侧乘实际高度) */
export class FbTimeBlock {
/** 稳定 key(ForEach 用) */
key: string = '';
title: string = '';
/** `09:00 - 10:30` */
timeText: string = '';
place: string = '';
color: string = '#A84A28';
/** 距 0 点的比例 0~1(乘时间轴总高) */
topRatio: number = 0;
/** 高度比例 */
heightRatio: number = 0;
/** 横向起始比例 0~1(冲突平分) */
leftRatio: number = 0;
/** 横向宽度比例 */
widthRatio: number = 1;
/** 冲突组序号(视图侧按组分行) */
groupIndex: number = 0;
/** 组内列号(同列互不重叠) */
laneIndex: number = 0;
/** 正在进行中(视图侧高亮) */
isNow: boolean = false;
/** 能不能编辑(衍生条目不给编辑) */
editable: boolean = false;
/** 原始条目(点击/详情用) */
ev: FbCalEvent | null = null;
}
/** 一天的完整时间轴布局结果 */
export class FbTimelineData {
/** 全天条目(画在 0 点之前) */
allDay: FbCalEvent[] = [];
/** 时间轴色块(按 topRatio 升序) */
blocks: FbTimeBlock[] = [];
/** 当天有没有"带时间"的日程 */
hasTimed: boolean = false;
}
/** 冲突组:一组时间上互相重叠的日程(用于横向平分宽度) */
class FbOverlapGroup {
items: FbCalEvent[] = [];
endMin: number = 0;
}
/** 视图渲染用的"冲突组"(组内按列并排) */
export class FbTimeGroup {
/** 组内最早开始(距 0 点比例) */
startRatio: number = 0;
/** 组内最晚结束 */
endRatio: number = 0;
/** 列数 = 组内"最大同时重叠数" */
laneCount: number = 1;
/** 组内全部色块(按开始时间升序) */
blocks: FbTimeBlock[] = [];
/** 按列组织:lanes[i] = 第 i 列上的色块 */
lanes: FbTimeBlock[][] = [];
}
/** 视窗(要显示的小时区间) */
export class FbViewRange {
startHour: number = 8;
endHour: number = 18;
}
/** 视图分组用的"一行"(空格行 或 冲突组行) */
export class FbTimeRow {
/** 0 = 空隙;1 = 冲突组 */
kind: number = 0;
topRatio: number = 0;
botRatio: number = 0;
/** kind=1 时有效 */
group: FbTimeGroup | null = null;
key: string = '';
}
export class FbTimelineUtil {
/** 1 小时 = 多少 vp(视图侧用同一常量换算) */
static readonly HOUR_UNIT: number = 44;
/** 基准可视区:恒定显示的小时区间 */
static readonly BASE_START_HOUR: number = 8;
static readonly BASE_END_HOUR: number = 18;
/** 色块最短时长:15 分钟(避免零高) */
static readonly MIN_SPAN_MIN: number = 15;
/** 一天有多少分钟 */
private static readonly DAY_MIN: number = 1440;
/** `HH:mm` → 当天第几分钟(认不出返回 -1) */
static minutesOf(hhmm: string): number {
if (hhmm.length < 4) {
return -1;
}
const h: number = parseInt(hhmm.substring(0, 2), 10);
const m: number = parseInt(hhmm.substring(3, 5), 10);
if (isNaN(h) || isNaN(m)) {
return -1;
}
return h * 60 + m;
}
/** 分钟 → `HH:mm` */
static hhmm(min: number): string {
const h: number = Math.floor(min / 60);
const m: number = min % 60;
const p = (n: number): string => n < 10 ? '0' + n.toString() : n.toString();
return p(h) + ':' + p(m);
}
/** 条目的唯一 key(ForEach 用;同一天同一条目重复出现时要能区分) */
static keyOf(e: FbCalEvent): string {
return e.kind + '_' + e.id.toString() + '_' + e.time + '_' + e.title;
}
/**
* 构建某一天的时间轴布局。
*
* @param events 这一天(`FbCalEvent.date` 已等于目标日期)的全部合格条目
* @param isToday 是不是今天(决定要不要画"当前时间")
* @param nowMin 当前时刻(当天第几分钟;仅 isToday 时有意义)
*/
static build(events: FbCalEvent[], isToday: boolean, nowMin: number): FbTimelineData {
const res: FbTimelineData = new FbTimelineData();
// 1) 分类:全天 → 顶部;带时间的 → 时间轴
const timed: FbCalEvent[] = [];
for (const e of events) {
if (e.allDay || FbTimelineUtil.minutesOf(e.time) < 0) {
res.allDay.push(e);
} else {
timed.push(e);
}
}
res.hasTimed = timed.length > 0;
if (timed.length === 0) {
return res;
}
// 2) 算出每条的 [起, 止] 分钟(缺结束时间按 1 小时估;止 <= 起 则按 +1h)
const starts: number[] = [];
const ends: number[] = [];
for (const e of timed) {
const s: number = FbTimelineUtil.minutesOf(e.time);
let en: number = FbTimelineUtil.minutesOf(e.endTime);
if (en <= s) {
en = s + 60;
}
if (en > FbTimelineUtil.DAY_MIN) {
en = FbTimelineUtil.DAY_MIN; // 夹到当天 24:00(跨天不处理:接口按天给条目)
}
starts.push(s);
ends.push(en);
}
// 3) 按开始时间排序(同步排 starts/ends,保持下标一致)
const idx: number[] = [];
for (let i = 0; i < timed.length; i++) {
idx.push(i);
}
idx.sort((a: number, b: number): number => starts[a] - starts[b]);
const sortedEv: FbCalEvent[] = [];
const sortedS: number[] = [];
const sortedE: number[] = [];
for (const i of idx) {
sortedEv.push(timed[i]);
sortedS.push(starts[i]);
sortedE.push(ends[i]);
}
// 4) 划分"冲突组"(时间上连通重叠的一簇)
const groups: FbOverlapGroup[] = [];
for (let i = 0; i < sortedEv.length; i++) {
const last: FbOverlapGroup | undefined =
groups.length > 0 ? groups[groups.length - 1] : undefined;
if (last !== undefined && sortedS[i] < last.endMin) {
// 与上一组还有重叠 ⇒ 并入(组的"最晚结束"跟着往外扩)
last.items.push(sortedEv[i]);
last.endMin = Math.max(last.endMin, sortedE[i]);
} else {
const ng: FbOverlapGroup = new FbOverlapGroup();
ng.items = [sortedEv[i]];
ng.endMin = sortedE[i];
groups.push(ng);
}
}
// 5) 组内"贪心分列"(区间图着色):每条放进"结束 <= 它开始"的编号最小的列
// ⇒ 列数 = 该组"最大同时重叠数",而不是组内条数
let gi: number = 0;
for (const g of groups) {
const laneEnd: number[] = [];
const laneOf: number[] = [];
for (const e of g.items) {
const s: number = FbTimelineUtil.minutesOf(e.time);
let lane: number = -1;
for (let i = 0; i < laneEnd.length; i++) {
if (laneEnd[i] <= s) {
lane = i;
break;
}
}
if (lane < 0) {
lane = laneEnd.length;
laneEnd.push(0);
}
// 该条的结束(同样规则)
let en: number = FbTimelineUtil.minutesOf(e.endTime);
if (en <= s) {
en = s + 60;
}
if (en > FbTimelineUtil.DAY_MIN) {
en = FbTimelineUtil.DAY_MIN;
}
laneEnd[lane] = en;
laneOf.push(lane);
}
const n: number = laneEnd.length > 0 ? laneEnd.length : 1;
for (let i = 0; i < g.items.length; i++) {
const e: FbCalEvent = g.items[i];
const s: number = FbTimelineUtil.minutesOf(e.time);
let en: number = FbTimelineUtil.minutesOf(e.endTime);
if (en <= s) {
en = s + 60;
}
if (en > FbTimelineUtil.DAY_MIN) {
en = FbTimelineUtil.DAY_MIN;
}
const span: number = en - s > FbTimelineUtil.MIN_SPAN_MIN ? en - s : FbTimelineUtil.MIN_SPAN_MIN;
const b: FbTimeBlock = new FbTimeBlock();
b.key = FbTimelineUtil.keyOf(e);
b.title = e.title !== '' ? e.title : '(无标题)';
b.timeText = FbTimelineUtil.hhmm(s) + ' - ' + FbTimelineUtil.hhmm(en);
b.place = e.place;
b.color = e.color;
b.topRatio = s / FbTimelineUtil.DAY_MIN;
b.heightRatio = span / FbTimelineUtil.DAY_MIN;
b.groupIndex = gi;
b.laneIndex = laneOf[i];
b.widthRatio = 1 / n;
b.leftRatio = laneOf[i] / n;
b.isNow = isToday && nowMin >= s && nowMin < en;
b.editable = e.kind === 'custom' && e.id > 0 && e.calId > 0;
b.ev = e;
res.blocks.push(b);
}
gi++;
}
// 兜底:严格按 topRatio 升序(视图侧求"空隙"无累积漂移)
res.blocks.sort((a: FbTimeBlock, b: FbTimeBlock): number => a.topRatio - b.topRatio);
return res;
}
/**
* 由平坦的色块列表重建"冲突分组":视图按组分行、组内按列并排渲染。
*/
static groupsOf(blocks: FbTimeBlock[]): FbTimeGroup[] {
const map: Map<number, FbTimeBlock[]> = new Map<number, FbTimeBlock[]>();
for (const b of blocks) {
const arr: FbTimeBlock[] | undefined = map.get(b.groupIndex);
if (arr === undefined) {
map.set(b.groupIndex, [b]);
} else {
arr.push(b);
}
}
const idxs: number[] = Array.from(map.keys()).sort((a: number, b: number): number => a - b);
const out: FbTimeGroup[] = [];
for (const gidx of idxs) {
const bs: FbTimeBlock[] = map.get(gidx) ?? [];
bs.sort((a: FbTimeBlock, b: FbTimeBlock): number => {
if (a.topRatio !== b.topRatio) {
return a.topRatio - b.topRatio;
}
return b.heightRatio - a.heightRatio;
});
// 贪心分列(用比例重算,保证与 build 一致)
const lanes: FbTimeBlock[][] = [];
const laneEnd: number[] = [];
for (const b of bs) {
let li: number = -1;
for (let i = 0; i < laneEnd.length; i++) {
if (laneEnd[i] <= b.topRatio + 0.0000001) {
li = i;
break;
}
}
const be: number = b.topRatio + b.heightRatio;
if (li < 0) {
li = lanes.length;
lanes.push([b]);
laneEnd.push(be);
} else {
lanes[li].push(b);
laneEnd[li] = be;
}
b.laneIndex = li;
}
const lc: number = lanes.length > 0 ? lanes.length : 1;
for (const b of bs) {
b.widthRatio = 1 / lc;
b.leftRatio = b.laneIndex / lc;
}
const g: FbTimeGroup = new FbTimeGroup();
g.blocks = bs;
g.lanes = lanes;
g.laneCount = lc;
let s: number = 1;
let e: number = 0;
for (const b of bs) {
if (b.topRatio < s) {
s = b.topRatio;
}
const be: number = b.topRatio + b.heightRatio;
if (be > e) {
e = be;
}
}
g.startRatio = s;
g.endRatio = e;
out.push(g);
}
return out;
}
/**
* 视窗:8:00 ~ 18:00 恒定显示;某天在 8 点前 / 18 点后有日程时向外扩。
*
* ⚠️ 这条规则很重要:若只按"当天最早/最晚日程"截断,
* 那么只有一条 9:00 日程的日子就只画 1 小时的格子,观感很怪。
*/
static viewRange(blocks: FbTimeBlock[]): FbViewRange {
const rg: FbViewRange = new FbViewRange();
let s: number = FbTimelineUtil.BASE_START_HOUR;
let e: number = FbTimelineUtil.BASE_END_HOUR;
for (const b of blocks) {
const bh: number = Math.floor(b.topRatio * 24);
const eh: number = Math.ceil((b.topRatio + b.heightRatio) * 24 - 0.0001);
if (bh < s) {
s = bh;
}
if (eh > e) {
e = eh;
}
}
if (s < 0) {
s = 0;
}
if (s > 23) {
s = 23;
}
if (e > 24) {
e = 24;
}
if (e <= s) {
e = s + 1 > 24 ? 24 : s + 1;
}
rg.startHour = s;
rg.endHour = e;
return rg;
}
/** 当前时刻的纵向比例 0~1 */
static nowRatio(nowMin: number): number {
let r: number = nowMin / FbTimelineUtil.DAY_MIN;
if (r < 0) {
r = 0;
}
if (r > 1) {
r = 1;
}
return r;
}
}
+148
View File
@@ -45,6 +45,124 @@ export class FbAppState {
/** 未读通知数(顶栏铃铛角标) */
@Trace unread: number = 2;
/**
* 状态栏高度(vp)。
*
* 🔴 为什么页面需要知道它(用户 2026-10-13 要"全沉浸"):
* 打开 `setWindowLayoutFullScreen(true)` 之后,**窗口不再为状态栏让位**,
* 内容会真的画到状态栏底下 ⇒ 顶栏必须自己往下让出这一段,
* 否则首屏的"家书"标题会被时间/信号图标压住。
* ⚠️ 但它**只用来给"滚动内容的第一行"垫高**(见 `FbTopBar`),
* 不是给根容器加 padding —— 那样内容就永远钻不到状态栏底下、
* 也就没有"滑上去和通知栏融为一体"的效果了。
*/
@Trace statusBarH: number = 0;
/**
* 底部悬浮快捷栏是否**被滚动收起**。
*
* 🔴 用户 2026-10-13:「下面四个悬浮按钮,往上滑的时候它就消失了……等到从上往下滑的时候,
* 它就又出现了。典型的应用像 IT 之家。」
* ⇒ 向上滚(看后面的内容)收起、向下滚(回看)唤回。
* ⚠️ 由 `common/FbScroll.ets` 的 `FB_SCROLL.track()` 写、`Index.ets` 读并驱动位移动画。
*/
@Trace dockHidden: boolean = false;
/**
* **页面内浮层**(人员详情 / 编辑 / 观影详情 / 影厅)当前打开了几个。
*
* 🔴 为什么需要(用户 2026-10-15 实测踩出来的真 bug):
* 名册的详情浮层是**页面内的覆盖层**,装在页面自己的 `Stack` 里;
* 而底部悬浮快捷栏装在**更外层的 `Index` 的 Stack` 里** ⇒ 悬浮栏的层级更高。
* 实测:详情卡片底部的「编辑」按钮(y≈2207-2331)与悬浮栏(y≈2240-2350)
* **在屏幕上重叠**,点「编辑」被悬浮栏抢走 ⇒ **直接跳到了日历页**。
* (「关闭」同理。)
* ⇒ 浮层开着的时候,悬浮栏必须**让位**(收起 + 不可点)。
*
* ⚠️ 用**计数**而不是 boolean:可能同时有"详情 + 编辑"两个浮层叠着
* (只是本项目目前一次只开一个),计数能天然处理嵌套/多层。
* ⚠️ 所有开/关都必须走 `openSheet()` / `closeSheet()`,不要直接改它。
*/
@Trace sheetCount: number = 0;
/**
* 滚动方向累积量(vp)。正 = 一直在往上滚;负 = 一直在往下滚。
*
* ⚠️ 放在**状态类里**而不是 `FbScroll.ets` 的模块级变量 ——
* `FbScroll` 要读 `FbAppState`、`Store` 若又要 import `FbScroll` 的复位函数,
* 就形成**循环 import**(本项目此前已经因此踩过坑)。
* ⇒ 让累积量归属状态类本身,依赖方向固定成单向:`FbScroll` → `Store`。
* ⚠️ 它不是"界面状态",不参与渲染(没有任何组件读它);
* 只是随状态类一起活着的内部计数器,所以**不加 `@Trace`**
* (加了会让每个滚动帧都触发一次无谓的状态通知)。
*/
scrollAcc: number = 0;
/**
* **横向滚动"占用"标记**:记录最近一次横向滚动发生的时刻(ms;0 = 从没发生)。
*
* 🔴 存在的理由(用户 2026-10-15 明确要求):
* 「不是在任何地方左滑都会出抽屉,而是在原来的页面上没有左滑右滑功能的时候,
* 左滑右滑才会出来抽屉。否则,如果页面上有左滑右滑的组件,
* 它就应该先优先响应这个组件。」
*
* 抽屉的横滑是 `parallelGesture`(**并行**识别),它**不管内容区在干什么**
* 都会一起触发 ⇒ 在标签条上横滑时"一不小心就把抽屉滑出来"。
*
* ⇒ 做法:横滑组件(标签条 / 看板筛选条 / 账本切换条 / 日历格)在自己滚动时
* **打一个时间戳**;抽屉在手势结束时看"刚刚有没有横滑在为",
* 有就让位、不开抽屉。
*
* ⚠️ 用**时间戳**而不是布尔标记:
* 横滑结束时并不知道"用户接下来不会再滑了",
* 用"最近 N 毫秒内发生过横滑"来判定更稳(见 `fbMarkHScroll`)。
* ⚠️ 不加 `@Trace`:它不参与渲染,只是内部判据。
*/
hScrollAt: number = 0;
/**
* 🔴 v55:**请求回到登录页**(登出 / 更换服务器时由设置页置位)。
*
* 为什么用状态位而不是"让设置页自己 push 一个页面":
* 本应用是**单页 + 模块分发**(`Index.moduleContent()`),
* 没有路由栈 —— 登录页是 `Index.build()` 里的一个分支。
* ⇒ 想回到它,只能"把这个分支的判据改掉"。
* ⚠️ 由 `Index.build()` 读;登录成功 / 用户选"先不登录"时**必须**复位,
* 否则下次渲染又会弹回登录页(死循环)。
*/
@Trace needLogin: boolean = false;
/**
* 🔴 v55:回到登录页时**从第几步开始**(0 = 自动判断)。
*
* `1` = 强制从"填服务器地址"开始(用户点了「更换服务器」);
* `2` = 直接从"输账号密码"开始(用户只是「退出登录」,地址还留着);
* `0` = 按本地有没有地址自动决定(正常启动)。
*
* ⚠️ 为什么必须是**状态位**而不是给登录页传个参数:
* 登录页的 `aboutToAppear` 只在组件**被创建**时跑一次;
* 本应用里它被 `if` 分支复用,状态位比"重建时参数对不对"更可控。
*/
@Trace loginStartStep: number = 0;
/**
* 🔴 v57(用户 2026-10-17):「这台设备已经被退出登录」之类的原因。
*
* 由启动校验 / 401 拦截置位,登录页顶部原样显示 ——
* 用户必须知道"为什么突然要我重新登录",否则只会觉得 App 坏了。
*/
@Trace lockReason: string = '';
/**
* 🔴 v57:离线超过 24 小时 → **锁界面但不删令牌**。
*
* 用户原话:「离线超过 24 个小时就硬退出。」
* ⚠️ 与 `needLogin` 分开是**刻意的**(见 `FbSession` 文件头):
* 这里只是"先别看了,联网确认一下",一联网校验通过就自动恢复,
* **不需要重新输密码**;只有被踢/退出登录才走 `needLogin`。
*/
@Trace offlineLocked: boolean = false;
/** 当前模块的账号(便捷取用,避免页面里到处写 this.app.session.me) */
me(): FbAccount {
return this.session.me;
@@ -54,6 +172,36 @@ export class FbAppState {
switchModule(key: string): void {
this.activeModule = key;
this.drawerOpen = false;
// 🔴 换页要**复位滚动累积**:那是跨页面共享的,
// 不清零的话下一页第一次轻滑就会"立刻收起悬浮栏"(其实用户什么都没做)。
this.resetScroll();
}
/** 有浮层开着吗(悬浮栏据此让位) */
sheetOpen(): boolean {
return this.sheetCount > 0;
}
/** 开一个浮层(计数 +1) */
openSheet(): void {
this.sheetCount += 1;
}
/** 关一个浮层(计数 -1,不会低于 0) */
closeSheet(): void {
this.sheetCount = this.sheetCount > 0 ? this.sheetCount - 1 : 0;
}
/**
* 复位滚动累积(换模块、回到顶部时调)。
*
* ⚠️ 用方法而不是让调用方直接写 `app.scrollAcc = 0`:
* 复位时**同时把悬浮栏放出来** —— 换到新模块时它必须是可见的,
* 否则会出现"进了新页面、底部入口却是收起来的"这种莫名其妙的状态。
*/
resetScroll(): void {
this.scrollAcc = 0;
this.dockHidden = false;
}
}
+72 -45
View File
@@ -7,8 +7,16 @@
*
* 三条形态,同一个组件:
* · **常驻侧栏**(宽屏:窗口短边 ≥ 600vp)—— 固定在左边,占 264vp;
* · **左滑出层**(手机,从左边缘向右滑)—— 覆盖内容,点遮罩或选中项即收起;
* · **右滑出层**(手机,从右边缘向左滑)—— 同上,只是贴右边(右手单手持机顺手)。
* · **左滑出层**(手机)—— 覆盖内容,点遮罩或选中项即收起;
* · **右滑出层**(手机)—— 同上,只是贴右边(右手单手持机顺手)。
*
* 🔴 抽屉**从哪边出**由「**握姿**」决定(用户 2026-10-16 第 4 条):
* 左手握 → `app.drawerSide = 'left'`;右手握 → `'right'`。
* 逻辑在 `common/FbGrip.ets`(用官方 `multimodalAwareness.motion` 的
* `HoldingHandStatus`),不在本组件里。
* 🔴 **打开方式只有一种:点顶栏左侧的家书图标**(`pages/Index.ets`)。
* ⚠️ **横向滑动开抽屉的旧手势已删**(用户要求)—— 它与页面里的横滑、
* 与"点人名拨号"在 `parallelGesture` 下**并行触发**,冲突无法根治。
*
* ⚠️ 这里**不用 SideBarContainer**:它的控制按钮与「滑出」是内建的一套,
* 样式(图标、位置、宽度动画)很难与家书的视觉统一,且它只能有一层侧栏
@@ -21,6 +29,16 @@ import { FbAppState, fbAppState } from '../common/Store';
import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules';
import { FbAvatar } from './Widgets';
/**
* 底部手势条区域的留白(vp)。
*
* 🔴 应用已开 `setWindowLayoutFullScreen(true)`(见 EntryAbility):
* 内容会铺到屏幕最下沿,而**系统手势条已隐藏**(用户要求"下面没有任何的条")
* ⇒ 手势区**没有任何视觉提示**,贴着底边的元素很容易被"从下往上滑"误触。
* 这里留一点缓冲,是给"手势区"而不是给"某个控件"。
*/
const FB_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbDrawer {
/** 全局状态(父组件传入,保证与页面同一份实例) */
@@ -60,7 +78,7 @@ export struct FbDrawer {
}
.width('100%')
// 顶部内边距 = 状态栏高度 + 一点呼吸,保证不压状态栏
.padding({ left: 18, right: 12, top: 16, bottom: 10 })
.padding({ left: 18, right: 12, top: 16 + this.app.statusBarH, bottom: 10 })
}
/** 一个模块行 */
@@ -112,29 +130,52 @@ export struct FbDrawer {
.color(FbColors.line2)
.margin({ bottom: 6 })
this.footerRow('fb_ic_bell', '通知消息', this.app.unread > 0 ? this.app.unread.toString() : '')
this.footerRow('fb_ic_user', '个人设置', '')
this.footerRow('fb_ic_lock', '修改密码', '')
this.footerRow('fb_ic_bell', '通知消息',
this.app.unread > 0 ? this.app.unread.toString() : '', '__notifications')
this.footerRow('fb_ic_user', '个人设置', '', '__settings')
// ⚠️ 下面两项端上**还没做页面**,传空 route ⇒ 灰显、不可点。
// 别给它们绑 `__password` / `__system` —— Index.ets 没这两个分支,
// 点下去是**空白页**,比"点不动"更让人困惑。
this.footerRow('fb_ic_lock', '修改密码', '', '')
if (this.app.session.me.role === 'admin') {
this.footerRow('fb_ic_gear', '系统设置', '')
this.footerRow('fb_ic_gear', '系统设置', '', '')
}
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 10 })
// 🔴 bottom 给底部**手势条区域**留一点(全屏布局下抽屉会铺到屏幕最下沿,
// 底部这两行若贴着边,手指从下往上滑时容易误触到系统手势)。
.padding({ left: 12, right: 12, bottom: 10 + FB_BOTTOM_GAP })
}
/** 底部一行 */
/**
* 底部一行(通知消息 / 个人设置 / 修改密码 / 系统设置)。
*
* 🔴 必须带 `route` 并绑 onClick —— 之前这四行**根本没有点击处理**,
* 界面上是个死按钮(点下去毫无反应)。
* 排查记录:点「个人设置」后仍停在首页,看界面树发现该元素
* `clickable=false` 且整行没有任何可点击祖先 —— 就是这个原因。
* ⚠️ 与模块项(`moduleRow`)不同:这四项的路由 key 是 `__xxx` 形式,
* 不是 `FB_MODULES` 里的模块,走同一个 `switchModule` 分发
* (Index.ets 里用 `activeModule === '__settings'` 渲染对应页面)。
*
* ⚠️ `route` 传空串表示"**这个页面还没做**" ⇒ 不绑 onClick,且文字降为灰、
* 保持不可点。为什么不给它绑个假跳转:那会跳到**空白页**
* (Index.ets 里没有对应分支),比"点了没反应"更让人困惑。
* 现状:`__notifications` / `__settings` 已有页面;
* `修改密码` / `系统设置` **还没做**(Web 端有,端上待开发)。
*/
@Builder
footerRow(iconName: string, label: string, badge: string) {
footerRow(iconName: string, label: string, badge: string, route: string) {
Row({ space: 12 }) {
Image(FbIcon.ui(iconName))
.width(17)
.height(17)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.muted)
// 未实现的项图标也变灰,明示"暂不可用"
.fillColor(route === '' ? FbColors.faint : FbColors.muted)
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.fontColor(route === '' ? FbColors.faint : FbColors.ink)
.layoutWeight(1)
if (badge.length > 0) {
Text(badge)
@@ -148,6 +189,11 @@ export struct FbDrawer {
.width('100%')
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
.borderRadius(FbMetric.radiusS)
.onClick(() => {
if (route !== '') {
this.app.switchModule(route); // switchModule 内部会收起抽屉
}
})
}
/** 当前是否选中该模块 */
@@ -263,41 +309,22 @@ export struct FbDrawerShell {
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
// 🔴 横向滑动开抽屉(用户 2026-10-12 定的规则)。
// 🔴🔴 **横向滑动开抽屉的手势已删除**(用户 2026-10-16 第 3、4 条)。
//
// ⚠️ **不要判断"起点是否贴屏幕边缘"** —— 用户实测反馈:屏幕边缘是
// **系统手势区**(返回/侧边栏),从边缘往里滑的事件根本轮不到应用处理,
// 我们无法控制。所以判据只能是"**在应用内部水平滑动**":
// 向右滑 → 抽屉从左边出;向左滑 → 抽屉从右边出。
// 这样左手持机时往右划、右手持机时往左划,单手都顺。
// 用户原话:「这个列表上左滑、右滑和点击后打电话的事件冲突了……
// 如果实在不行,就把左滑、右滑弹出抽屉这个功能去掉吧,
// 只保留点击标题或图标后弹出抽屉的功能。」
// 并再次强调:「**先这样做,把滑动出抽屉这个功能先去掉**,
// 因为它现在在很多地方都跟别的功能有冲突。」
//
// ⚠️ `offsetX` 的单位是 **vp**(SDK 原文 "in vp"),不要再除以 density ——
// 除一次会把位移缩到 1/3,导致阈值永远达不到、手势"看起来没生效"。
// 历史:`parallelGesture(PanGesture)` + `fbHScrollBusy()` 的那套"让位"
// 机制(见 git 历史)**治不了根本** —— 因为 `parallelGesture` 是**并行**识别,
// 它和应用自己的横滑/点按/拨号**同时**成立,没有"谁先抢到"的机制。
// 名册里"滑动既弹抽屉又跳拨号"就是这个并行识别的必然结果。
// ⇒ 唯一可靠的办法是**不用横滑手势**。
//
// ⚠️ 用 `parallelGesture` 而不是 `gesture`:内容区自己还有竖向滚动
// (以及纪念日/财务/任务的横向滚动条),用 gesture 会把它们抢掉。
// 代价是横向滚动条扫得猛时也可能触发 —— 所以阈值给到 80vp,
// 轻扫(浏览横向列表)不会误开。
.parallelGesture(
PanGesture({ direction: PanDirection.Horizontal, distance: 30 })
.onActionEnd((e: GestureEvent) => {
// 宽屏下抽屉常驻,不需要手势
if (this.app.wideLayout) {
return;
}
const dx: number = e.offsetX; // vp
// 向右滑 ⇒ 抽屉在左
if (dx > 80) {
this.app.drawerSide = 'left';
this.app.drawerOpen = true;
return;
}
// 向左滑 ⇒ 抽屉在右
if (dx < -80) {
this.app.drawerSide = 'right';
this.app.drawerOpen = true;
}
})
)
// 抽屉入口保留在:**顶栏左侧的家书图标**(`pages/Index.ets` 的 `topBarSlot`)。
// 抽屉从哪边出,改由「**握姿**」决定(见 `FbGripWatcher` / `app.drawerSide`):
// 左手握 → 从左边出;右手握 → 从右边出。
}
}
@@ -0,0 +1,72 @@
/**
* 家书 FamilyBook · 设置里的「单选行」(鸿蒙端)
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug):
* 「设置里面这个"**默认视图**",**都没法点**,也没有办法设置。」
* 根因:原来是 `FbCalSettingsSheet.radioRow(label, hint, on: boolean, onTap)` ——
* **`@Builder` 的值类型参数(boolean)不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」)。
* ⇒ 点一下 `this.view` 确实变了、`FB_CAL_SETTINGS.setView()` 也确实写了,
* 但**打勾 ✓ 与高亮底色的那份子树不重建** ⇒ 看着"点了没反应"。
* 本项目已在 `FbTagChip` 上踩过同一个坑(见那份文件头),这里照同样办法修。
*
* ⚠️ 顺带说明:ForEach 的 key **不用**带选中态 —— 只要这一片是
* `@ComponentV2` + `@Param`,`active` 变了它自己就会重建。
* (`FbTagChip` 的 key 也只是标签名。)
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbDot } from './Widgets';
@ComponentV2
export struct FbCalOptionRow {
/** 主文字 */
@Param label: string = '';
/** 副说明(小字;空则不占位) */
@Param hint: string = '';
/** 是否选中(**必须由父组件算好传进来**) */
@Param active: boolean = false;
/** 左侧色点(日历本那几行用它;空则不画) */
@Param dotColor: string = '';
/** 被点击 */
@Event onPick: () => void = (): void => {
};
build() {
Row({ space: 10 }) {
if (this.dotColor !== '') {
FbDot({ dia: 10, fillC: this.dotColor })
}
Column({ space: 2 }) {
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.active ? '#4A7C82' : FbColors.ink)
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.maxLines(2)
if (this.hint !== '') {
Text(this.hint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(this.active ? '✓' : '')
.fontSize(FbMetric.fontSmall)
.fontColor('#4A7C82')
.width(20)
.textAlign(TextAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
.backgroundColor(this.active ? '#EAF2F2' : FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onPick();
})
}
}
@@ -0,0 +1,253 @@
/**
* 家书 FamilyBook · 日程设置浮层(鸿蒙端)
*
* 用户 2026-10-15:「在新建的那个地方,**右侧那加一个设置按钮**,
* 点进去之后实际上就是**对这个日程的设置**。」
* 用户 2026-10-16:「设置里面这个"**默认视图**",都没法点,也没有办法设置。」
*
* 对齐 SyncCalendar 设置页里**与日程显示相关**的那几项
* (它那一页还有一堆 DAV 账号/镜像/系统的设置,那些家书用不上,不抄):
* · **默认视图**:月 / 周 / 列表
* · **日程显示方式**:时间轴 / 列表
* · **新建日程落到哪本日历**(默认日历本)
* · **哪些日历本可见**(逐本开关)
*
* 🔴🔴 v53 修的真 bug:「默认视图点不动」
* 根因:单选行原来是 `@Builder radioRow(type, hint, on: boolean, onTap)` ——
* **`@Builder` 的值类型参数不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`)⇒ 点一下 `this.view` 变了、
* 服务端也写了,但打勾/高亮那份子树**不重建** ⇒ 看着"点了没反应"。
* ⇒ 改成 `FbCalOptionRow`(`@ComponentV2` + `@Param`)。
*
* ⚠️ 这些设置**只存在本机**(`FbCalSettings` / preferences),
* 不上传服务端 —— 它是"这台设备上的人怎么看日历",不是共享数据。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalBook } from '../model/Types';
import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../data/FbCalSettings';
import { FbCalOptionRow } from './FbCalOptionRow';
import { FbDot } from './Widgets';
const FB_SHEET_BOTTOM_GAP: number = 16;
/** 视图选项 */
interface FbViewOpt {
key: FbCalView;
label: string;
hint: string;
}
const VIEW_OPTS: FbViewOpt[] = [
{ key: 'month', label: '月视图', hint: '看整月,点某天看当天日程' },
{ key: 'week', label: '周视图', hint: '看这一周,一周里的安排一目了然' },
{ key: 'agenda', label: '列表视图', hint: '从今天往后排,按天分组' }
];
/** 显示方式选项 */
interface FbStyleOpt {
key: FbCalStyle;
label: string;
hint: string;
}
const STYLE_OPTS: FbStyleOpt[] = [
{ key: 'timeline', label: '时间轴', hint: '按小时排布,一眼看出时间分布' },
{ key: 'list', label: '列表', hint: '一条一行,看得更清楚' }
];
@ComponentV2
export struct FbCalSettingsSheet {
/** 日历本(可见性开关要列出来) */
@Param books: FbCalBook[] = [];
/** 关掉 */
@Event onClose: () => void = (): void => {
};
/**
* 设置变了(父页面据此重新渲染 —— 比如切换视图后马上生效)。
*/
@Event onChanged: () => void = (): void => {
};
@Local view: FbCalView = 'month';
@Local style: FbCalStyle = 'timeline';
@Local bookId: number = 0;
aboutToAppear(): void {
this.view = FB_CAL_SETTINGS.getView();
this.style = FB_CAL_SETTINGS.getStyle();
this.bookId = FB_CAL_SETTINGS.getBookId();
}
/** 某一本当前是否可见 */
private visibleOf(id: number): boolean {
return FB_CAL_SETTINGS.isVisible(id);
}
/** 视图 key → 中文(给"当前:xx"那行用) */
private viewWord(v: FbCalView): string {
if (v === 'week') { return '周视图'; }
if (v === 'agenda') { return '列表视图'; }
return '月视图';
}
private styleWord(s: FbCalStyle): string {
return s === 'list' ? '列表' : '时间轴';
}
@Builder
sectionTitle(t: string) {
Text(t)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 4 })
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row({ space: 12 }) {
Column({ space: 2 }) {
Text('日程设置')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
// 🔴 把"当前是什么"直接写在标题下面 ——
// 用户点完一眼就能确认生效了(不用去几行里找哪颗打了勾)
Text('当前:' + this.viewWord(this.view) + ' · ' + this.styleWord(this.style))
.fontSize(FbMetric.fontTiny)
.fontColor('#4A7C82')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('完成')
.fontSize(FbMetric.fontSmall)
.fontColor('#4A7C82')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 6 }) {
// ---- 默认视图 ----
this.sectionTitle('打开的默认视图')
ForEach(VIEW_OPTS, (o: FbViewOpt) => {
// 🔴 用**组件**而不是 @Builder —— @Builder 的值类型参数不参与观察,
// 点了不会重建(这正是"默认视图点不动"的根因)
FbCalOptionRow({
label: o.label,
hint: o.hint,
active: this.view === o.key,
onPick: (): void => {
this.view = o.key;
FB_CAL_SETTINGS.setView(o.key);
this.onChanged();
}
})
}, (o: FbViewOpt): string => 'vo_' + o.key)
// ---- 显示方式 ----
this.sectionTitle('日程怎么显示')
ForEach(STYLE_OPTS, (o: FbStyleOpt) => {
FbCalOptionRow({
label: o.label,
hint: o.hint,
active: this.style === o.key,
onPick: (): void => {
this.style = o.key;
FB_CAL_SETTINGS.setStyle(o.key);
this.onChanged();
}
})
}, (o: FbStyleOpt): string => 'so_' + o.key)
// ---- 默认日历本 ----
if (this.books.length > 0) {
this.sectionTitle('新建日程默认加入')
ForEach(this.books, (b: FbCalBook) => {
if (b.canWrite) {
FbCalOptionRow({
label: b.name,
hint: '',
active: this.bookId === b.id,
dotColor: b.color,
onPick: (): void => {
this.bookId = b.id;
FB_CAL_SETTINGS.setBookId(b.id);
this.onChanged();
}
})
}
}, (b: FbCalBook): string => 'db_' + b.id.toString())
// ---- 哪些日历本可见 ----
this.sectionTitle('显示哪些日历本')
ForEach(this.books, (b: FbCalBook) => {
// ⚠️ 这里**不用** `FbCalOptionRow`(那个是"单选、带打勾"的行);
// 可见性是**独立开关**,一行配一个 Toggle 更直观。
Row({ space: 10 }) {
FbDot({ dia: 10, fillC: b.color })
Text(b.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Toggle({ type: ToggleType.Switch, isOn: this.visibleOf(b.id) })
.selectedColor(b.color)
.onChange((on: boolean): void => {
FB_CAL_SETTINGS.setVisible(b.id, on);
this.onChanged();
})
}
.width('100%')
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (b: FbCalBook): string => 'vb_' + b.id.toString())
}
Text('这些设置只存在这台手机上,不会同步给别人。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding({ top: 14 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,158 @@
/**
* 家书 FamilyBook · 影院列表浮层(鸿蒙端)
*
* 观影模块的"影院管理"入口(Web 端在观影页的设置里)。
*
* 🔴 为什么影院**不占抽屉里的位置**:
* 它不是独立模块,是"观影"的一部分 —— 一家影院只有"在哪、
* 叫什么、在那看过几部"这三个属性,独立成一页太空。
* ⇒ 做成观影页里的浮层(与详情、设置同一套交互)。
*
* ⚠️ 数据来自**本地库**(观影同步时一起落库的 `cinemas` 表),
* 不是现拉接口 —— 见 `FbMovieRepo.cinemaList()` 的说明。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCinemaItem } from '../data/FbMovieRepo';
/**
* 贴底卡片的手势区缓冲(vp)。
*
* 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)——
* 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段,
* 否则最后一排按钮会被"从下往上滑"误触。
*/
const FB_SHEET_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbCinemaSheet {
/** 影院列表 */
@Param cinemas: FbCinemaItem[] = [];
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
Column({ space: 0 }) {
// ---- 标题 ----
Row() {
Column({ space: 2 }) {
Text('影院')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.cinemas.length.toString() + ' 家 · 按名字排序')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Divider()
.color(FbColors.line)
.margin({ top: 12, bottom: 4 })
// ---- 列表 ----
// ⚠️ 用 List 而不是 ForEach 堆在 Column 里:影院可能几十家,
// List 才会**按需渲染**(进视口才建),Column 会一次全建。
if (this.cinemas.length === 0) {
Text('还没有影院。同步观影记录后,去过的地方会自动出现在这里。')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 32, bottom: 32, left: 12, right: 12 })
.textAlign(TextAlign.Center)
} else {
List({ space: 0 }) {
ForEach(this.cinemas, (c: FbCinemaItem, idx: number) => {
ListItem() {
Column({ space: 0 }) {
Row({ space: 12 }) {
// 色点:每家影院一个色(与 Web 端一致,便于在别处一眼认出来)
Column()
.width(10)
.height(10)
.backgroundColor(c.color)
.borderRadius(5)
Column({ space: 3 }) {
Row({ space: 8 }) {
Text(c.name)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.maxLines(1)
if (c.movieCount > 0) {
Text(c.movieCount.toString() + ' 部')
.fontSize(10)
.fontColor(FbColors.faint)
}
}
.alignItems(VerticalAlign.Center)
// 场所 / 地址(有才显示)
if (c.place !== '' || c.address !== '') {
Text(c.place !== '' ? c.place : c.address)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 4, right: 4, top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (idx < this.cinemas.length - 1) {
Divider()
.strokeWidth(0.5)
.color(FbColors.line)
}
}
.width('100%')
}
}, (c: FbCinemaItem): string => 'cin_' + c.id.toString())
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,182 @@
/**
* 家书 FamilyBook · 「某一天的日程」面板(鸿蒙端)
*
* 日程页里"日历下方 / 周条下方 / 列表里某天"共用的那一块。
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug):
* 「你在日历上点击任何一个地方,**下面的日程都不变**。」
* 根因:原来是 `@Builder dayPanel(iso: string)` ——
* **`@Builder` 的值类型参数不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」;
* 本项目已在 `FbTagChip` 上踩过同一个坑,见那份的文件头)。
* ⇒ 点格子后 `pickedIso` 确实变了、`eventsOf()` 也能算对,
* 但**已经建好的那份 Builder 子树不会重建** ⇒ 看起来"下面不变"。
* ⇒ 改成 `@ComponentV2` + `@Param`:`@Param` **是参与观察的**,
* 父组件传进来的日期/条目一变,这一整块就重建。
*
* ⚠️ 父组件必须把**算好的条目**传进来(`events`),
* 而不是传日期让本组件自己去查 —— 组件拿不到父组件的筛选状态。
* (传 `events` 还有个好处:每次都是新数组引用 ⇒ 一定触发重建。)
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEvent } from '../model/Types';
import { FbCalStyle } from '../data/FbCalSettings';
import { FbTimelineUtil } from '../common/FbTimeline';
import { FbDayTimeline } from './FbDayTimeline';
import { FbEmptyBox } from './Widgets';
@ComponentV2
export struct FbDayEventPanel {
/** 面板标题(`10月15日 周三`) */
@Param label: string = '';
/** 这一天的条目(**父组件已筛选排序好的**) */
@Param events: FbCalEvent[] = [];
/** 显示方式:时间轴 / 列表(来自日程设置) */
@Param style: FbCalStyle = 'timeline';
/** 是不是今天(时间轴画红线用) */
@Param isToday: boolean = false;
/** 当前时刻(当天第几分钟) */
@Param nowMin: number = 0;
/**
* 面板头是否显示。
*
* 🔴 v54:首页复用了本组件(用户 2026-10-16:「把**今天那个块**拿到首页上来」),
* 而首页自己有一个 `FbSectionTitle`("今天的日程 · N 条")——
* 两个头叠在一起会重复。⇒ 首页传 `false` 把内置头关掉。
* ⚠️ 日程页保持 `true`(那里没有别的标题)。
*/
@Param showHead: boolean = true;
/**
* 空态的提示语。空串 = 用默认那句(日程页:点右下角 + 加一条)。
*
* ⚠️ 首页**不能**说"点右下角的 +" —— 那是日程页的悬浮按钮,
* 首页没有它,说了等于骗人。
*/
@Param emptyHint: string = '';
/**
* 空态的标题。空串 = `这天没有安排`。
* ⚠️ 日程页的 `label` 是具体日期(`10月15日 周三`),说"这天"是对的;
* 首页说的是"今天",所以那边传 `今天没有安排`。
*/
@Param emptyTitle: string = '';
/** 点一条日程 */
@Event onPick: (e: FbCalEvent) => void = (e: FbCalEvent): void => {
};
build() {
Column({ space: 0 }) {
// ---- 面板头(首页会关掉它,见 showHead 的说明)----
if (this.showHead) {
Row() {
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.events.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
}
// ---- 主体 ----
if (this.events.length === 0) {
Column() {
FbEmptyBox({
title: this.emptyTitle !== '' ? this.emptyTitle : '这天没有安排',
hint: this.emptyHint !== '' ? this.emptyHint : '点右下角的 + 加一条日程',
icon: $r('app.media.fb_ic_calendar')
})
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
} else if (this.style === 'timeline') {
// ---- 时间轴:按真实时间铺在 0-24 小时格子上 ----
FbDayTimeline({
timeline: FbTimelineUtil.build(this.events, this.isToday, this.nowMin),
isToday: this.isToday,
nowMin: this.nowMin,
onPick: (e: FbCalEvent): void => {
this.onPick(e);
}
})
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 8 })
} else {
// ---- 列表:一条一行 ----
Column({ space: 0 }) {
ForEach(this.events, (ev: FbCalEvent, idx: number) => {
this.eventRow(ev, idx === this.events.length - 1)
}, (ev: FbCalEvent, idx: number): string =>
'ev_' + this.label + '_' + idx.toString() + '_' + ev.id.toString())
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
}
}
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 12 })
.alignItems(HorizontalAlign.Start)
}
/** 列表模式的一条(左侧色条 + 标题 + 时间 + 地点 + 日历本名) */
@Builder
eventRow(ev: FbCalEvent, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
Row()
.width(4)
.height(38)
.backgroundColor(ev.color)
.borderRadius(2)
Column({ space: 3 }) {
Text(ev.title !== '' ? ev.title : '(无标题)')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(ev.allDay ? '全天'
: (ev.endTime !== '' ? (ev.time + ' - ' + ev.endTime) : ev.time))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
if (ev.place !== '') {
Text('📍' + ev.place)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.layoutWeight(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(ev.calName)
.fontSize(10)
.fontColor(ev.color)
.maxLines(1)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onPick(ev);
})
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 14 })
}
}
.width('100%')
}
}
@@ -0,0 +1,476 @@
/**
* 家书 FamilyBook · 日程「时间轴」视图(鸿蒙端)
*
* 🔴 用户 2026-10-16:「**没有时间轴展示**……你这不是说让你把 SyncCalendar
* 里面的功能搬过来吗?」
* ⇒ 照 SyncCalendar 的 `DayTimelineView`(在 `Index.ets` 里)移植:
* 左侧小时刻度 + 右侧**三层堆叠**渲染。
*
* 为什么是"三层堆叠"(照 SyncCalendar 的设计,别改):
* · 第 1 层 **整点网格**:每小时一格、格底一条灰线;
* · 第 2 层 **日程色块**:按冲突组分列、纵向用 `Blank` 定位;
* · 第 3 层 **当前时间红线**:浮在最上面。
* ⚠️ 三层尺寸与分格完全一致、上层透明 ⇒
* **色块不会被整点线或红线"切开"**,红线也永远压在最上面。
* 如果图省事把线直接画进日程层,一条日程就会被切成两段。
*
* 🔴 为什么是"组件"而不是 `@Builder`:
* `@Builder` 的**值类型参数不参与状态观察**(见 `FbTagChip.ets` 的说明、
* SDK 规则 `r/quick-rules/05-state.md`)—— 本项目的日程页正是因此
* 出现"点日历下面的日程不变"。这里用 `@ComponentV2` + `@Param`,
* `timeline` 一变整棵子树就重建。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEvent } from '../model/Types';
import {
FbTimelineData, FbTimeBlock, FbTimeGroup, FbTimeRow, FbTimelineUtil, FbViewRange
} from '../common/FbTimeline';
/** 「当前时间红线」的颜色(照 SyncCalendar:系统红) */
const FB_NOW_RED: string = '#FF3B30';
@ComponentV2
export struct FbDayTimeline {
/** 一天的布局(**必须由父组件按选中日期算好传进来**) */
@Param timeline: FbTimelineData = new FbTimelineData();
/** 是不是"今天"(决定要不要画红线) */
@Param isToday: boolean = false;
/** 当前时刻(当天第几分钟) */
@Param nowMin: number = 0;
/** 点一条日程 */
@Event onPick: (e: FbCalEvent) => void = (e: FbCalEvent): void => {
};
/** 1 小时的高度(vp) */
private hourUnit: number = FbTimelineUtil.HOUR_UNIT;
/** 极短色块的可读高度下限(一行"标题 + 时间") */
private minBlockVp: number = 16;
/** 能放下两行的高度门槛 */
private twoLineVp: number = 30;
/** 能放下三行的高度门槛 */
private threeLineVp: number = 46;
// ---- 视窗 ----
private range(): FbViewRange {
return FbTimelineUtil.viewRange(this.timeline.blocks);
}
private viewTop(): number {
return this.range().startHour / 24;
}
private viewBot(): number {
return this.range().endHour / 24;
}
/** 视窗总高(vp) */
private viewH(): number {
const r: FbViewRange = this.range();
return (r.endHour - r.startHour) * this.hourUnit;
}
/** 整日高度(vp) */
private totalH(): number {
return this.hourUnit * 24;
}
/** 视窗内的小时刻度 */
private hourLabels(): number[] {
const r: FbViewRange = this.range();
const out: number[] = [];
for (let h = r.startHour; h < r.endHour; h++) {
out.push(h);
}
return out;
}
private hourText(h: number): string {
return h < 10 ? ('0' + h.toString() + ':00') : (h.toString() + ':00');
}
/** 当前小时是否高亮(仅今天) */
private isNowHour(h: number): boolean {
return this.isToday && Math.floor(FbTimelineUtil.nowRatio(this.nowMin) * 24) === h;
}
/** 红线是否可见(今天 + 落在视窗内) */
private nowVisible(): boolean {
if (!this.isToday) {
return false;
}
const r: number = FbTimelineUtil.nowRatio(this.nowMin);
return r >= this.viewTop() - 0.0001 && r <= this.viewBot() + 0.0001;
}
/** 红线距层顶的偏移(vp) */
private nowOffsetVp(): number {
const y: number = (FbTimelineUtil.nowRatio(this.nowMin) - this.viewTop()) * this.totalH() - 1;
return y < 0 ? 0 : y;
}
// ---- 行序列(空隙 + 冲突组)----
private groups(): FbTimeGroup[] {
return FbTimelineUtil.groupsOf(this.timeline.blocks);
}
/**
* 纵向序列:只看视窗内的部分。
* **本层只有两样东西:空隙(Blank)与冲突组行** ——
* 整点线与红线交给另外两层,所以每条日程永远是完整连续的色块。
*/
private buildRows(): FbTimeRow[] {
const rows: FbTimeRow[] = [];
const gs: FbTimeGroup[] = this.groups();
const top: number = this.viewTop();
const bot: number = this.viewBot();
const dk: string = this.range().startHour.toString() + '_' + this.range().endHour.toString();
let cursor: number = top;
for (const g of gs) {
const gsx: number = g.startRatio > top ? g.startRatio : top;
const gex: number = g.endRatio < bot ? g.endRatio : bot;
if (gex - gsx <= 0.0002) {
continue; // 整组都在视窗外
}
this.pushRow(rows, cursor, gsx, null, dk);
this.pushRow(rows, gsx, gex, g, dk);
cursor = gex;
}
this.pushRow(rows, cursor, bot, null, dk);
return rows;
}
private pushRow(rows: FbTimeRow[], from: number, to: number,
g: FbTimeGroup | null, dk: string): void {
if (to - from <= 0.0002) {
return;
}
const r: FbTimeRow = new FbTimeRow();
r.topRatio = from;
r.botRatio = to;
if (g === null) {
r.kind = 0;
r.key = 'g_' + dk + '_' + rows.length.toString() + '_'
+ Math.round((to - from) * 100000).toString();
} else {
r.kind = 1;
r.group = g;
let sig: string = '';
for (const b of g.blocks) {
sig = sig + '_' + b.key;
}
r.key = 'b_' + dk + '_' + rows.length.toString() + sig;
}
rows.push(r);
}
/** 某一列中、与本行窗口有交集的色块 */
private laneBlocks(r: FbTimeRow, lane: FbTimeBlock[]): FbTimeBlock[] {
const out: FbTimeBlock[] = [];
for (const b of lane) {
const be: number = b.topRatio + b.heightRatio;
if (be > r.topRatio + 0.0001 && b.topRatio < r.botRatio - 0.0001) {
out.push(b);
}
}
return out;
}
/** 列内第 index 个色块之前要空出的高度(vp) */
private lanePadH(r: FbTimeRow, lane: FbTimeBlock[], index: number): number {
const arr: FbTimeBlock[] = this.laneBlocks(r, lane);
let prevEnd: number = r.topRatio;
if (index > 0) {
const p: FbTimeBlock = arr[index - 1];
const pe: number = p.topRatio + p.heightRatio;
prevEnd = pe > r.topRatio ? pe : r.topRatio;
}
const b: FbTimeBlock = arr[index];
const top: number = b.topRatio > r.topRatio ? b.topRatio : r.topRatio;
const d: number = (top - prevEnd) * this.totalH();
return d < 0 ? 0 : d;
}
/** 色块实际渲染高度(vp):极短的尽量垫到可读高度,但不越过同列下一块 */
private blockVp(r: FbTimeRow, lane: FbTimeBlock[], index: number): number {
const arr: FbTimeBlock[] = this.laneBlocks(r, lane);
const b: FbTimeBlock = arr[index];
const real: number = b.heightRatio * this.totalH();
if (real >= this.minBlockVp) {
return real;
}
let room: number = 0;
if (index + 1 < arr.length) {
room = this.lanePadH(r, lane, index + 1);
} else {
const tail: number = (r.botRatio - (b.topRatio + b.heightRatio)) * this.totalH();
room = tail > 0 ? tail : 0;
}
const h: number = real + room;
return h > this.minBlockVp ? this.minBlockVp : h;
}
/** 色块信息行数(按渲染高度判定) */
private blockLines(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): number {
const h: number = this.blockVp(r, lane, index);
if (h >= this.threeLineVp && b.place !== '') {
return 3;
}
if (h >= this.twoLineVp) {
return 2;
}
return 1;
}
/** 两行色块的第二行:`09:00 - 10:30 · 地点` */
private blockMeta(b: FbTimeBlock): string {
if (b.place !== '') {
return b.timeText + ' · ' + b.place;
}
return b.timeText;
}
/** 色块 key 的内容签名(内容变了必须重建,否则 ArkUI 复用旧子组件) */
private blockSig(r: FbTimeRow, lane: FbTimeBlock[], index: number, b: FbTimeBlock): string {
return b.title.length.toString() + '_' + b.place.length.toString() + '_'
+ Math.round(this.blockVp(r, lane, index)).toString();
}
// ============================================================
// 三层
// ============================================================
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
@Builder
gridLayer() {
Column() {
ForEach(this.hourLabels(), (h: number) => {
Column()
.width('100%')
.height(this.hourUnit)
.border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } })
.hitTestBehavior(HitTestMode.None)
}, (h: number): string => 'gd_' + h.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None) // 装饰层:点击穿透到色块
}
/** 第 2 层:日程色块(冲突组分行 + 组内分列,纵向用 Blank 定位) */
@Builder
eventLayer() {
Column() {
ForEach(this.buildRows(), (r: FbTimeRow) => {
if (r.kind === 1 && r.group !== null) {
Row() {
ForEach(r.group!.lanes, (lane: FbTimeBlock[], li: number) => {
Column() {
ForEach(this.laneBlocks(r, lane), (b: FbTimeBlock, index: number) => {
Blank().height(this.lanePadH(r, lane, index))
Column({ space: 1 }) {
if (this.blockLines(r, lane, index, b) === 1) {
// 一行:标题 · 时间(地点放不下)
Row({ space: 4 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '55%' })
}
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
if (this.blockLines(r, lane, index, b) >= 3) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.place)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
Text(this.blockMeta(b))
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Start)
.padding({ left: 6, right: 3, top: 2, bottom: 2 })
.borderRadius(6)
.backgroundColor(b.color)
.clip(true)
.width('100%')
.height(this.blockVp(r, lane, index))
.onClick((): void => {
if (b.ev !== null) {
this.onPick(b.ev);
}
})
}, (b: FbTimeBlock, index: number): string =>
'blk_' + r.key + '_' + b.key + '_' + this.blockSig(r, lane, index, b))
}
.layoutWeight(1)
.height('100%')
.padding({ right: 2 })
.clip(true)
}, (lane: FbTimeBlock[], li: number): string => 'ln_' + r.key + '_' + li.toString())
}
.width('100%')
.height((r.botRatio - r.topRatio) * this.totalH())
.alignItems(VerticalAlign.Top)
} else {
// 空隙:纯占位
Blank().height((r.botRatio - r.topRatio) * this.totalH())
}
}, (r: FbTimeRow): string => r.key)
}
.width('100%')
.height('100%')
}
/** 第 3 层:当前时间红线(浮在最上层) */
@Builder
nowLayer() {
if (this.nowVisible()) {
Row() {
// 左侧时间文字(红线那头)
Text(FbTimelineUtil.hhmm(this.nowMin))
.fontSize(9)
.fontColor(FB_NOW_RED)
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.End)
.width(34)
Row()
.layoutWeight(1)
.height(2)
.backgroundColor(FB_NOW_RED)
Row()
.width(6)
.height(6)
.borderRadius(3)
.backgroundColor(FB_NOW_RED)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: this.nowOffsetVp() })
.hitTestBehavior(HitTestMode.None)
}
}
build() {
Column({ space: 0 }) {
// ---- 全天 / 跨天:画在 0 点之前,用色块展示 ----
if (this.timeline.allDay.length > 0) {
Column({ space: 4 }) {
ForEach(this.timeline.allDay, (e: FbCalEvent) => {
Row({ space: 6 }) {
Text('全天')
.fontSize(9)
.fontColor(Color.White)
.backgroundColor('#26000000')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
Text(e.title !== '' ? e.title : '(无标题)')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (e.place !== '') {
Text(e.place)
.fontSize(10)
.fontColor('#D9FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 110 })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.height(26)
.padding({ left: 8, right: 8 })
.borderRadius(8)
.backgroundColor(e.color)
.onClick((): void => {
this.onPick(e);
})
}, (e: FbCalEvent): string => 'al_' + FbTimelineUtil.keyOf(e))
}
.width('100%')
.padding({ bottom: 6 })
}
// ---- 时间轴主体 ----
Row() {
// 左侧小时刻度
Column() {
ForEach(this.hourLabels(), (h: number) => {
Text(this.hourText(h))
.fontSize(10)
.fontColor(this.isNowHour(h) ? FB_NOW_RED : FbColors.faint)
.width(36)
.height(this.hourUnit)
.textAlign(TextAlign.End)
.padding({ right: 4, top: 2 })
.border({ width: { bottom: 0.5 }, color: { bottom: FbColors.line2 } })
}, (h: number): string => 'h' + h.toString())
}
.width(36)
// 右侧内容区:三层堆叠
Stack() {
this.gridLayer()
this.eventLayer()
this.nowLayer()
}
.layoutWeight(1)
.height(this.viewH())
.alignContent(Alignment.TopStart)
.border({ width: { left: 0.5 }, color: { left: FbColors.line2 } })
}
.width('100%')
.alignItems(VerticalAlign.Top)
// ---- 当天没有带时间的日程 ----
if (!this.timeline.hasTimed && this.timeline.allDay.length === 0) {
Text('这天没有安排')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 20, bottom: 20 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
@@ -0,0 +1,50 @@
/**
* 家书 FamilyBook · 可选中胶囊片(鸿蒙端)
*
* 日程「新建/编辑」里给**重复**和**提醒**两组选项用。
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`(用户 2026-10-16 报的真 bug):
* 「重复和提醒都挤在一块了……而且**重复和提醒什么的都点不了**。」
* 根因:原来是 `FbEventEditSheet.chip(label, on: boolean, onTap)` ——
* **`@Builder` 的值类型参数(boolean)不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`:按值传递「状态变化刷新 ❌」)。
* ⇒ 点一下 `this.fRepeat` / `this.fRemind` 确实改了、保存也发得出去,
* 但**选中态那份子树不重建** ⇒ 界面上没有任何反馈 ⇒ 看着"点不了"。
* 本项目已在 `FbTagChip`、`FbCalOptionRow` 上踩过同一个坑(各见其文件头)。
* ⇒ 统一做法:抽成 `@ComponentV2` + `@Param active`。
*
* ⚠️ 顺带的另一处:它原来是裸放在 `Flex({wrap})` 里、**没有 margin** ——
* 项目里别处的 wrap 都写了 `.margin({ right: 8, bottom: 8 })`
* (见 `FbHomePage` 的标签云、`FbSettingsPage` 的色板)。
* 少了 margin,两行 chip 会**贴在一起**(这正是"挤在一块"的观感来源)。
* ⇒ margin 放在**本组件根节点**上,调用方不用再写。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbEventChip {
/** 显示文字 */
@Param label: string = '';
/** 是否选中(**必须由父组件按状态算好传进来**) */
@Param active: boolean = false;
/** 选中时的底色(默认用日程模块的主题色;日历本那组传本色) */
@Param activeBg: string = '#4A7C82';
/** 被点击 */
@Event onPick: () => void = (): void => {
};
build() {
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.active ? Color.White : FbColors.muted)
.backgroundColor(this.active ? this.activeBg : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
// ⚠️ 间距放在组件内部:wrap 容器里的子项必须自己有 margin,否则会贴在一起
.margin({ right: 8, bottom: 8 })
.onClick((): void => {
this.onPick();
})
}
}
@@ -0,0 +1,908 @@
/**
* 家书 FamilyBook · 日程新建 / 编辑浮层(鸿蒙端)
*
* 用户 2026-10-15:「我们家书的日程功能模块……跟 SyncCalendar 做得一样」。
*
* 字段对齐 SyncCalendar 的 `EventEditPage`:
* **标题 / 全天 / 日期与时间(同一行)/ 重复 / 提醒 / 日历本 / 地点 / 备注**,
* 编辑既有日程时底部还有一颗**删除**。
*
* 🔴 用户 2026-10-16 的两条修正:
* 1. 「**重复和提醒都挤在一块了**……而且**重复和提醒什么的都点不了**。」
* · "点不了" = chip 曾是 `@Builder` 值参数(不参与观察)⇒ 换成 `FbEventChip` 组件;
* · "挤在一块" = 裸 `Flex({wrap})` 里的 chip **没有 margin** ⇒
* 间距放进 `FbEventChip` 组件内部(项目别处的 wrap 也都写了 margin)。
* 2. 「时间和日期……**放在一行上吧**,别放上下了。」
* · 原来开始/结束各占一整行(`日期 时间` 拼成一串)。
* 3. **第二轮**:「你参考一下 SyncCalendar 的做法,实际上就是
* **把日期和时间放在一个控件里面**。」
* · 🔴 第一版我只做到"日期块 + 时间块并排一行",**但仍是两个可点区**、
* 点开的是**两个独立选择器** —— 不是 SyncCalendar 的形态。
* · SyncCalendar(`EventEditPage.dateTimePickerSheet`)是:
* **一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**,
* 选完点「确定」**一次回填**。
* · ⇒ 改成:控件是**一整块可点的框**(显示 `10月16日 周五 20:00`),
* 点它开弹层、里面两个滚轮并排、点「确定」写回;
* 弹层内部用**草稿**(`pickDate`/`pickHour`/`pickMin`)—— 取消可丢弃。
*
* 🔴 为什么用**浮层**而不是像 SyncCalendar 那样跳新页面:
* 本项目的浮层范式已经统一(见 `FbPersonEditSheet` / `FbMovieDetailSheet`)——
* 都是页面内的 `Stack` 覆盖层。跳页面会丢列表滚动位置、还要处理返回栈;
* 浮层状态完全由父页面掌控(一个 `@Param`),更简单。
* ⚠️ 与 SyncCalendar 的**功能对齐**是有意的;**形态不同**是有意的。
*
* ⚠️ 服务端 `POST/PATCH /api/v1/events` 的字段名是**下划线**(`starts_at` 等),
* 且"没传的字段沿用旧值" ⇒ 编辑时只需发改动的那些(见 `FbCalRepo.toBody`)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEventFull, FbCalEventInput, FbCalRepo } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbEventChip } from './FbEventChip';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbEventEdit';
/** 贴底卡片的手势区缓冲(同其他浮层) */
const FB_SHEET_BOTTOM_GAP: number = 16;
/**
* 提醒选项(分钟 → 文案)。
*
* 🔴 与 SyncCalendar 的 `reminderChip` 同一组值(0/5/10/15/30/60/1440)——
* 两边口径一致,用户在两个 App 里看到的选项不会不一样。
*/
interface FbRemindOpt {
min: number;
label: string;
}
const REMIND_OPTS: FbRemindOpt[] = [
{ min: 0, label: '不提醒' },
{ min: 5, label: '提前5分钟' },
{ min: 10, label: '提前10分钟' },
{ min: 15, label: '提前15分钟' },
{ min: 30, label: '提前30分钟' },
{ min: 60, label: '提前1小时' },
{ min: 1440, label: '提前1天' }
];
/**
* 重复选项(key → 文案 + RRULE)。
*
* ⚠️ 只做**最常见的那几种**(对齐 SyncCalendar 的 chip 组),
* "每两周/自定义"这类留给网页端 —— 手机上点五层下拉去拼 RRULE 得不偿失。
* ⚠️ 端上拼的是**完整 RRULE 串**,服务端 `rruleFromBody()` 也认这个口径。
*/
interface FbRepeatOpt {
key: string;
label: string;
rule: string;
}
const REPEAT_OPTS: FbRepeatOpt[] = [
{ key: 'none', label: '不重复', rule: '' },
{ key: 'daily', label: '每天', rule: 'FREQ=DAILY' },
{ key: 'weekday', label: '工作日', rule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' },
{ key: 'weekly', label: '每周', rule: 'FREQ=WEEKLY' },
{ key: 'monthly', label: '每月', rule: 'FREQ=MONTHLY' },
{ key: 'yearly', label: '每年', rule: 'FREQ=YEARLY' }
];
@ComponentV2
export struct FbEventEditSheet {
/** 要编辑的日程(null + isCreate=false 表示没打开;isCreate=true 时是"新建") */
@Param event: FbCalEventFull | null = null;
/** 新建模式 */
@Param isCreate: boolean = false;
/** 新建时预填的日期(`YYYY-MM-DD`,从"点哪天的格子"来) */
@Param presetDate: string = '';
/** 可选的日历本(新建时要选落到哪本) */
@Param books: FbCalBook[] = [];
/** 取消 */
@Event onCancel: () => void = (): void => {
};
/** 保存成功 */
@Event onSaved: () => void = (): void => {
};
/** 删除成功 */
@Event onDeleted: () => void = (): void => {
};
// ---- 表单状态 ----
@Local fTitle: string = '';
@Local fAllDay: boolean = false;
/** `YYYY-MM-DD` */
@Local fStartDate: string = '';
/** `HH:mm` */
@Local fStartTime: string = '09:00';
@Local fEndDate: string = '';
@Local fEndTime: string = '10:00';
@Local fPlace: string = '';
@Local fNote: string = '';
/** 重复的 key(REPEAT_OPTS 里那个) */
@Local fRepeat: string = 'none';
/** 提醒分钟(可多选;空数组 = 不提醒) */
@Local fRemind: number[] = [];
/** 选中的日历本 id */
@Local fBookId: number = 0;
@Local busy: boolean = false;
@Local err: string = '';
@Local confirmDel: boolean = false;
@Local deleting: boolean = false;
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar 的 `dateTimePickerSheet`)----
/**
* 弹层开着吗。
*
* 🔴 **为什么不是"就地展开"**(这是用户 2026-10-16 的第二条反馈):
* SyncCalendar 的做法是**一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**
* (见其 `EventEditPage.dateTimePickerSheet`),选完点「确定」一次回填。
* 我第一版做成了"点日期块就地展开一个 DatePicker、点时间块再展开一个 TimePicker"
* —— 那是两个控件、分两次选,不是它的做法。
*/
@Local pickOpen: boolean = false;
/** 正在选的是"结束"(false = 开始) */
@Local pickIsEnd: boolean = false;
/** 弹层里的**草稿**值:点「确定」才写回 `fStartXxx`/`fEndXxx`(取消则丢弃) */
@Local pickDate: Date = new Date();
@Local pickHour: number = 9;
@Local pickMin: number = 0;
aboutToAppear(): void {
const e: FbCalEventFull | null = this.event;
if (e !== null) {
// ---- 编辑:回填 ----
this.fTitle = e.title;
this.fAllDay = e.all_day;
this.fStartDate = this.dateOf(e.starts_at);
this.fStartTime = this.timeOf(e.starts_at);
this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at);
this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : this.addHour(e.starts_at);
this.fPlace = e.location;
this.fNote = e.note;
this.fRepeat = this.repeatKeyOf(e.rrule);
this.fRemind = this.minsOf(e.reminder_minutes);
this.fBookId = e.calendar_id;
return;
}
// ---- 新建:预填 ----
const d: string = this.presetDate !== '' ? this.presetDate : this.todayStr();
this.fStartDate = d;
this.fEndDate = d;
// 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 ——
// 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。
const now: Date = new Date();
const nh: number = Math.min(now.getHours() + 1, 22);
this.fStartTime = this.pad(nh) + ':00';
this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00';
this.fBookId = this.defaultBookId();
}
// ============================================================
// 取值 / 换算
// ============================================================
private todayStr(): string {
const n: Date = new Date();
return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate());
}
private pad(n: number): string {
return n < 10 ? ('0' + n.toString()) : n.toString();
}
/** 从 `YYYY-MM-DD HH:MM:SS` 取日期段 */
private dateOf(dt: string): string {
return dt.length >= 10 ? dt.substring(0, 10) : this.todayStr();
}
/** 从 `YYYY-MM-DD HH:MM:SS` 取时间段的 `HH:mm` */
private timeOf(dt: string): string {
return dt.length >= 16 ? dt.substring(11, 16) : '09:00';
}
/** 给一个时间的时分 +1 小时(当结束时间没填时的兜底) */
private addHour(dt: string): string {
const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9;
const h2: number = Math.min(hh + 1, 23);
return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '00');
}
/** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */
private defaultBookId(): number {
const want: number = FB_CAL_SETTINGS.getBookId();
for (const b of this.books) {
if (b.id === want && b.canWrite) {
return b.id;
}
}
for (const b of this.books) {
if (b.canWrite) {
return b.id;
}
}
return 0;
}
/** 重复:从 RRULE 串反推是哪个 chip(认不出就归"不重复") */
private repeatKeyOf(rrule: string): string {
if (rrule === '') {
return 'none';
}
for (const o of REPEAT_OPTS) {
if (o.rule !== '' && rrule.indexOf(o.rule) === 0) {
return o.key;
}
}
// 认不出的复杂规则:给"不重复"更危险(会覆盖掉原规则),
// 但端上确实没有编辑自定义规则的能力 ⇒ 保留原文不覆盖(见 save() 的处理)
return 'none';
}
/** 重复 chip → RRULE 串 */
private ruleOfRepeat(): string {
for (const o of REPEAT_OPTS) {
if (o.key === this.fRepeat) {
return o.rule;
}
}
return '';
}
/** 提醒串 → 分钟数组 */
private minsOf(s: string): number[] {
const out: number[] = [];
if (s === '') {
return out;
}
for (const x of s.split(',')) {
const n: number = parseInt(x.trim(), 10);
if (!isNaN(n) && n > 0) {
out.push(n);
}
}
return out;
}
/** 登录令牌 */
private token(): string {
return FB_TOKEN_STORE.get();
}
// ============================================================
// 小件
// ============================================================
@Builder
field(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
TextInput({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
area(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
TextArea({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 小标题(把一大串字段分开) */
@Builder
section(title: string) {
Text(title)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 6 })
}
/**
* ⚠️ 这里删掉过两个 `@Builder`,都**不要**加回来:
* · `chip(label, on: boolean, onTap)` —— "重复/提醒点不了"的根因
* (@Builder 值参数不参与观察)⇒ 已换成 `FbEventChip` 组件。
* · `pickRow(label, value, onTap)` —— 被"日期时间并排一行"取代
* (用户要求时间和日期同一行,不再上下堆)⇒ 见 `dateTimeRow`。
*/
build() {
// 🔴 外层 `Stack` 是**必需**的:本组件有**三个**要显示的层
// (编辑卡片 + 日期时间弹层 + 删除确认遮罩),而 build() 只能有一个根节点。
// ⚠️ `alignContent: Bottom` 让卡片/弹层都贴底(同一形态)。
Stack({ alignContent: Alignment.Bottom }) {
this.editCard()
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar)----
if (this.pickOpen) {
this.dateTimePickerSheet()
}
if (this.confirmDel) {
this.deleteConfirm()
}
}
.width('100%')
.height('100%')
}
@Builder
editCard() {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row({ space: 12 }) {
Text(this.isCreate ? '新建日程' : '编辑日程')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick((): void => {
this.onCancel();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
this.field('标题', this.fTitle, '日程得有个标题', (v: string): void => {
this.fTitle = v;
})
// ---- 全天 ----
Row() {
Text('全天')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.fAllDay })
.selectedColor('#4A7C82')
.onChange((on: boolean): void => {
this.fAllDay = on;
})
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
// ---- 日期与时间 ----
// 🔴 照 SyncCalendar:**一行一个"日期 + 时间"控件**(点开一个弹层、
// 里面 DatePicker 与 TimePicker 并排,选完点「确定」)。
// 用户 2026-10-16:「实际上就是把日期和时间放在一个控件里面。」
this.section('日期与时间')
this.dateTimeRow('开始', true)
if (!this.fAllDay) {
this.dateTimeRow('结束', false)
}
// ---- 重复 ----
// 🔴 用 `FbEventChip` **组件**而不是 @Builder ——
// @Builder 的值类型参数(on: boolean)变化**不触发重建**,
// 这正是"重复/提醒点不了"的根因(见该组件文件头)。
this.section('重复')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(REPEAT_OPTS, (o: FbRepeatOpt) => {
FbEventChip({
label: o.label,
active: this.fRepeat === o.key,
onPick: (): void => {
this.fRepeat = o.key;
}
})
}, (o: FbRepeatOpt): string => 'rp_' + o.key)
}
.width('100%')
.margin({ top: 2 })
// ---- 提醒 ----
this.section('提醒(可多选)')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(REMIND_OPTS, (o: FbRemindOpt) => {
FbEventChip({
label: o.label,
active: this.hasRemind(o.min),
onPick: (): void => {
this.toggleRemind(o.min);
}
})
}, (o: FbRemindOpt): string => 'rm_' + o.min.toString())
}
.width('100%')
.margin({ top: 2 })
// ---- 日历本 ----
if (this.books.length > 0) {
this.section('加入哪本日历')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.books, (b: FbCalBook) => {
FbEventChip({
label: b.name,
active: this.fBookId === b.id,
// 选中时用**这本日历自己的颜色**(一眼看出落到哪本)
activeBg: b.color,
onPick: (): void => {
this.fBookId = b.id;
}
})
}, (b: FbCalBook): string => 'bk_' + b.id.toString())
}
.width('100%')
}
// ---- 地点 / 备注 ----
this.field('地点', this.fPlace, '地点(可选)', (v: string): void => {
this.fPlace = v;
})
this.area('备注', this.fNote, '备注(可选)', (v: string): void => {
this.fNote = v;
})
if (this.err !== '') {
Text(this.err)
.fontSize(FbMetric.fontTiny)
.fontColor('#A32D2D')
.width('100%')
}
}
.width('100%')
.padding({ top: 14 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
// ---- 底部按钮:删除 / 保存 ----
if (this.deleting) {
Text('删除中…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.margin({ top: 16 })
} else {
Row({ space: 10 }) {
if (!this.isCreate) {
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor('#C0533F')
.textAlign(TextAlign.Center)
.height(42)
.padding({ left: 18, right: 18 })
.border({ width: 1, color: '#C0533F' })
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.confirmDel = true;
}
})
}
Text(this.busy ? '保存中…' : (this.isCreate ? '创建' : '保存'))
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(this.busy ? '#4A7C8288' : '#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.save();
}
})
}
.width('100%')
.margin({ top: 16 })
}
}
.width('100%')
.constraintSize({ maxHeight: '86%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.alignItems(HorizontalAlign.Start)
}
/**
* **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。
*
* 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是
* **把日期和时间放在一个控件里面**。」
* 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与
* `TimePicker` 并排**,选完点「确定」一次回填。
* ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker"
* —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。
*
* 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`;
* 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。
*/
@Builder
dateTimeRow(label: string, isStart: boolean) {
Row({ space: 10 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(28)
// 整块可点:日期与时间在**同一个控件**里,中间不再分两块
Row() {
Text(this.cnDate(isStart ? this.fStartDate : this.fEndDate))
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
if (!this.fAllDay) {
Text(' ' + (isStart ? this.fStartTime : this.fEndTime))
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
}
}
.layoutWeight(1)
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.openPicker(isStart);
})
// 右侧小箭头:暗示"点开会选"(与 SyncCalendar 的行样式一致)
Text('›')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.faint)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
/**
* 打开「日期 + 时间」弹层(把当前值装进草稿)。
*
* ⚠️ 草稿的意义:用户滚了一半又点「取消」⇒ **不写回**(`fStartXxx` 保持原样)。
* 若直接改 `fStartXxx`,"取消"就没法撤销了。
*/
private openPicker(isEnd: boolean): void {
this.pickIsEnd = isEnd;
const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate);
this.pickDate = d;
const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime);
this.pickHour = tm.getHours();
this.pickMin = tm.getMinutes();
this.pickOpen = true;
}
/** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */
private applyPicker(): void {
const iso: string = this.pad(this.pickDate.getFullYear()) + '-'
+ this.pad(this.pickDate.getMonth() + 1) + '-' + this.pad(this.pickDate.getDate());
const hm: string = this.pad(this.pickHour) + ':' + this.pad(this.pickMin);
if (this.pickIsEnd) {
this.fEndDate = iso;
this.fEndTime = hm;
} else {
this.fStartDate = iso;
this.fStartTime = hm;
// ⚠️ 开始改到"结束之后" ⇒ 把结束顺延到同一时刻(服务端不校验,但负时长是坏的)
if (this.fEndDate < iso || (this.fEndDate === iso && this.fEndTime < hm)) {
this.fEndDate = iso;
this.fEndTime = hm;
}
}
this.pickOpen = false;
}
/**
* 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。
*
* 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。
* ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**:
* 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。
* ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。
*/
@Builder
dateTimePickerSheet() {
Column({ space: 14 }) {
Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12 })
Row({ space: 6 }) {
DatePicker({
start: new Date('2000-01-01'),
end: new Date('2035-12-31'),
selected: this.pickDate
})
.onDateChange((v: Date): void => {
this.pickDate = v;
})
.textStyle({ color: FbColors.faint, font: { size: 14 } })
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
.layoutWeight(1)
.height(210)
if (!this.fAllDay) {
TimePicker({ selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) })
.useMilitaryTime(true)
.onChange((v: TimePickerResult): void => {
// ⚠️ `hour`/`minute` 是**数字**;这里存草稿数字,拼 `HH:MM` 时再补零
this.pickHour = v.hour;
this.pickMin = v.minute;
})
.textStyle({ color: FbColors.faint, font: { size: 14 } })
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
.layoutWeight(1)
.height(210)
}
}
.width('100%')
Button(this.fAllDay ? '确定日期' : '确定')
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.fontSize(FbMetric.fontBody)
.backgroundColor('#4A7C82')
.fontColor(Color.White)
.onClick((): void => {
this.applyPicker();
})
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.padding({ left: 18, right: 18, top: 8, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.alignItems(HorizontalAlign.Start)
}
/** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */
private cnDate(iso: string): string {
if (iso.length < 10) {
return iso;
}
const y: number = parseInt(iso.substring(0, 4), 10);
const m: number = parseInt(iso.substring(5, 7), 10);
const d: number = parseInt(iso.substring(8, 10), 10);
const w: string[] = ['日', '一', '二', '三', '四', '五', '六'];
return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()];
}
private parseDate(s: string): Date {
if (s.length < 10) {
return new Date();
}
const y: number = parseInt(s.substring(0, 4), 10);
const m: number = parseInt(s.substring(5, 7), 10) - 1;
const d: number = parseInt(s.substring(8, 10), 10);
return new Date(y, m, d);
}
private parseTime(s: string): Date {
const out: Date = new Date();
if (s.length >= 5) {
out.setHours(parseInt(s.substring(0, 2), 10));
out.setMinutes(parseInt(s.substring(3, 5), 10));
}
return out;
}
@Builder
deleteConfirm() {
Column() {
Column({ space: 8 }) {
Text('删除这条日程?')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('删掉之后不能撤销。若这条日程是重复的,这一次和以后的都会不见。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(3)
Row({ space: 10 }) {
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
})
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor('#C0533F')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
this.doDelete();
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(18)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding({ left: 32, right: 32 })
.backgroundColor('#00000066')
.onClick((): void => {
this.confirmDel = false;
})
}
// ============================================================
// 提醒多选
// ============================================================
private hasRemind(min: number): boolean {
// 0 = "不提醒":选中它等价于"没有任何提醒"
if (min === 0) {
return this.fRemind.length === 0;
}
return this.fRemind.indexOf(min) >= 0;
}
private toggleRemind(min: number): void {
if (min === 0) {
this.fRemind = [];
return;
}
const at: number = this.fRemind.indexOf(min);
if (at >= 0) {
this.fRemind.splice(at, 1);
} else {
this.fRemind.push(min);
}
}
// ============================================================
// 网络
// ============================================================
private async doDelete(): Promise<void> {
const e: FbCalEventFull | null = this.event;
if (e === null) {
return;
}
const token: string = this.token();
if (token === '') {
this.err = '未登录,无法删除';
return;
}
this.deleting = true;
this.err = '';
const res = await FbCalRepo.inst().remove(e.id, token);
this.deleting = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '删除日程失败:%{public}s', res.error);
this.err = '删除失败:' + res.error;
return;
}
this.onDeleted();
}
/** 保存(新建走 POST、编辑走 PATCH) */
private async save(): Promise<void> {
const title: string = this.fTitle.trim();
if (title === '') {
this.err = '日程得有个标题';
return;
}
const token: string = this.token();
if (token === '') {
this.err = this.isCreate ? '未登录,无法创建' : '未登录,无法保存';
return;
}
const isNew: boolean = this.isCreate || this.event === null;
if (isNew && this.fBookId <= 0) {
this.err = '请选一本日历';
return;
}
// ---- 时间:全天归零到 00:00;否则拼上时分 ----
const startAt: string = this.fAllDay
? (this.fStartDate + ' 00:00:00')
: (this.fStartDate + ' ' + this.fStartTime + ':00');
let endAt: string = this.fAllDay
? (this.fEndDate + ' 00:00:00')
: (this.fEndDate + ' ' + this.fEndTime + ':00');
// ⚠️ 结束早于开始 ⇒ 补成同一时刻(服务端不校验时长,但"负时长"看着是坏的)
if (endAt < startAt) {
endAt = startAt;
}
this.busy = true;
this.err = '';
const input: FbCalEventInput = {
title: title,
location: this.fPlace.trim(),
starts_at: startAt,
ends_at: endAt,
all_day: this.fAllDay,
note: this.fNote,
reminder_minutes: this.fRemind
};
// ⚠️ 重复:只在用户**确实选过**时才发。
// 编辑一条"自定义重复"的日程时,端上认不出来(归到"不重复"),
// 若照样发个空 rrule,就会把人家原来的规则**清掉**。
// ⇒ 规则原文认不出(非空且 chip 是 none)⇒ 这一项**不发**,保持原样。
const recognize: boolean = this.fRepeat !== 'none'
|| (this.event !== null && this.event.rrule === '');
if (recognize) {
input.rrule = this.ruleOfRepeat();
}
if (isNew) {
input.calendar_id = this.fBookId;
}
const repo: FbCalRepo = FbCalRepo.inst();
const res = isNew ? await repo.create(input, token) : await repo.update(this.event!.id, input, token);
this.busy = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '保存日程失败:%{public}s', res.error);
this.err = (isNew ? '创建失败:' : '保存失败:') + res.error;
return;
}
// 新建时把选的日历本记成"以后默认用它"(与 SyncCalendar 的默认本同一体验)
if (isNew) {
FB_CAL_SETTINGS.setBookId(this.fBookId);
}
this.onSaved();
}
}
@@ -0,0 +1,269 @@
/**
* 家书 FamilyBook · 日程详情浮层(鸿蒙端)
*
* 用户 2026-10-15:「……包括下面日程的显示啊什么之类的,就按照 SyncCalendar 那个模式去做。」
* SyncCalendar 的详情是**半屏 bindSheet**,内容为:
* 标题(带日历本色点 + 系统/只读徽标 + 关闭)→ 时间 → 日历本/重复 →
* 地点(可点导航)→ 备注 → 「编辑日程」。
*
* 🔴 这里用**页面内 Stack 覆盖层**而不是 `bindSheet`(与家书其他浮层一致,
* 理由见 `FbMovieDetailSheet` 的文件头);
* **功能与信息结构**照 SyncCalendar 对齐。
*
* ⚠️ 详情数据是**异步拉的**(`/events/<id>` 才给 note/rrule/reminder/ends_at)
* ⇒ 三种态都要画:加载中 / 成功 / 失败。别只画成功态。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbCalEventFull } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FbDot } from './Widgets';
const FB_SHEET_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbEventSheet {
/** 日程详情(null = 还在加载) */
@Param event: FbCalEventFull | null = null;
/** 加载中 */
@Param loading: boolean = false;
/** 加载失败说明(空 = 没失败) */
@Param error: string = '';
/** 这条日程属于哪本日历(列表里那份已知,用来显示本色/名字) */
@Param bookName: string = '';
@Param bookColor: string = '#9AA0A6';
/** 能不能编辑(不能写的日历本不给"编辑"按钮) */
@Param canEdit: boolean = false;
@Event onClose: () => void = (): void => {
};
@Event onEdit: () => void = (): void => {
};
/** 时间显示:`10月15日 周三 09:00 - 10:30` / `全天 10月15日 周三` */
private timeText(): string {
const e: FbCalEventFull | null = this.event;
if (e === null) {
return '';
}
const d: string = this.cnDate(e.starts_at);
if (e.all_day) {
return '全天 ' + d;
}
const t1: string = this.hhmm(e.starts_at);
if (e.ends_at === '') {
return d + ' ' + t1;
}
// 同一天就只写一次日期
const sameDay: boolean = e.ends_at.length >= 10 && e.starts_at.length >= 10
&& e.ends_at.substring(0, 10) === e.starts_at.substring(0, 10);
const t2: string = this.hhmm(e.ends_at);
return sameDay ? (d + ' ' + t1 + ' - ' + t2) : (d + ' ' + t1 + ' → ' + this.cnDate(e.ends_at) + ' ' + t2);
}
/** `YYYY-MM-DD HH:MM:SS` → `10月15日 周三` */
private cnDate(dt: string): string {
if (dt.length < 10) {
return '';
}
const y: number = parseInt(dt.substring(0, 4), 10);
const m: number = parseInt(dt.substring(5, 7), 10);
const d: number = parseInt(dt.substring(8, 10), 10);
const w: string[] = ['日', '一', '二', '三', '四', '五', '六'];
const day: number = new Date(y, m - 1, d).getDay();
return m.toString() + '月' + d.toString() + '日 周' + w[day];
}
/** `YYYY-MM-DD HH:MM:SS` → `HH:mm` */
private hhmm(dt: string): string {
return dt.length >= 16 ? dt.substring(11, 16) : '';
}
/**
* 重复规则 → 中文。
*
* ⚠️ 只翻译**端上能生成的那几种**(见 `FbEventEditSheet.REPEAT_OPTS`);
* 认不出的(网页端配的"每两周"等)原样显示 RRULE 串 ——
* "看不懂"也好过"显示成不重复"(那是错的)。
*/
private repeatText(): string {
const e: FbCalEventFull | null = this.event;
if (e === null || e.rrule === '') {
return '';
}
const r: string = e.rrule;
if (r.indexOf('FREQ=DAILY') === 0) { return '每天'; }
if (r.indexOf('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR') === 0) { return '每个工作日'; }
if (r.indexOf('FREQ=WEEKLY') === 0) { return '每周'; }
if (r.indexOf('FREQ=MONTHLY') === 0) { return '每月'; }
if (r.indexOf('FREQ=YEARLY') === 0) { return '每年'; }
return r;
}
/** 提醒 → 中文(多个用顿号连) */
private remindText(): string {
const e: FbCalEventFull | null = this.event;
if (e === null || e.reminder_minutes === '') {
return '';
}
const out: string[] = [];
for (const x of e.reminder_minutes.split(',')) {
const n: number = parseInt(x.trim(), 10);
if (isNaN(n)) {
continue;
}
if (n === 0) { continue; }
if (n < 60) { out.push('提前 ' + n.toString() + ' 分钟'); }
else if (n === 60) { out.push('提前 1 小时'); }
else if (n === 1440) { out.push('提前 1 天'); }
else if (n % 1440 === 0) { out.push('提前 ' + (n / 1440).toString() + ' 天'); }
else if (n % 60 === 0) { out.push('提前 ' + (n / 60).toString() + ' 小时'); }
else { out.push('提前 ' + n.toString() + ' 分钟'); }
}
return out.join('、');
}
@Builder
row(label: string, value: string) {
Row({ space: 10 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(56)
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top)
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
Column({ space: 0 }) {
// ---- 标题区 ----
Row({ space: 10 }) {
FbDot({ dia: 12, fillC: this.bookColor })
Text(this.event !== null && this.event.title !== ''
? this.event.title : '(无标题)')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(2)
.layoutWeight(1)
if (this.event !== null && this.event.all_day) {
Text('全天')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
}
}
.width('100%')
.alignItems(VerticalAlign.Top)
Divider()
.color(FbColors.line)
.margin({ top: 12, bottom: 4 })
if (this.loading) {
Row({ space: 8 }) {
LoadingProgress().width(18).height(18).color(FbColors.faint)
Text('正在读取…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 20, bottom: 20 })
.justifyContent(FlexAlign.Center)
} else if (this.event === null) {
Text(this.error !== '' ? ('读不到这条日程:' + this.error) : '读不到这条日程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 20, bottom: 20 })
} else {
Scroll() {
Column({ space: 0 }) {
this.row('时间', this.timeText())
if (this.bookName !== '') {
this.row('日历本', this.bookName)
}
// ⚠️ build() 里**不能声明变量**(只有 UI 组件语法)⇒
// 条件直接调方法(方法返回空串就不显示)
if (this.repeatText() !== '') {
this.row('重复', this.repeatText())
}
if (this.remindText() !== '') {
this.row('提醒', this.remindText())
}
if (this.event.location !== '') {
this.row('地点', this.event.location)
}
if (this.event.note !== '') {
this.row('备注', this.event.note)
}
Row().height(8)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
// ---- 底部按钮 ----
Row({ space: 10 }) {
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onClose();
})
if (this.canEdit && this.event !== null) {
Text('编辑日程')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor('#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onEdit();
})
}
}
.width('100%')
.margin({ top: 14 })
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,288 @@
/**
* 家书 FamilyBook · 家谱图谱(鸿蒙端)
*
* 把亲属关系画成**知识图谱**那样的图:节点是圆形头像,边是连线。
*
* 🔴 为什么用 `Canvas` 而不是 `Circle`/`Path` 组件拼:
* · 布局是**算出来的**(按代数分层、同代均分、夫妻相邻),
* 节点数量与位置都不定 —— 组件树的写法要动态拼 Path 的 `commands`,
* 比直接算坐标画线**难得多**、也更难调;
* · 连线要**画在节点下面**(否则线会压在头像上)——
* Canvas 一次画完,天然有先后顺序。
* ⚠️ 代价:Canvas **不是响应式的**,数据变了要**自己重画**。
* 所以这里在 `onReady`(首帧)与**每次数据/尺寸变化时**都重画一遍。
*
* 布局规则(与"从上往下读家谱"的直觉一致):
* · **纵向 = 代数**:长辈在上、我居中、晚辈在下(`gen` 越小越靠上);
* · **横向 = 同代展开**:同代的人均分宽度;配偶排在彼此旁边;
* · 连线:父母→子女(带箭头感的折线)、夫妻(水平短线,更粗)。
* ⚠️ 不做"力导向"那种自动布局:家谱有**明确的层次语义**,
* 力导向会把人随机甩开,反而看不懂谁是长辈。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbKinPerson } from '../model/Types';
/** 一个节点在画布上的位置 */
interface KinNode {
p: FbKinPerson;
x: number;
y: number;
}
@ComponentV2
export struct FbKinGraphView {
/** 家谱里的人 */
@Param people: FbKinPerson[] = [];
/** 我(居中显示、加粗) */
@Param meId: number = 0;
/** 点某个节点(弹详情) */
@Event onPick: (id: number) => void = (id: number): void => {
};
/** 画布上下文(必须在组件里创建,不能跨组件共享) */
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
/** 画布尺寸(onAreaChange 拿到) */
@Local cw: number = 0;
@Local ch: number = 0;
/** 算好的节点位置(画与点击都用它) */
private nodes: KinNode[] = [];
/** 节点半径(vp) */
private readonly R: number = 22;
build() {
Canvas(this.ctx)
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
// ⚠️ `onReady` 只在**首帧**触发一次;后续尺寸变化要靠 `onAreaChange`。
// 只用 onReady 的话,切到家谱页(宽高这时才定下来)会画不出东西。
.onReady((): void => {
this.redraw();
})
.onAreaChange((_old: Area, now: Area): void => {
this.cw = Number(now.width);
this.ch = Number(now.height);
this.redraw();
})
// 🔴 `Canvas` **没有**"点到了哪个图形"的概念 —— 命中检测要自己算。
// 画的位置由 `layout()` 定,点击时拿同一份 `this.nodes` 比距离即可。
.onClick((ev: ClickEvent): void => {
const x: number = ev.x;
const y: number = ev.y;
// 命中半径放宽一点(手指比鼠标粗):用 1.35×R 更"跟手"
const hit: number = this.R * 1.35;
for (const n of this.nodes) {
const dx: number = x - n.x;
const dy: number = y - n.y;
if (dx * dx + dy * dy <= hit * hit) {
this.onPick(n.p.id);
return;
}
}
})
}
/**
* 重画整张图(**先算位置、再画线、最后画节点**)。
*
* ⚠️ 顺序很重要:线在节点下面 —— 否则线会横穿头像。
* Canvas 没有 z-index,只能靠绘制顺序。
*/
private redraw(): void {
if (this.cw <= 0 || this.ch <= 0 || this.people.length === 0) {
return;
}
this.layout();
this.drawEdges();
this.drawNodes();
}
/**
* 算节点位置(按代数分层)。
*
* ⚠️ 结果存进 `this.nodes` —— 点击命中检测要用**同一份坐标**,
* 重算一遍容易出现"看到的和点到的不一样"。
*/
private layout(): void {
// ---- ① 按代数分组 ----
const byGen: Map<number, FbKinPerson[]> = new Map<number, FbKinPerson[]>();
for (const p of this.people) {
const cur: FbKinPerson[] | undefined = byGen.get(p.gen);
if (cur !== undefined) {
cur.push(p);
} else {
byGen.set(p.gen, [p]);
}
}
const gens: number[] = [];
byGen.forEach((_v: FbKinPerson[], k: number): void => {
gens.push(k);
});
// ⚠️ 必须按**数值**排(Map 的键直接 sort 会按字符串:-1,-2,0,1)
gens.sort((a: number, b: number): number => a - b);
// ---- ② 纵向:每代一条水平带,均分高度 ----
const padTop: number = 46; // 给顶部代数标签留位置
const padBottom: number = 26;
const bandH: number = (this.ch - padTop - padBottom) / Math.max(1, gens.length);
const nodes: KinNode[] = [];
for (let gi: number = 0; gi < gens.length; gi++) {
const g: number = gens[gi];
const ps: FbKinPerson[] = byGen.get(g) !== undefined ? (byGen.get(g) as FbKinPerson[]) : [];
const y: number = padTop + bandH * gi + bandH / 2;
// ---- ③ 横向:同代均分 ----
// ⚠️ 左右各留 `R + 8`,否则第一个/最后一个节点会贴边、头像被切掉。
const padX: number = this.R + 10;
const usable: number = this.cw - padX * 2;
const step: number = ps.length > 1 ? usable / (ps.length - 1) : 0;
// ⚠️ 只有一个人时放**正中间**(step=0 会让它贴在左边)
const startX: number = ps.length > 1 ? padX : this.cw / 2;
for (let i: number = 0; i < ps.length; i++) {
nodes.push({ p: ps[i], x: startX + step * i, y: y });
}
}
this.nodes = nodes;
}
/** 按 id 找节点 */
private nodeOf(id: number): KinNode | undefined {
for (const n of this.nodes) {
if (n.p.id === id) {
return n;
}
}
return undefined;
}
/** 画连线 */
private drawEdges(): void {
const c: CanvasRenderingContext2D = this.ctx;
c.lineWidth = 1.6;
c.strokeStyle = '#B9A896';
for (const n of this.nodes) {
// ---- 父母 → 子女(折线:下-横-下)----
const parents: number[] = n.p.parents;
for (const pid of parents) {
const par: KinNode | undefined = this.nodeOf(pid);
if (par === undefined) {
continue;
}
// 折线中点取两者的横向中点,纵向上 1/3 处拐
const midY: number = par.y + (n.y - par.y) * 0.55;
c.beginPath();
c.moveTo(par.x, par.y + this.R);
c.lineTo(par.x, midY);
c.lineTo(n.x, midY);
c.lineTo(n.x, n.y - this.R);
c.stroke();
}
// ---- 夫妻(水平短线,**画粗一点**,与血缘线区分)----
for (const sid of n.p.spouses) {
// 只画一次(按 id 大小去重,否则同一对会画两条)
if (sid < n.p.id) {
continue;
}
const sp: KinNode | undefined = this.nodeOf(sid);
if (sp === undefined) {
continue;
}
c.save();
c.lineWidth = 3;
c.strokeStyle = '#C97B6A';
c.beginPath();
c.moveTo(n.x + this.R, n.y);
c.lineTo(sp.x - this.R, sp.y);
c.stroke();
c.restore();
}
}
}
/** 画节点(圆 + 首字 + 称谓) */
private drawNodes(): void {
const c: CanvasRenderingContext2D = this.ctx;
for (const n of this.nodes) {
const isMe: boolean = n.p.id === this.meId;
// 圆底(用头像色)
c.beginPath();
c.arc(n.x, n.y, this.R, 0, Math.PI * 2);
c.fillStyle = n.p.avatarColor !== '' ? n.p.avatarColor : '#8A6B4B';
c.fill();
// 「我」加一圈描边,一眼认出中心
if (isMe) {
c.lineWidth = 2.5;
c.strokeStyle = '#A84A28';
c.stroke();
}
// 名字首字
c.font = '16px sans-serif';
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillStyle = '#FFFFFF';
const nm: string = n.p.shown !== '' ? n.p.shown : n.p.name;
c.fillText(nm.length > 0 ? nm.substring(0, 1) : '?', n.x, n.y);
// 名字(圆下方)
c.font = isMe ? 'bold 12px sans-serif' : '12px sans-serif';
c.fillStyle = isMe ? '#A84A28' : '#2C231D';
// ⚠️ 名字可能很长("青岛西海岸电影城"那种)—— 截断,别糊成一片
const label: string = nm.length > 5 ? nm.substring(0, 5) + '…' : nm;
c.fillText(label, n.x, n.y + this.R + 11);
// 称谓(名字下面,小字灰色)
if (n.p.label !== '' && n.p.label !== '我') {
c.font = '10px sans-serif';
c.fillStyle = '#8A7A6A';
c.fillText(n.p.label, n.x, n.y + this.R + 24);
}
// ---- 代数标签(每层左边)----
// ⚠️ 只在每层**第一个**节点旁写,避免重复画
const first: KinNode | undefined = this.firstOfGen(n.p.gen);
if (first !== undefined && first.p.id === n.p.id) {
c.font = '11px sans-serif';
c.textAlign = 'left';
c.fillStyle = '#8A7A6A';
c.fillText(this.genLabel(n.p.gen), 8, n.y - this.R - 8);
c.textAlign = 'center';
}
}
}
/** 某代里排在最前的那个节点(用来放代数标签) */
private firstOfGen(gen: number): KinNode | undefined {
for (const n of this.nodes) {
if (n.p.gen === gen) {
return n;
}
}
return undefined;
}
/** 代数 → 层名(与家谱页的分层标题同口径) */
private genLabel(g: number): string {
if (g === 0) {
return '我这一辈';
}
if (g === -1) {
return '父母辈';
}
if (g <= -2) {
return '祖辈';
}
if (g === 1) {
return '子女辈';
}
return '孙辈';
}
}
@@ -0,0 +1,150 @@
/**
* 家书 FamilyBook · 模块设置浮层(鸿蒙端)
*
* 🔴 用户 2026-10-16:「**所有页面的设置按钮全部都改成和网页版一致**,
* 就是**三个横线**那种设置按钮。」
*
* 网页版的做法(`templates/` 下各模块的 `_settings_menu.php`):
* 每个模块的动作区最右有一个 `<details class="movie-set menu">`,
* `summary` 里就是**三条横线**的 SVG
* (`M2.5 4.5h11M2.5 8h11M2.5 11.5h11`),点开是一个菜单列表,
* 每条**图标 + 文字**。
*
* 🔴 本组件就是那个"点开后的菜单"(鸿蒙端做成底部浮层,与家书其它浮层同一形态)。
* ⚠️ **通用**:各模块只是条目不同,所以做成一个组件 + 传 `items`,
* 而不是给 8 个模块各写一个浮层(那种写法必然漂移)。
*
* ⚠️ 条目点了"还没做"的怎么办:**照实显示为不可用**(灰掉 + "暂未开放"),
* 而不是让点击没反应 —— 后者用户会以为卡了。
*/
import { FbColors, FbMetric, FbIcon } from '../common/Theme';
/** 贴底卡片的手势区缓冲(vp)—— 同其它浮层 */
const FB_SHEET_BOTTOM_GAP: number = 16;
/** 设置菜单里的一条 */
export interface FbSettingItem {
/** 图标名(`FbIcon.ui()` 的 key,如 'chart' / 'gear' / 'filter') */
icon: string;
/** 文字 */
label: string;
/** 一句话说明(可为空) */
hint: string;
/**
* 点了做什么。传空串 = **这项还没做**(画成灰色 + "暂未开放",点了不响应)。
* ⚠️ 用 string 而不是回调:@Param 传函数在 ArkTS 里会被"不可序列化"折腾,
* 这里只需要一个"标识",由父组件的 @Event 统一分发。
*/
action: string;
}
@ComponentV2
export struct FbModuleSettingsSheet {
/** 标题(如「观影设置」) */
@Param title: string = '设置';
/** 菜单条目 */
@Param items: FbSettingItem[] = [];
/** 关闭 */
@Event onClose: () => void = (): void => {
};
/** 点了某条(`action` 是条目的标识) */
@Event onPick: (action: string) => void = (a: string): void => {
};
build() {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row() {
Text(this.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
Text('完成')
.fontSize(FbMetric.fontSmall)
.fontColor('#4A7C82')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
ForEach(this.items, (it: FbSettingItem) => {
this.settingRow(it)
}, (it: FbSettingItem): string => 'st_' + it.action + '_' + it.label)
}
.width('100%')
.padding({ top: 10 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
.width('100%')
.constraintSize({ maxHeight: '70%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.alignItems(HorizontalAlign.Start)
}
/**
* 一条菜单项:**图标 + 文字**(照网页版:`每条文字前都要有图标`)。
*
* ⚠️ 未实现的条目(`action === ''`)画灰、且**不响应点击** ——
* "点了没反应"比"明确告诉你还没做"更糟(用户会以为卡死)。
*/
@Builder
settingRow(it: FbSettingItem) {
Row({ space: 12 }) {
Image(FbIcon.ui(it.icon))
.width(17)
.height(17)
.renderMode(ImageRenderMode.Template)
.fillColor(it.action === '' ? FbColors.faint : FbColors.muted)
Column({ space: 1 }) {
Text(it.label)
.fontSize(FbMetric.fontBody)
.fontColor(it.action === '' ? FbColors.faint : FbColors.ink)
if (it.hint !== '') {
Text(it.hint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (it.action === '') {
Text('暂未开放')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
} else {
Text('›')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.faint)
}
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.borderRadius(FbMetric.radiusS)
.margin({ bottom: 6 })
.backgroundColor(it.action === '' ? Color.Transparent : FbColors.inputBg)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
// ⚠️ 未实现的项**点了不响应**(不是"关闭浮层")——
// 否则用户以为"点了进去但没东西"
if (it.action !== '') {
this.onPick(it.action);
}
})
}
}
@@ -0,0 +1,506 @@
/**
* 家书 FamilyBook · 观影详情浮层(鸿蒙端)
*
* 点观影卡片后弹出,展示:海报大图、片名、时间、影院(含地址)、
* 剧情简介、评分、同看的人、照片。
*
* 🔴 为什么用**页面内浮层(Stack 覆盖)**而不是系统弹窗:
* ① 系统弹窗(`AlertDialog` / `CustomDialog`)受"多弹窗堆叠""后台不许弹"
* 等一堆约束,还要处理生命周期;
* ② 浮层就是一个普通组件,**状态完全由父页面掌控**(`@Param` 一个
* `visible` 就行),要加动画/手势/局部刷新都自由。
* 本项目 Web 端 v43 也是这个思路(`?embed=1` 浮层而不是整页跳转)。
*
* ⚠️ 详情数据是**异步拉的**(网络),所以这个组件要处理三种态:
* 加载中 / 成功 / 失败。别只画成功态 —— 断网时用户会看到"空白浮层"。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo';
import { fbImageUrl } from '../data/FbHttp';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbAvatar, FbRating } from './Widgets';
/**
* 照片缩略图的宽高比。
*
* 🔴 **实测值**:线上照片是 **270×377**(`270/377 ≈ 0.7162`)。
* 别改成 1(正方形)—— 竖版照片会被裁掉上下大半,用户一眼就看出不对。
* ⚠️ 服务端目前上传的图都是这个尺寸(4 张实测一致),
* 若将来允许不同比例,应改成"每张读自己的尺寸"而不是统一比例。
*/
const FB_PHOTO_RATIO: number = 270 / 377;
@ComponentV2
export struct FbMovieDetailSheet {
/** 要展示的详情(null = 还在加载) */
@Param detail: FbMovieDetail | null = null;
/** 加载中标志(与 detail 配合:加载中时 detail 也可能是 null) */
@Param loading: boolean = false;
/** 加载失败信息(空串 = 没失败) */
@Param error: string = '';
/** 列表项带过来的评分(详情接口不给,见 FbMovieRepo.detail 的说明) */
@Param listRating: number = 0;
@Param listReviewCount: number = 0;
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
// ---- 全屏大图查看器 ----
/** 查看器是否打开 */
@Local viewerOpen: boolean = false;
/** 查看器当前显示第几张 */
@Local viewerIndex: number = 0;
/** 查看器里的图片列表(打开时快照,避免与详情刷新打架) */
@Local viewerList: string[] = [];
/**
* 详情里所有可看的图:**票根在前 + 现场照片**。
*
* ⚠️ 与 Web 端口径一致(`templates/movies/show.php` 把票根和照片
* "合成一块展示、同一套灯箱",注释里写明了不再分类)。
* 端上也别分类 —— 用户关心的是"这次观影的照片",不是它的来源。
*/
private allPhotos(d: FbMovieDetail): string[] {
const out: string[] = [];
if (d.ticketKey !== '') {
out.push(d.ticketKey);
}
for (const k of d.photos) {
out.push(k);
}
return out;
}
/** 打开全屏查看器(点缩略图时) */
private openViewer(key: string): void {
const list: string[] = this.allPhotos(this.detail !== null
? this.detail : this.emptyForViewer());
const idx: number = list.indexOf(key);
if (idx < 0) {
return;
}
this.viewerList = list;
this.viewerIndex = idx;
this.viewerOpen = true;
}
/** 查看器用的空详情(列表为空时 openViewer 不会走到,仅类型需要) */
private emptyForViewer(): FbMovieDetail {
return {
ok: false, error: '', id: 0, title: '', startsAt: '', endsAt: '', note: '',
info: '', posterKey: '', ticketKey: '', cinemaName: '', cinemaPlace: '',
cinemaAddress: '', cinemaColor: '', myRating: 0, myReview: '',
reviewCount: 0, reviewAvg: 0, attendees: [], photos: []
};
}
/** 上一张 / 下一张(**循环**:到末张再往后回到第一张) */
private viewerStep(delta: number): void {
const n: number = this.viewerList.length;
if (n === 0) {
return;
}
// ⚠️ 必须取模归一到 [0,n):直接加会让 index 越界 / 变成负数
this.viewerIndex = ((this.viewerIndex + delta) % n + n) % n;
}
/**
* 时间显示:'YYYY-MM-DD HH:mm'(去掉秒)。
* ⚠️ 之前这里写错过一次(把小时当分钟取),时用 substring(11,13)。
*/
private shortTime(v: string): string {
if (v.length < 16) {
return v;
}
return v.substring(0, 16);
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
// 🔴 用 Stack 把"浮层内容"和"全屏查看器"叠起来:
// 查看器要盖住整个浮层(含它自己的圆角/把手),所以必须是
// **同一个 Stack 里的后声明节点**,不能放进浮层内部的 Column
// (那会被浮层的圆角裁剪、也会被内层 Scroll 的滚动带走)。
Stack({ alignContent: Alignment.Center }) {
Column({ space: 0 }) {
this.sheetHeader()
this.sheetBody()
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
// ⚠️ 同样用 `Default`(不写)。`Block` 会挡住浮层内部的子节点
// (照片缩略图、关闭按钮都点不动)。
// `Default` 已足够:浮层在遮罩之后声明 ⇒ 是兄弟节点、会被优先命中。
// ---- 全屏大图查看器 ----
if (this.viewerOpen) {
this.viewer()
}
}
.width('100%')
// ⚠️ 高度随内容自适应(浮层是"底部抽屉"形态)。
// 🔴 但**查看器打开时必须撑满**:查看器要盖住整屏,
// 如果这里还是内容高度,查看器会被裁在一小块里,
// 而且**它没盖住的地方点击会穿透到外层的遮罩** ——
// 表现就是"点查看器的 ✕,结果把详情浮层也一起关了"
// ( 2026-10-12 实测到的 bug)。
.height(this.viewerOpen ? '100%' : undefined)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
/** 浮层顶部:把手 + 关闭 */
@Builder
sheetHeader() {
Row() {
// 把手(视觉提示"可以关掉")
Column()
.width(36)
.height(4)
.backgroundColor(FbColors.line2)
.borderRadius(2)
Blank()
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 8, bottom: 4 })
}
/** 浮层主体(三种态) */
@Builder
sheetBody() {
Column({ space: 0 }) {
if (this.error !== '') {
// 失败:明确告诉用户,别留白
Column({ space: 8 }) {
Text('详情没能加载出来')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text(this.error)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(3)
.textAlign(TextAlign.Center)
}
.width('100%')
.padding({ top: 40, bottom: 40 })
.justifyContent(FlexAlign.Center)
} else if (this.loading || this.detail === null) {
Row() {
Text('正在加载详情…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 40, bottom: 40 })
.justifyContent(FlexAlign.Center)
} else {
this.body(this.detail)
}
}
.width('100%')
}
/**
* 全屏大图查看器。
*
* 交互(对齐 Web 端灯箱):
* · 点背景关闭;
* · 左右两个按钮翻页(**循环**);
* · 底部显示"第 n / N 张"。
*
* ⚠️ 图片用 `ImageFit.Contain` 而不是 `Cover`:
* 查看器的目的是"看清整张图",裁掉边缘就失去意义了。
* (缩略图用 Cover 保持整齐,大图用 Contain 保证完整 —— 两者目的不同。)
*/
@Builder
viewer() {
// 🔴 必须用一个容器把查看器的所有零件**包在一层里**。
// `@Builder` 里平铺多个节点时,它们会被依次挂到**父容器**(浮层的
// Stack)上,各自独立参与布局 —— 结果是遮罩/图/控制条/关闭按钮
// 全都变成浮层 Stack 的兄弟节点,位置靠 position/margin 硬凑,
// 点击区域互相错位(2026-10-12 实测:点 ✕ 会连带关掉详情浮层)。
// ⇒ 外层一个 Stack 包住,内部用 alignContent 定位,才是正确写法。
Stack({ alignContent: Alignment.Center }) {
// ① 全屏背景(点它关闭)
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000E6')
.onClick((): void => {
this.viewerOpen = false;
})
// ② 大图
// ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图",
// 裁掉边缘就失去意义(缩略图才用 Cover)。
Image(fbImageUrl(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
.width('100%')
.height('76%')
.objectFit(ImageFit.Contain)
// ③ 底部控制条(靠下对齐)
Row({ space: 18 }) {
Text('‹ 上一张')
.fontSize(FbMetric.fontSmall)
.fontColor('#FFFFFFCC')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.viewerStep(-1);
})
Text((this.viewerIndex + 1).toString() + ' / ' + this.viewerList.length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFF99')
Text('下一张 ›')
.fontSize(FbMetric.fontSmall)
.fontColor('#FFFFFFCC')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.viewerStep(1);
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
// ⚠️ 用 `align` 把它压到 Stack 底部,而不是靠 margin 推
.align(Alignment.Bottom)
.margin({ bottom: 40 })
// ④ 右上角关闭(比"点背景"更明确)
// ⚠️ 必须留在 Stack 里当子节点(不能平铺出去)——
// 平铺会跑到浮层 Stack 上,position 相对的是**浮层**而不是查看器。
Column({ space: 2 }) {
Text('✕')
.fontSize(FbMetric.fontTitle)
.fontColor('#FFFFFFCC')
Text('关闭')
.fontSize(9)
.fontColor('#FFFFFF88')
}
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.align(Alignment.TopEnd)
.onClick((): void => {
this.viewerOpen = false;
})
}
.width('100%')
.height('100%')
// 🔴 事件拦截取 **`Default`(默认值,不写就行)**,**不能用 `Block`**。
// 🔴 SDK 原文(`component/enums.d.ts`)说得很清楚:
// · `Default` —— 自身和**子节点**响应,阻挡**兄弟节点**,不影响祖先;
// · `Block` —— 自身响应,**阻挡子节点**、兄弟、祖先;
// · `Transparent` —— 自身和子节点响应,**不阻挡**兄弟和祖先。
// 需求是"查看器挡住下层遮罩(兄弟),但里面的翻页/关闭按钮要能点(子节点)"
// ⇒ 正是 `Default`。
// ⚠️ 我一开始用了 `Block`,结果**子节点(缩略图/翻页按钮)全部点不动**
// —— 2026-10-12 实测:点缩略图毫无反应,查 SDK 注释才发现 Block 会挡子节点。
//
// ⚠️ 这里**不额外加 onClick**:加了会让"点查看器任意处"都关掉查看器,
// 而遮罩层(子节点之一)已经负责"点空白关闭",两者会打架。
// 靠 `Default` + 遮罩子节点即可:遮罩铺满、点它关闭;
// 翻页/关闭按钮在遮罩**之后**声明,层级更高、优先响应。
}
/** 成功态的主体 */
@Builder
body(d: FbMovieDetail) {
Scroll() {
Column({ space: 10 }) {
// ---- 海报(大图,2:3)----
if (d.posterKey !== '') {
Image(fbImageUrl(d.posterKey, FB_TOKEN_STORE.get()))
.width(140)
.aspectRatio(0.75)
.objectFit(ImageFit.Cover)
.borderRadius(FbMetric.radiusS)
.alt($r('app.media.fb_ic_movies'))
}
// ---- 片名 ----
Text(d.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.textAlign(TextAlign.Center)
// ---- 时间 ----
Text(this.shortTime(d.startsAt) +
(d.endsAt !== '' ? ' — ' + this.shortTime(d.endsAt).substring(11) : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
// ---- 评分(来自列表项,见 FbMovieRepo.detail 说明)----
if (this.listReviewCount > 0) {
Row({ space: 6 }) {
FbRating({ score: this.listRating, max: 10 })
Text(this.listRating.toFixed(1).toString() + ' 分 · ' +
this.listReviewCount.toString() + ' 条评价')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(VerticalAlign.Center)
}
// ---- 影院 ----
if (d.cinemaName !== '') {
Column({ space: 4 }) {
Row({ space: 6 }) {
Column()
.width(6)
.height(6)
.backgroundColor(d.cinemaColor !== '' ? d.cinemaColor : FbColors.muted)
.borderRadius(3)
Text(d.cinemaName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
}
.alignItems(VerticalAlign.Center)
if (d.cinemaAddress !== '') {
Text(d.cinemaAddress)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
// ---- 同看的人 ----
if (d.attendees.length > 0) {
Column({ space: 6 }) {
Text('同看的人')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 10 }) {
ForEach(d.attendees, (a: FbMovieAttendeeRow) => {
Column({ space: 3 }) {
FbAvatar({
name: a.myNickname !== '' ? a.myNickname : a.name,
// ⚠️ 用接口给的头像色(`avatar_color`);没有才退回灰。
// 别写死一个灰 —— 那样所有人的头像都是同一个颜色。
color: a.avatarColor !== undefined && a.avatarColor !== ''
? a.avatarColor : '#A84A28',
dia: 34
})
Text(a.myNickname !== '' ? a.myNickname : a.name)
.fontSize(10)
.fontColor(FbColors.muted)
.maxLines(1)
}
}, (a: FbMovieAttendeeRow): string => 'att_' + a.id.toString())
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
// ---- 剧情简介 ----
if (d.info !== '') {
Column({ space: 6 }) {
Text('简介')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(d.info)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
// ⚠️ 不设 maxLines:简介本来就长,让用户滚着看全文
.textAlign(TextAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
// ---- 备注 ----
if (d.note !== '') {
Column({ space: 6 }) {
Text('备注')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(d.note)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
// ---- 照片(票根 + 现场照片合成一块,与 Web 端一致)----
if (this.allPhotos(d).length > 0) {
Column({ space: 6 }) {
Row({ space: 6 }) {
Text('观影照片')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(this.allPhotos(d).length.toString() + ' 张')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(VerticalAlign.Center)
Grid() {
ForEach(this.allPhotos(d), (k: string) => {
GridItem() {
// 🔴 缩略图必须按**照片真实比例**做格子。
// 实测线上照片是 **270×377(竖版,比例 ≈0.716)**,
// 而第一版用的是**正方形格子**(GridItem 没有固定高度,
// 由 Grid 行高决定)⇒ 竖版照片被 `Cover` 裁掉上下大半,
// "看起来格式不对"。用户 2026-10-12 反馈的就是这个。
Column() {
Image(fbImageUrl(k, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
}
.width('100%')
// ⚠️ 用 `aspectRatio` 指定比例,而不是靠父容器推:
// 在 Grid 里 `height('100%')` 拿不到确定的高度
// (行高是内容决定的),必须显式给比例。
.aspectRatio(FB_PHOTO_RATIO)
.clip(true)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.openViewer(k);
})
}
}, (k: string): string => 'ph_' + k)
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(6)
.rowsGap(6)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 4, bottom: 28 })
.alignItems(HorizontalAlign.Center)
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,481 @@
/**
* 家书 FamilyBook · 人员详情浮层(鸿蒙端)
*
* 点名册里的一个人 → 弹出他的完整资料(头像 / 姓名 / 称谓 / 单位职务 /
* 手机 / 生日(阳历+农历)/ 邮箱 / 地址 / 座右铭 / 标签 / 备注)。
*
* 🔴 为什么做成"浮层"而不是新页面:
* 与观影的详情浮层同一个理由(见 `FbMovieDetailSheet` 的文件头):
* · 用户在名册里"随手点开看看",看完就关 —— 跳页面会丢失列表的滚动位置,
* 回来还得重新找;
* · 浮层是**页面内的覆盖层**,不动导航栈,关掉即回到原处。
* ⚠️ 但它**不是**系统弹窗(`bindSheet`/`AlertDialog`)——
* 那些会带系统边框与动画,且塞不进我们要的自定义布局。
*
* 数据来源(🔴 v52 重写,用户 2026-10-15):
* 用户要求「**完全脱离电脑端**,所有信息都显示出来」⇒ 详情不再"用列表那份凑合":
* · 列表(`/api/v1/roster`)只给画一栏要用的字段;
* · 详情**单独拉 `GET /api/v1/people/<id>`** —— 它多带
* **工作经历 / 补充联系方式(按类型)/ 私人备注 / 结构化地址五级 /
* 身份证(打码) / 头像 URL / 能不能编辑**。
* ⚠️ 旧注释里写"`/people/<id>` 字段并不比 roster 多" —— 那是当时的事实,
* v52 起**已经不成立**(服务端这次把详情接口补全了)。
* ⚠️ 断开网络时退回"用列表里那份"(`person` 本来就是列表那份),
* 只是缺那几项详情专属字段,界面不会空。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbRosterPerson, FbJobRow, FbContactRow } from '../data/FbPersonRepo';
import { FbAvatar, FbTag } from './Widgets';
import { fbImageUrl } from '../data/FbHttp';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
/**
* 贴底卡片的手势区缓冲(vp)。
*
* 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)——
* 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段,
* 否则最后一排按钮会被"从下往上滑"误触。
*/
const FB_SHEET_BOTTOM_GAP: number = 16;
@ComponentV2
export struct FbPersonSheet {
/** 要看的人(null = 没打开) */
@Param person: FbRosterPerson | null = null;
/** 关闭回调 */
@Event onClose: () => void = (): void => {
};
/**
* 「编辑」回调(用户要求:悬浮卡片上要有一个"编辑")。
*
* ⚠️ 这里**只发事件、不自己做编辑** —— 编辑是另一个浮层(`FbPersonEditSheet`),
* 由页面决定"关掉详情、打开编辑"。浮层之间不互相引用,
* 免得出现"详情嵌编辑、编辑又嵌详情"的循环。
*/
@Event onEdit: () => void = (): void => {
};
/** 一行的图标资源 */
private iconOf(name: string): Resource {
switch (name) {
case 'phone': return $r('app.media.fb_ic_user');
case 'org': return $r('app.media.fb_ic_people');
case 'birth': return $r('app.media.fb_ic_days');
case 'mail': return $r('app.media.fb_ic_notes');
case 'addr': return $r('app.media.fb_ic_resources');
case 'card': return $r('app.media.fb_ic_notes');
default: return $r('app.media.fb_ic_dot');
}
}
/**
* 生日合并成一行:`阳历 11-16(农历 十月初五)`。
*
* ⚠️ 阳历/农历**分开两个字段**,但并成一行更好读(两行都只有月日、很短)。
* 只有农历没阳历时也能显示。
*/
private birthdayLine(): string {
if (this.person === null) {
return '';
}
const solar: string = this.person.birthdaySolar;
const lunar: string = this.person.birthdayLunar;
if (solar !== '' && lunar !== '') {
return solar + '(农历 ' + lunar + ')';
}
if (solar !== '') {
return solar;
}
if (lunar !== '') {
return '农历 ' + lunar;
}
return '';
}
/**
* 多行文本块(标题 + 正文)。
*
* ⚠️ 为什么不用 `infoRow`:工作经历/地址/备注这类**多行等宽信息**
* 配一个小图标反而挤,标题一行、正文一段更清楚。
*/
@Builder
blockText(label: string, body: string) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(body)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.lineHeight(21)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 12 })
}
/** 工作经历:每段一行(单位|职务 · 职称(起止)) */
@Builder
jobsBlock() {
Column({ space: 6 }) {
Text('工作经历')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
if (this.person !== null) {
ForEach(this.person.jobs, (j: FbJobRow, idx: number) => {
Column({ space: 2 }) {
Text(this.jobText(j))
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
if (j.note !== '') {
Text(j.note)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}, (j: FbJobRow, idx: number): string => 'job_' + idx.toString())
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 12 })
}
/**
* 除主号之外的其它电话(逗号拼接)。
*
* ⚠️ `phones[0]` 是主号,已经在「手机号」那一行单独显示过 ⇒ 这里从下标 1 开始,
* 否则同一个号码会显示两遍。
* ⚠️ 服务端已保证"主号在前、去重",这里不再做去重(保持简单)。
*/
private otherPhones(): string {
if (this.person === null) {
return '';
}
const out: string[] = [];
for (let i = 1; i < this.person.phones.length; i++) {
out.push(this.person.phones[i]);
}
return out.join('、');
}
/**
* 头像图 URL(**空 = 用色块字回落**)。
*
* 🔴🔴 v52 修的真 bug(用户 2026-10-15:「点击详情,弹出的卡片里面**为什么没有头像**呢?」):
* 列表里画了一整天的头像,**详情浮层却没传 `imageUrl`** ——
* `FbAvatar` 的 `imageUrl` 缺省是空串,于是**所有人在详情里都退化成色块字**
* (有头像的人也显示不出来)。这是纯粹的漏传参数,不是数据问题。
* ⚠️ `avatar_url`(服务端下发的那个)是**相对路径**,`Image()` 直接用会
* 打到 App 自己的包名上 ⇒ 必须用 `fbImageUrl(key, token)` 拼成
* 带令牌的绝对地址(与列表同一套,见 `fbImageUrl` 的说明)。
*/
private avatarUrl(): string {
if (this.person === null) {
return '';
}
return fbImageUrl(this.person.avatar, FB_TOKEN_STORE.get());
}
/** 拼多行联系方式的显示串(`label: value`,label 空就只显示 value) */
private contactText(rows: FbContactRow[]): string {
const out: string[] = [];
for (const r of rows) {
out.push(r.label !== '' ? (r.label + ':' + r.value) : r.value);
}
return out.join('\n');
}
/** 地址一整行(国家 省 市 区 + 详细;空段自动跳过) */
private fullAddress(): string {
if (this.person === null) {
return '';
}
const parts: string[] = [
this.person.addrCountry, this.person.addrProvince,
this.person.addrCity, this.person.addrDistrict
];
const out: string[] = [];
for (const s of parts) {
if (s !== '') {
out.push(s);
}
}
if (this.person.address !== '') {
out.push(this.person.address);
}
return out.join(' ');
}
/** 一段工作经历的显示串(`单位 · 职务/职称(起止)`) */
private jobText(j: FbJobRow): string {
const head: string[] = [];
if (j.org !== '') {
head.push(j.org);
}
// 职务与职称可能都有("副院长 / 副教授"),都可能没有
const roles: string[] = [];
if (j.title !== '') {
roles.push(j.title);
}
if (j.rankName !== '' && j.rankName !== j.title) {
roles.push(j.rankName);
}
if (roles.length > 0) {
head.push(roles.join(' · '));
}
let line: string = head.join('|');
const span: string = this.jobSpan(j);
if (span !== '') {
line += '(' + span + ')';
}
return line;
}
/** 一段工作经历的起止(`2020-09 至今` / `2015-07 ~ 2020-08`) */
private jobSpan(j: FbJobRow): string {
const start: string = j.startYm;
const end: string = j.endYm !== '' ? j.endYm : '至今';
if (start === '' && j.endYm === '') {
return '';
}
if (start === '') {
return end;
}
return start + ' ~ ' + end;
}
/**
* 信息行(图标 + 标签 + 值)。
*
* ⚠️ `@Builder` 里**不加返回值**、参数要显式类型 —— 见项目里 ArkTS 约束。
*/
@Builder
infoRow(icon: string, label: string, value: string) {
Row({ space: 10 }) {
Image(this.iconOf(icon))
.width(15)
.height(15)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Column({ space: 2 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Top)
}
build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。
Column() {
Column({ space: 0 }) {
// ---- 顶部:头像 + 姓名 + 称谓 ----
// ⚠️ 这一块**不参与滚动**(它跟着卡片固定在顶部):
// 用户往下滑看资料时,"这是谁"始终可见,不用滚回去确认。
Row({ space: 14 }) {
FbAvatar({
name: this.person !== null ? this.person.shown : '',
color: this.person !== null ? this.person.avatarColor : '#A84A28',
dia: 60,
fontSizeVp: 24,
// 🔴 v52 补上(之前漏传 ⇒ 详情里永远看不到头像,见 avatarUrl 的说明)
imageUrl: this.avatarUrl()
})
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(this.person !== null ? this.person.shown : '')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
if (this.person !== null && this.person.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
}
.alignItems(VerticalAlign.Center)
// 本名(昵称与不同名时才有意义)
if (this.person !== null && this.person.name !== this.person.shown) {
Text('本名 ' + this.person.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
// 称谓(服务端按家族图谱推导)
if (this.person !== null && this.person.label !== '') {
FbTag({ text: this.person.label, tone: 'muted' })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 标签 ----
// ⚠️ 标签行**留在头部(不滚动)**:它很短,而且"这人是哪类人"是扫一眼就要的
// 信息,跟着资料一起滚走反而不方便。详情里的"长内容"是资料行(见下面)。
if (this.person !== null && this.person.tags.length > 0) {
Row({ space: 6 }) {
ForEach(this.person.tags, (t: string) => {
FbTag({ text: t, tone: 'blue' })
}, (t: string): string => 'pt_' + t)
}
.width('100%')
.margin({ top: 12 })
}
Divider()
.color(FbColors.line)
.margin({ top: 14, bottom: 4 })
// 🔴 **资料区可滚动**(用户 2026-10-15:「显示详细信息,并且可以往下滑」)。
//
// 为什么必须给它一个 `Scroll`:
// 卡片整体限了 `maxHeight: 78%`,而资料行(手机/单位/职务/生日阴阳历/
// 邮箱/地址/座右铭/备注)加起来很容易超过这个高度 ——
// 没有 `Scroll` 时**超出的部分直接被裁掉、看不到也够不着**。
// ⚠️ 用 `layoutWeight(1)` 让 Scroll 吃掉"头部与底部按钮之间的剩余高度",
// 而不是给固定高度 —— 内容长短不同时都能正好铺满。
// ⚠️ 底部按钮**留在 Scroll 外面**:它们是"动作",不该跟着内容滚走
// (要滑到底才能点"编辑"是很糟的体验)。
Scroll() {
Column({ space: 0 }) {
// ---- 资料 ----
// ⚠️ 每条都**只在有值时显示** —— 空行会让浮层显得"很多没填",
// 而实际上大部分人的手机/邮箱/地址本来就是空的。
if (this.person !== null && this.person.phone !== '') {
this.infoRow('phone', '手机号', this.person.phone)
}
// 🔴 补充电话(服务端下发的 `phones`,主号已在上面单独显示)
if (this.person !== null && this.person.phones.length > 1) {
this.infoRow('phone', '其他电话', this.otherPhones())
}
if (this.person !== null && this.person.org !== '') {
this.infoRow('org', '单位', this.person.org)
}
if (this.person !== null && this.person.title !== '') {
this.infoRow('org', '职务', this.person.title)
}
// 家中排行(只在有值时显示;1 = 老大)
if (this.person !== null && this.person.birthRank !== '') {
this.infoRow('org', '家中排行', '第 ' + this.person.birthRank + ' 个')
}
// 生日:阳历 + 农历(农历是服务端算的)—— 合并成一行显示更好读
if (this.person !== null && this.birthdayLine() !== '') {
this.infoRow('birth', '生日', this.birthdayLine())
}
if (this.person !== null && this.person.email !== '') {
this.infoRow('mail', '邮箱', this.person.email)
}
// 地址(结构化五级 + 详细;旧数据只有 `addrCity` 也能显示)
if (this.person !== null && this.fullAddress() !== '') {
this.infoRow('addr', '地址', this.fullAddress())
}
// 身份证(打码)—— 只显示,编辑时可改
if (this.person !== null && this.person.idCardMasked !== '') {
this.infoRow('card', '身份证', this.person.idCardMasked)
}
// ---- 补充联系方式(vCard 对齐:邮箱/地址/网址,可多行)----
if (this.person !== null && this.person.contacts.email.length > 0) {
this.blockText('其它邮箱', this.contactText(this.person.contacts.email))
}
if (this.person !== null && this.person.contacts.address.length > 0) {
this.blockText('其它地址', this.contactText(this.person.contacts.address))
}
if (this.person !== null && this.person.contacts.url.length > 0) {
this.blockText('网址', this.contactText(this.person.contacts.url))
}
// ---- 工作经历(可多段)----
if (this.person !== null && this.person.jobs.length > 0) {
this.jobsBlock()
}
// ---- 座右铭 / 公共备注 / 我的私人备注 ----
if (this.person !== null && this.person.slogan !== '') {
this.blockText('座右铭', '「' + this.person.slogan + '」')
}
if (this.person !== null && this.person.note !== '') {
this.blockText('备注', this.person.note)
}
// 🔴 「我的私人备注」与公共"备注"**是两块**(只有我看得到)
if (this.person !== null && this.person.myNote !== '') {
this.blockText('我的备注(仅自己可见)', this.person.myNote)
}
// 底部一点余量:最后一行不贴着按钮
Row().height(8)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// ---- 底部按钮:编辑 / 关闭 ----
Row({ space: 10 }) {
Text('编辑')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor('#4A7C82')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onEdit();
})
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(40)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.margin({ top: 18 })
}
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边(92%)、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('92%')
// 最多占屏幕 72%:上方留出可见列表,暗示"这是浮层、下面还有内容"
.constraintSize({ maxHeight: '72%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.Transparent)
}
}
@@ -0,0 +1,58 @@
/**
* 家书 FamilyBook · 概述卡里的「一格数字」
*
* 🔴🔴 v54b(用户 2026-10-17)—— 为什么必须是一个**组件**而不是 `@Builder`:
*
* 用户原话:「概述卡高度正常了,但是里面的**日程、在做、待办的数量是错误的**。」
*
* 真机实测(`uitest dumpLayout` + 直读端上缓存):
* · 接口返回 `stat:{today_events:1, doing:3, todo:2, overdue:0}` —— **正确**;
* · 端上 `module_cache` 里存的那份 payload **也是这份正确数据**;
* · 可界面显示的是 `0 / 0 / 0`。
* 而**同一份数据**里「今天的日程 1 条」显示是对的 —— 区别在于
* 那一块走的是**组件**(`FbDayEventPanel`),而数字走的是
* `FbStatTriple` 内部的 `@Builder cell(label, value, alarm)`。
*
* 🔴 根因:SDK 规则 `r/quick-rules/05-state.md` → 「@Builder 参数传递」:
* > **两个及以上参数不触发刷新**(多参数、按值与按引用混用均不刷新)
* ⇒ `cell()` 那份子树只按**初始值**渲染了一次(那时 `stat` 还是默认的 0),
* 后来数据回来、`build()` 重跑,但**这个多值 @Builder 的子树不重建**。
*
* ⇒ 修法:抽成 `@ComponentV2` + `@Param`(**@Param 参与观察**)。
* 本项目已在同一类问题上修过 5 次,这是第 6 次:
* `FbTagChip` → `FbCalOptionRow` → `FbDayEventPanel` → `FbEventChip` → `FbTagPickChip`。
*
* ⚠️ **不要**改回 `@Builder`(哪怕只有一个参数也别用:见本组件底部那条)。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbStatCell {
/** 数字(**@Param 参与观察** —— 父组件改了它,本格就重建) */
@Param value: number = 0;
/** 下面的标签(`今天日程` / `在做` / `待办` / `逾期`) */
@Param label: string = '';
/**
* 这一格是"警示项"(逾期)。
*
* ⚠️ 与网页版 `hb-stats .hbs.is-late` 同一取舍:**只有真的 > 0 时才红**。
* 0 件逾期是常态,天天标红会让"逾期"这件事失去分量。
*/
@Param alarm: boolean = false;
build() {
Column({ space: 2 }) {
Text(this.value.toString())
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(this.alarm && this.value > 0 ? FbColors.red : FbColors.ink)
Text(this.label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
}
@@ -0,0 +1,59 @@
/**
* 家书 FamilyBook · 标签筛选片(鸿蒙端)
*
* 🔴🔴 **为什么必须做成"组件"而不是 `@Builder`**(这是真机踩出来的坑):
* 用户 2026-10-15:「点'大学同学',其实'大学同学'这个标签并没有发生改变,
* 其他标签也是一样的。」
*
* 根因:标签片原来是 `FbPeoplePage.tagChip(label, active, count)` 这样的
* **`@Builder` + 简单类型参数**。而在 ArkTS 里:
* `@Builder` 的**值类型参数**(string / number / boolean / 数组/对象字面量)
* **不参与状态观察** —— 父组件里 `active` 的计算结果变了,
* 已经建好的那份 Builder 子树**不会重建**(只有对象引用变化才会)。
* ⇒ 点标签后 `tagFilter` 确实变了、列表也筛了,
* 但**标签片自己的选中样式(蓝底白字)不变** = 用户看到的"标签没变化"。
*
* 解法:做成 `@ComponentV2` 组件,用 `@Param` 接收 ——
* `@Param` **是参与观察的**,父组件传值变了就会重建这一片。
*
* ⚠️ 这也是本项目第一次把"列表里的可选中片"从 Builder 改成组件;
* 其它地方(如看板筛选条 `boardChip`)若也有同样症状,同样处理。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbTagChip {
/** 显示文字(标签名;「全部」传 '全部') */
@Param label: string = '';
/** 是否选中(**必须由父组件按 tagFilter 算好传进来**) */
@Param active: boolean = false;
/**
* 人数;**传 -1 表示不显示数字**(「全部」用它 ——
* 全部的人数等于名册总人数,写出来反而占地方且没信息量)。
*/
@Param count: number = -1;
/** 被点击 */
@Event onPick: () => void = (): void => {
};
build() {
Row({ space: 4 }) {
Text(this.label)
.fontSize(FbMetric.fontTiny)
.fontColor(this.active ? Color.White : FbColors.muted)
if (this.count >= 0) {
Text(this.count.toString())
.fontSize(10)
.fontColor(this.active ? '#FFFFFFCC' : FbColors.faint)
}
}
.padding({ left: 11, right: 11, top: 6, bottom: 6 })
.backgroundColor(this.active ? '#4A7C82' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onPick();
})
}
}
@@ -0,0 +1,52 @@
/**
* 家书 FamilyBook · 标签多选片(鸿蒙端)
*
* 🔴 用户 2026-10-16:「名册里面编辑的时候,或者添加的时候,
* 人的标签**应该是选择的**吧?应该让人**选择多个**,
* 而不是直接在这里输入文本。」
*
* 🔴🔴 为什么必须是**组件**而不是 `@Builder`:
* **`@Builder` 的值类型参数不参与状态观察**
* (SDK 规则 `r/quick-rules/05-state.md`)——
* 点一下 `active` 变了,但**选中样式那份子树不重建** ⇒ 看着"点不了"。
* 本项目已在 `FbTagChip` / `FbCalOptionRow` / `FbEventChip` / `FbDayEventPanel`
* 上各踩过一次(这是第 5 次同类)⇒ **有选中态的片一律做成组件**。
*
* ⚠️ 与名册页那个 `FbTagChip` 的区别:那个是**筛选**(单选、带人数),
* 这个是**编辑多选**(没有人数、可取消)。两个语义不同,故不复用。
*/
import { FbColors, FbMetric } from '../common/Theme';
@ComponentV2
export struct FbTagPickChip {
/** 标签文字 */
@Param label: string = '';
/** 是否已选(**由父组件算好传进来**) */
@Param active: boolean = false;
/** 点一下切换选中 */
@Event onToggle: () => void = (): void => {
};
build() {
Row({ space: 4 }) {
if (this.active) {
Text('✓')
.fontSize(FbMetric.fontTiny)
.fontColor(Color.White)
}
Text(this.label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.active ? Color.White : FbColors.muted)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.active ? '#4A7C82' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
// ⚠️ 间距放组件内部:wrap 容器里的子项必须自带 margin,否则两行会贴在一起
.margin({ right: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.onToggle();
})
}
}
+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();
})
}
}
+39 -9
View File
@@ -28,14 +28,23 @@ export struct FbTopBar {
@BuilderParam actions: () => void;
build() {
Row({ space: 10 }) {
// ---- 应用图标(贴最左;**点它开抽屉**)----
// 🔴 顶栏横向镜像 —— 「质感握姿」(用户 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 自带最小尺寸与内边距,会把图标挤离左边线(用户要"图标靠左")。
// Button 自带最小尺寸与内边距,会把图标挤离边线(用户要"图标靠边")。
Image($r('app.media.fb_logo'))
.width(30)
.height(30)
@@ -43,7 +52,7 @@ export struct FbTopBar {
.border({ width: 1, color: FbColors.line })
.flexShrink(0)
.onClick(() => {
// 点图标 ⇒ 开抽屉(沿用上次的方向 drawerSide,默认左边)
// 点图标 ⇒ 开抽屉(方向由**握姿**定,见 common/FbGrip.ets)
this.app.drawerOpen = true;
})
@@ -60,6 +69,7 @@ export struct FbTopBar {
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10, right: 10 })
.layoutWeight(1)
// ---- 右:各模块自己的操作 ----
@@ -100,14 +110,34 @@ export struct FbTopBar {
dia: 32,
fontSizeVp: 14
})
.margin({ left: 2 })
}
.width('100%')
.height(FbMetric.topBarH)
// 🔴 高度 = **状态栏高度 + 顶栏本身高度**(用户 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: 4, bottom: 4 })
.alignItems(VerticalAlign.Center)
// 顶栏底色 = 白色(与下面的个人信息卡同色)
.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 })
+55 -47
View File
@@ -176,7 +176,7 @@ export struct FbSectionTitle {
*/
@ComponentV2
export struct FbAvatar {
/** 显示名(取首字) */
/** 显示名(取首字;也是**没有图片时**的回落) */
@Param name: string = '';
/** 底色(由业务侧算好传进来) */
@Param color: string = '#A84A28';
@@ -184,6 +184,17 @@ export struct FbAvatar {
@Param dia: number = 36;
/** 字号(vp);不传则按直径推算 */
@Param fontSizeVp: number = 0;
/**
* 头像图 URL(**空 = 用色块字回落**)。
*
* 🔴 为什么要支持真图(用户 2026-10-13:"名册的头像怎么都没有?"):
* 之前这里只有色块字,看起来像"没有头像"。
* 实测名册 800 人里只有 19 人传过头像 ⇒ **两者都要有**:
* 有图的显示图、没图的回落到色块字(这是绝大多数)。
* ⚠️ URL 由调用方用 `fbImageUrl(key, token)` 拼好传进来 ——
* `Image()` 不支持自定义请求头,令牌只能走查询串(见 `fbImageUrl` 的说明)。
*/
@Param imageUrl: string = '';
/** 取名字的第一个字(含中文名的姓) */
private initial(): string {
@@ -195,15 +206,29 @@ export struct FbAvatar {
}
build() {
Text(this.initial())
.width(this.dia)
.height(this.dia)
.borderRadius(this.dia / 2)
.backgroundColor(this.color)
.fontColor(Color.White)
.fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42))
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.Center)
// ⚠️ 用 if/else 而不是"图层叠":两种形态的**尺寸来源**不同
// (Image 有 intrinsic size,Text 靠 width/height)——
// 叠起来容易出现"图把字盖住但字还在占位"的错位。
if (this.imageUrl !== '') {
Image(this.imageUrl)
.width(this.dia)
.height(this.dia)
.borderRadius(this.dia / 2)
// 加载失败/慢时先显示底色,避免"一片空白"
.backgroundColor(this.color)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_logo'))
} else {
Text(this.initial())
.width(this.dia)
.height(this.dia)
.borderRadius(this.dia / 2)
.backgroundColor(this.color)
.fontColor(Color.White)
.fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42))
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.Center)
}
}
}
@@ -403,43 +428,26 @@ export struct FbDotLine {
}
}
/** 一条「三格数字」统计条(首页近七天:日程 / 待办 / 逾期) */
@ComponentV2
export struct FbStatTriple {
@Param labelA: string = '';
@Param valueA: string = '';
@Param labelB: string = '';
@Param valueB: string = '';
@Param labelC: string = '';
@Param valueC: string = '';
/** C 项是否用警示红(逾期数) */
@Param cAlarm: boolean = false;
@Builder
cell(label: string, value: string, alarm: boolean) {
Column({ space: 2 }) {
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(alarm ? FbColors.red : FbColors.ink)
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
build() {
Row() {
this.cell(this.labelA, this.valueA, false)
this.cell(this.labelB, this.valueB, false)
this.cell(this.labelC, this.valueC, this.cAlarm)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
}
/**
* 一条「N 格数字」统计条 —— 🔴🔴 **已删除(v54b,2026-10-17)**。
*
* 原来这里有个 `FbStatTriple`(三格:日程 / 待办 / 逾期),内部用
* `@Builder cell(label, value, alarm)` 画每一格。
*
* 用户 2026-10-17 报「概述卡……**日程、在做、待办的数量是错误的**」,
* 真机实测:接口与端上缓存都是 `{1,3,2,0}`(正确),界面却是 `0/0/0`。
* 根因:SDK 规则 `r/quick-rules/05-state.md` →
* > **@Builder 参数传递**:「**两个及以上参数不触发刷新**」
* ⇒ `cell()` 那份子树只按**初始值**渲染一次(那时还是默认的 0),
* 数据回来、`build()` 重跑了,但**这个多值 @Builder 的子树不重建**。
*
* ⇒ **删除而不是修补**,理由:
* ① 它当时**已无人引用**(首页改成四格 + `FbStatCell` 之后);
* ② 留着它,以后有人"顺手用一下"就会原样复发(本项目已踩过 6 次这个坑);
* ③ 它的三格形态也不再匹配需求(现在要四格)。
* ⇒ 新的写法用 **`components/FbStatCell.ets`**(`@ComponentV2` + `@Param`,
* **@Param 参与观察**),需要几格就摆几个。**不要再恢复 `@Builder` 版本。**
*/
/**
* 进度条(读书进度、旅程完成度…)。
+278
View File
@@ -0,0 +1,278 @@
/**
* 家书 FamilyBook · 日程数据仓库(鸿蒙端)
*
* 对应服务端 `/api/v1/events`(增删改查)与 `/api/v1/calendar`(读全量条目 + 日历本)。
*
* 🔴 为什么日程要**单独一个 Repo**(而不是走通用的 `FbModuleRepo`):
* 通用仓库只做"拉一次、缓存 JSON、展示"——**只读**。
* 而日程是**要能改的**(用户 2026-10-15:「完全脱离电脑端……所有功能都在手机上完成」):
* 新建 / 改时间 / 改标题 / 删掉,每一项都要落到服务端。
* 写操作需要"知道哪个字段动了、失败怎么回滚",与"一次性展示"是两种东西。
* ⇒ 单独一个仓库,写操作显式处理(同观影/名册的做法)。
*
* ⚠️ 读(月历/列表)走 `/calendar`(一次拿全量条目,含节日/农历/日程,
* 由 `FbModuleRepo` 缓存);**单条日程的详情与写**走 `/events/<id>`。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
import { FbCalBook } from '../model/Types';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCalRepo';
/** 一条日程的**完整字段**(服务端 `/events/<id>` 的形状) */
export interface FbCalEventFull {
id: number;
calendar_id: number;
title: string;
location: string;
/** `YYYY-MM-DD HH:MM:SS` */
starts_at: string;
/** `YYYY-MM-DD HH:MM:SS`(空 = 没设结束) */
ends_at: string;
all_day: boolean;
/** 备注 */
note: string;
/** 重复规则(RFC 5545,空 = 不重复) */
rrule: string;
/** 提醒(分钟,可多个;服务端存的是数组或逗号串) */
reminder_minutes: string;
status: string;
transp: boolean;
/** 创建者 person_id */
created_by: number;
}
/** 写日程时要发的字段(全部可选 —— 只传变化的) */
export interface FbCalEventInput {
calendar_id?: number;
title?: string;
location?: string;
starts_at?: string;
ends_at?: string;
all_day?: boolean;
note?: string;
/** 重复:直接给 RRULE 串(端上按 chip 拼好) */
rrule?: string;
/** 提醒分钟数(多个) */
reminder_minutes?: number[];
status?: string;
}
export class FbCalRepo {
private static _inst: FbCalRepo | null = null;
static inst(): FbCalRepo {
if (FbCalRepo._inst === null) {
FbCalRepo._inst = new FbCalRepo();
}
return FbCalRepo._inst;
}
/**
* 拉日历本清单(`GET /api/v1/calendar/books`)。
*
* 🔴🔴 这是个**真 bug 的修复**(2026-10-15 接日程改造时发现):
* 端上原来**不用这个接口**,而是从 `/calendar` 的条目里**归并**出"有哪些本",
* 并且把 `id` 编成 `seen.size + 1`(1、2、3…)——
* 那是**臆造的假 id**,与库里的 `calendars.id` 毫无关系。
* 后果:用假 id 去 `POST /events`(服务端按 `calendar_id` 找本、校验写权限)
* 必然落到错的本、或者直接 403「这本日历不能写入」⇒ **新建日程根本建不上**。
* ⇒ 老实用 `/calendar/books`(它返回真实 id / kind / perm / 是否私有)。
*
* ⚠️ `kind` 决定能不能写:`movie`/`trip`/`lunar`/`holiday` 这些是**派生本/系统本**,
* 服务端 `Calendar::isReadonlyKind` 会拒写 ⇒ 端上也要把它们标成 `canWrite=false`,
* 否则用户选了它、点保存才报错(体验很差)。
*/
async fetchBooks(token: string): Promise<FbCalBook[]> {
const res: FbApiResult = await fbApi('/api/v1/calendar/books', { token: token });
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '日历本拉取失败:%{public}s', res.error);
return [];
}
const out: FbCalBook[] = [];
try {
const rows: Record<string, Object>[] = JSON.parse(res.data) as Record<string, Object>[];
for (const r of rows) {
const kind: string = this.str(r, 'kind');
const perm: string = this.str(r, 'perm');
const b: FbCalBook = {
id: this.num(r, 'id'),
name: this.str(r, 'name'),
color: this.str(r, 'color') !== '' ? this.str(r, 'color') : '#9AA0A6',
// 派生本(观影/旅行/农历/节假日/节气/传统节日)不可写;只读共享本也不可写
canWrite: !this.isReadonlyKind(kind) && perm !== 'read',
visible: true,
shared: r['shared'] === 1 || r['shared'] === true,
derived: kind !== 'custom'
};
if (b.id > 0 && b.name !== '') {
out.push(b);
}
}
} catch (err) {
hilog.error(DOMAIN, TAG, '日历本解析失败:%{public}s', JSON.stringify(err));
}
return out;
}
/** 与 `Calendar::isReadonlyKind` 同一口径(这些本不放用户日程) */
private isReadonlyKind(kind: string): boolean {
return kind === 'movie' || kind === 'trip' || kind === 'lunar'
|| kind === 'holiday' || kind === 'traditional' || kind === 'solarterm';
}
/**
* 拉单条日程的完整字段(打开详情/编辑时用)。
*
* ⚠️ `/calendar` 给的是"画格子用的精简条目"(标题/时间/本色/地点),
* 没有 `note`/`rrule`/`reminder`/`ends_at` —— 详情与编辑都需要它们,
* 所以点开某条时再单独拉一次(一次往返、几百毫秒)。
*/
async fetchEvent(id: number, token: string): Promise<FbCalEventFull | null> {
if (id <= 0) {
return null;
}
const res: FbApiResult = await fbApi('/api/v1/events/' + id.toString(), { token: token });
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '日程详情拉取失败(%{public}d):%{public}s', id, res.error);
return null;
}
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
return this.toFull(o);
} catch (err) {
hilog.error(DOMAIN, TAG, '日程详情解析失败:%{public}s', JSON.stringify(err));
return null;
}
}
/** 新建日程(`POST /api/v1/events`)。成功时返回新 id(0 = 失败/取不到) */
async create(input: FbCalEventInput, token: string): Promise<FbApiResult> {
const body: Record<string, Object> = this.toBody(input);
const res: FbApiResult = await fbApi('/api/v1/events',
{ method: 'POST', body: body, token: token });
return res;
}
/** 改日程(`PATCH /api/v1/events/<id>`)—— 只传要改的字段 */
async update(id: number, input: FbCalEventInput, token: string): Promise<FbApiResult> {
const body: Record<string, Object> = this.toBody(input);
return await fbApi('/api/v1/events/' + id.toString(),
{ method: 'PATCH', body: body, token: token });
}
/** 删日程(`DELETE /api/v1/events/<id>`) */
async remove(id: number, token: string): Promise<FbApiResult> {
return await fbApi('/api/v1/events/' + id.toString(),
{ method: 'DELETE', token: token });
}
/** 从接口返回里取新 id(取不到返回 0) */
newIdOf(res: FbApiResult): number {
if (!res.ok || res.data === '') {
return 0;
}
try {
const o: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
const v: Object | undefined = o['id'];
if (typeof v === 'number') {
return v;
}
if (typeof v === 'string') {
return parseInt(v, 10);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '解析新日程 id 失败:%{public}s', JSON.stringify(err));
}
return 0;
}
/**
* 输入 → 请求体。
*
* ⚠️ **只放传进来的字段**:服务端每个字段都是"取不到就用原值",
* 少传一个就少覆盖一个 —— 这正是"编辑时只改一项、其余不动"能成立的原因。
* (反过来,若把所有字段都填上默认值发过去,会把用户没动的东西清空。)
*/
private toBody(input: FbCalEventInput): Record<string, Object> {
const b: Record<string, Object> = {};
if (input.calendar_id !== undefined) { b['calendar_id'] = input.calendar_id; }
if (input.title !== undefined) { b['title'] = input.title; }
if (input.location !== undefined) { b['location'] = input.location; }
if (input.starts_at !== undefined) { b['starts_at'] = input.starts_at; }
if (input.ends_at !== undefined) { b['ends_at'] = input.ends_at; }
if (input.all_day !== undefined) { b['all_day'] = input.all_day; }
if (input.note !== undefined) { b['note'] = input.note; }
if (input.rrule !== undefined) { b['rrule'] = input.rrule; }
if (input.status !== undefined) { b['status'] = input.status; }
if (input.reminder_minutes !== undefined) { b['reminder_minutes'] = input.reminder_minutes; }
return b;
}
/** 接口对象 → 强类型(容错:缺字段给默认值) */
private toFull(o: Record<string, Object>): FbCalEventFull {
const e: FbCalEventFull = {
id: this.num(o, 'id'),
calendar_id: this.num(o, 'calendar_id'),
title: this.str(o, 'title'),
location: this.str(o, 'location'),
starts_at: this.str(o, 'starts_at'),
ends_at: this.str(o, 'ends_at'),
all_day: o['all_day'] === true || o['all_day'] === 1,
note: this.str(o, 'note'),
rrule: this.str(o, 'rrule'),
reminder_minutes: this.remStr(o, 'reminder_minutes'),
status: this.str(o, 'status') !== '' ? this.str(o, 'status') : 'confirmed',
transp: o['transp'] === true || o['transp'] === 1,
created_by: this.num(o, 'created_by')
};
return e;
}
private str(o: Record<string, Object>, k: string): string {
const v: Object | undefined = o[k];
if (typeof v === 'string') {
return v;
}
if (typeof v === 'number') {
return v.toString();
}
return '';
}
private num(o: Record<string, Object>, k: string): number {
const v: Object | undefined = o[k];
if (typeof v === 'number') {
return v;
}
if (typeof v === 'string') {
const n: number = parseInt(v, 10);
return isNaN(n) ? 0 : n;
}
return 0;
}
/** 提醒可能是数组、也可能是逗号串 ⇒ 统一成逗号串 */
private remStr(o: Record<string, Object>, k: string): string {
const v: Object | undefined = o[k];
if (v === undefined || v === null) {
return '';
}
if (Array.isArray(v)) {
const parts: string[] = [];
for (const x of v as Object[]) {
parts.push(typeof x === 'number' ? x.toString() : String(x));
}
return parts.join(',');
}
if (typeof v === 'string') {
return v;
}
if (typeof v === 'number') {
return v.toString();
}
return '';
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* 家书 FamilyBook · 日程设置(鸿蒙端)
*
* 存"日程模块自己的偏好"——它们是**这台设备上的人怎么看日历**,
* 与服务器无关、也不该同步给别人。
*
* 🔴 用户 2026-10-15:「我们家书的日程功能模块,包括它的界面设计和功能,
* 跟 SyncCalendar 做得一样,就差不多。……有几种不同的视图方式,
* 然后包括下面日程的显示啊什么之类的。」
* ⇒ 对齐 SyncCalendar 的设置项:
* · **默认视图**(月 / 周 / 列表)—— 打开日程页时先用哪个
* · **日程显示方式**(时间轴 / 列表)—— 同一天里日程怎么画
* · **新建日程落到哪本日历**(默认日历本)
* · **哪些日历本可见**(隐藏不想看的那几本)
*
* 🔴 为什么用 `preferences` 而不是本地库:都是**单个键值**,
* 不是多行记录(同一范式见 `FbTokenStore` 的说明)。
* ⚠️ 必须**同步读**(`get*()`):页面一渲染就要知道用哪个视图,
* 若只能 await,每个调用点都要 async、把简单逻辑搞碎。
* ⇒ 用"启动时预加载进内存 + 写时同时更新内存"(同 FbTokenStore)。
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCalSettings';
/** preferences 实例名 */
const STORE_NAME: string = 'fb_calendar';
const K_VIEW: string = 'default_view'; // month | week | agenda
const K_STYLE: string = 'display_style'; // timeline | list
const K_BOOK: string = 'default_book_id'; // 新建日程默认落到哪本
const K_HIDDEN: string = 'hidden_books'; // 逗号分隔的日历本 id
/** 视图:月 / 周 / 列表(与 SyncCalendar 的 VIEW_ORDER 同一套) */
export type FbCalView = 'month' | 'week' | 'agenda';
/** 日程画法:时间轴 / 列表(与 SyncCalendar 的 displayStyle 同一套) */
export type FbCalStyle = 'timeline' | 'list';
class FbCalSettingsImpl {
private view: FbCalView = 'month';
private style: FbCalStyle = 'timeline';
private bookId: number = 0;
private hidden: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/** 预加载进内存(应用启动时调一次;幂等) */
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(K_VIEW, 'month');
const s: preferences.ValueType = await this.store.get(K_STYLE, 'timeline');
const b: preferences.ValueType = await this.store.get(K_BOOK, 0);
const h: preferences.ValueType = await this.store.get(K_HIDDEN, '');
this.view = (v === 'week' || v === 'agenda') ? v : 'month';
this.style = (s === 'list') ? 'list' : 'timeline';
this.bookId = typeof b === 'number' ? b : 0;
this.hidden = typeof h === 'string' ? h : '';
this.ready = true;
hilog.info(DOMAIN, TAG, '日程设置预加载完成:%{public}s / %{public}s', this.view, this.style);
} catch (err) {
// ⚠️ 读不到就用默认值,**不要**让启动挂掉
hilog.warn(DOMAIN, TAG, '日程设置预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
isReady(): boolean {
return this.ready;
}
// ---- 同步读 ----
getView(): FbCalView {
return this.view;
}
getStyle(): FbCalStyle {
return this.style;
}
getBookId(): number {
return this.bookId;
}
/** 被隐藏的日历本 id 列表 */
hiddenIds(): number[] {
const out: number[] = [];
if (this.hidden === '') {
return out;
}
for (const s of this.hidden.split(',')) {
const n: number = parseInt(s, 10);
if (!isNaN(n) && n > 0) {
out.push(n);
}
}
return out;
}
/** 某本日历是否可见(默认都可见) */
isVisible(id: number): boolean {
return this.hiddenIds().indexOf(id) < 0;
}
// ---- 异步写(同时更新内存) ----
async setView(v: FbCalView): Promise<void> {
this.view = v;
await this.put(K_VIEW, v);
}
async setStyle(s: FbCalStyle): Promise<void> {
this.style = s;
await this.put(K_STYLE, s);
}
async setBookId(id: number): Promise<void> {
this.bookId = id;
await this.put(K_BOOK, id);
}
/** 设置某本日历的可见性 */
async setVisible(id: number, visible: boolean): Promise<void> {
const ids: number[] = this.hiddenIds();
const at: number = ids.indexOf(id);
if (visible && at >= 0) {
ids.splice(at, 1);
} else if (!visible && at < 0) {
ids.push(id);
}
const parts: string[] = [];
for (const n of ids) {
parts.push(n.toString());
}
this.hidden = parts.join(',');
await this.put(K_HIDDEN, this.hidden);
}
private async put(key: string, val: preferences.ValueType): Promise<void> {
try {
if (this.store === null) {
hilog.warn(DOMAIN, TAG, '还没有 store,设置没能落盘(重启会丢)');
return;
}
await this.store.put(key, val);
// ⚠️ `put` 只写内存,**必须 `flush`** 才落盘(同 FbTokenStore 的坑)
await this.store.flush();
} catch (err) {
hilog.error(DOMAIN, TAG, '日程设置保存失败:%{public}s', JSON.stringify(err));
}
}
}
/** 全局单例 */
export const FB_CAL_SETTINGS: FbCalSettingsImpl = new FbCalSettingsImpl();
+460
View File
@@ -0,0 +1,460 @@
/**
* 家书 FamilyBook · 本地库管理器(鸿蒙端)
*
* 职责:打开/创建本地库、首装建表、按版本迁移、提供便捷查询。
*
* 🔴 为什么用**单例**而不是每次 `getRdbStore`:
* RDB 的 store 是可以复用的句柄,反复打开会重复持有连接;
* 而且首装建表/迁移**必须只跑一次**(并发打开会两边都建表、互相报错)。
* ⚠️ 用 Promise 缓存(`openPromise`)而不是"boolean 标志":
* 标志位挡不住并发——两个调用同时进来时,标志都还是 false,
* 于是两个都去打开。缓存 Promise 才能保证"同一时刻只有一次打开"。
*/
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbDb';
/**
* 本地库管理器。
*
* ⚠️ 用 class + 模块级单例,而不是 static 方法堆在一个类上:
* 需要持有 `store` 句柄与"打开中"的 Promise,这些是**实例状态**。
*/
export class FbDb {
private store: relationalStore.RdbStore | null = null;
/**
* 最后一次打开/操作失败的说明('' = 没失败)。
*
* 🔴 为什么必须留这么一个字段(而不是只打 hilog):
* **本机设备 hilog 读不出内容**(已知限制,试过 `hilog -x -T xxx` 全是空)。
* 而"本地库没建起来"这种问题**只能从失败原因定位** ——
* 留在内存里、由设置页显示出来,是这台设备上唯一可靠的诊断手段。
* ⚠️ 它**不是**给用户看的正式文案(可能是英文异常),
* 是给开发者看的诊断信息,界面要标成"诊断"。
*/
private lastError: string = '';
/** 打开进度打点(走到哪一步了,同样为了诊断) */
private lastStage: string = '';
/** 打开中的 Promise(并发去重,见文件头说明) */
private openPromise: Promise<relationalStore.RdbStore> | null = null;
/**
* 打开(必要时创建)本地库并保证结构就绪。
*
* @param ctx 应用上下文(`getContext()` 得到的能力上下文,
* ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录)
*/
open(ctx: common.Context): Promise<relationalStore.RdbStore> {
if (this.store !== null) {
return Promise.resolve(this.store);
}
if (this.openPromise !== null) {
return this.openPromise; // 已有人在开,跟着等同一个
}
this.openPromise = this.doOpen(ctx);
return this.openPromise;
}
/** 真正的打开逻辑 */
private async doOpen(ctx: common.Context): Promise<relationalStore.RdbStore> {
try {
// 🔴 诊断:把每一步单独打点。
// 第一次跑时"界面显示走到了同步、但沙箱里没有 .db 文件"——
// 说明**某一步静默失败了**。分步打点才能定位是哪一步。
this.lastStage = '[1] 准备 getRdbStore';
hilog.info(DOMAIN, TAG, '[1] 准备 getRdbStore,name=%{public}s', FB_STORE_CONFIG.name);
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
this.lastStage = '[2] getRdbStore 成功,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[2] getRdbStore 成功,当前 version=%{public}d', store.version);
// 首装建表:`IF NOT EXISTS` 本身幂等,但**版本迁移**不能这样粗暴跑
await this.createSchemaIfNeeded(store);
this.lastStage = '[3] 建表完成,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[3] 建表完成,version=%{public}d', store.version);
await this.migrate(store);
this.lastStage = '[4] 迁移完成,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[4] 迁移完成,version=%{public}d', store.version);
this.store = store;
this.lastStage = '[5] 就绪,version=' + store.version.toString();
this.lastError = '';
hilog.info(DOMAIN, TAG, '[5] 本地库就绪,版本=%{public}d', store.version);
return store;
} catch (err) {
// ⚠️ 失败时**必须清掉 openPromise**,否则后续调用永远拿到这个失败的 Promise
// (表现为"重试也没用",很难查)。
this.openPromise = null;
const e: BusinessError = err as BusinessError;
// ⚠️ 必须把 message 完整打出来:BusinessError 的 code 常是 14800xxx 这类
// 通用码,光看码猜不出原因,message 里才有"哪个参数不对"。
this.lastError = 'code=' + (e.code !== undefined ? e.code : -1).toString()
+ ' msg=' + (e.message !== undefined ? e.message : JSON.stringify(err));
hilog.error(DOMAIN, TAG, '打开本地库失败:code=%{public}d message=%{public}s',
e.code !== undefined ? e.code : -1,
e.message !== undefined ? e.message : JSON.stringify(err));
throw err as Error;
}
}
/**
* 首装建表。
*
* ⚠️ 判据是 `store.version === 0`(全新库),**不是**"表数量为 0":
* 后者在有"只建了部分表"的脏状态时会误判。
*/
private async createSchemaIfNeeded(store: relationalStore.RdbStore): Promise<void> {
if (store.version !== 0) {
return; // 老库,交给 migrate()
}
for (const sql of FB_SCHEMA_SQL) {
await store.executeSql(sql);
}
store.version = FB_DB_VERSION;
hilog.info(DOMAIN, TAG, '首装建表完成,%{public}d 条语句', FB_SCHEMA_SQL.length);
}
/**
* 按版本迁移。
*
* 🔴 本地库迁移与服务端**完全不同**:
* 服务端可以"每次请求都全量 ALTER 一遍"(它自己就是服务器、失败可重试);
* 用户的手机不能这么干 —— 逐版本、可预期、失败能停在已知状态。
*
* ⚠️ 目前只有 v1(首版),迁移体是空的占位。
* 将来加表/加列时在这里按 `from` 版本逐级 ALTER,**不要**直接改 FB_SCHEMA_SQL ——
* 那只会影响新装用户,老用户升不上来(这是最常见的迁移翻车方式)。
*/
private async migrate(store: relationalStore.RdbStore): Promise<void> {
const from: number = store.version;
if (from === 0 || from >= FB_DB_VERSION) {
return;
}
hilog.info(DOMAIN, TAG, '本地库迁移:v%{public}d → v%{public}d', from, FB_DB_VERSION);
// ---- v1 → v2:名册支持 ----
// ⚠️ 老库(已经装了观影那版的手机)升级时走到这里。
// v2 的改动是"persons 补 7 列 + 新增 person_roster / person_tags 两张表"。
// ⚠️ SQLite 的 `ALTER TABLE ADD COLUMN` **不支持 IF NOT EXISTS**,
// 所以这里靠"版本号只加一次"来保证不重复执行(from<2 才进来)。
// ⚠️ 新增的列都给上 NOT NULL DEFAULT '' 或允许 NULL ——
// 老数据行会被填上默认值,不会因为加列而失败。
if (from < 2) {
const addCols: string[] = [
`ALTER TABLE persons ADD COLUMN avatar_color TEXT`,
`ALTER TABLE persons ADD COLUMN email TEXT`,
`ALTER TABLE persons ADD COLUMN org TEXT`,
`ALTER TABLE persons ADD COLUMN title TEXT`,
`ALTER TABLE persons ADD COLUMN slogan TEXT`,
`ALTER TABLE persons ADD COLUMN address TEXT`,
`ALTER TABLE persons ADD COLUMN addr_city TEXT`,
];
for (const sql of addCols) {
try {
await store.executeSql(sql);
} catch (err) {
// ⚠️ 已经加过这一列就跳过(比如迁移中途失败后重跑)。
// 这里**不能**直接抛:一列已在会让整次迁移卡死、永远升不上去。
hilog.warn(DOMAIN, TAG, '加列跳过(可能已存在):%{public}s', sql);
}
}
// 两张新表用 IF NOT EXISTS,可重复执行
const newTables: string[] = [
`CREATE TABLE IF NOT EXISTS person_roster (
owner_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
family_id INTEGER NOT NULL,
group_id INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
synced_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner_id, person_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_roster_owner ON person_roster (owner_id, sort_order)`,
`CREATE TABLE IF NOT EXISTS person_tags (
owner_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
tag TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT '',
synced_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner_id, person_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_tags_owner ON person_tags (owner_id, tag)`,
];
for (const sql of newTables) {
await store.executeSql(sql);
}
hilog.info(DOMAIN, TAG, 'v2 迁移完成(名册)');
}
// ---- v2 → v3:通用模块缓存 ----
// ⚠️ 一张"按模块名存 JSON"的通用表(理由见 FbSchema 里 module_cache 的说明)。
// 用 IF NOT EXISTS,可重复执行。
if (from < 3) {
await store.executeSql(
'CREATE TABLE IF NOT EXISTS module_cache ('
+ ' name TEXT PRIMARY KEY, payload TEXT NOT NULL DEFAULT \'\','
+ ' fetched_at INTEGER NOT NULL DEFAULT 0)');
hilog.info(DOMAIN, TAG, 'v3 迁移完成(模块缓存)');
}
// ---- v3 → v4:名册行补 `phones`(JSON 数组,主号在前)----
//
// 🔴 为什么存 JSON 而不是"号码表":
// 号码本来就是**名册同步时一起拿到的**(`/roster` 的 `phones` 字段),
// 单独建表反而要多一次 join、多一套写入逻辑;而这里只需要"整组读出来"。
// ⚠️ 与"标签用 person_tags 表"不同:标签要**跨人聚合**(标签云)、
// 必须能 GROUP BY,所以它值得一张表;电话不需要聚合。
if (from < 4) {
await store.executeSql(
"ALTER TABLE person_roster ADD COLUMN phones TEXT NOT NULL DEFAULT '[]'");
hilog.info(DOMAIN, TAG, 'v4 迁移完成(名册电话)');
}
store.version = FB_DB_VERSION;
}
/** 取已打开的 store;未打开时报错(而不是静默返回 null,避免空指针难查) */
require(): relationalStore.RdbStore {
if (this.store === null) {
throw new Error('本地库还没打开,请先 await FbDb.inst().open(ctx)');
}
return this.store;
}
/** 是否已就绪 */
isReady(): boolean {
return this.store !== null;
}
/** 打开失败的原因('' = 没失败);**诊断用**,见 lastError 的说明 */
errorText(): string {
return this.lastError;
}
/** 打开进度到哪一步;**诊断用** */
stageText(): string {
return this.lastStage;
}
/**
* 执行查询并返回对象数组。
*
* ⚠️ 用 `querySql` 直接写 SQL 而不是 `RdbPredicates`:
* 本项目的查询都是"按 family_id + updated_at 增量拉"这类**带条件的复杂查询**,
* Predicates 表达"IN (子查询)"之类很别扭;而 SQL 是服务端同步过来的原文,
* 直接对照排查更方便。
* ⚠️ 所有外部值一律走 `?` 占位(下方 `args`),**不做字符串拼接** —— 防注入。
*/
async queryRows(sql: string, args: relationalStore.ValueType[] = []): Promise<FbRow[]> {
const store: relationalStore.RdbStore = this.require();
const rs: relationalStore.ResultSet = await store.querySql(sql, args);
const rows: FbRow[] = [];
try {
while (rs.goToNextRow()) {
rows.push(FbRow.fromResultSet(rs));
}
} finally {
rs.close(); // ⚠️ 必须关;不关会泄漏游标,连续查询会报错
}
return rows;
}
/** 执行一条查询,返回第一行(没有则 null) */
async queryOne(sql: string, args: relationalStore.ValueType[] = []): Promise<FbRow | null> {
const rows: FbRow[] = await this.queryRows(sql, args);
return rows.length > 0 ? rows[0] : null;
}
/** 执行写语句(INSERT/UPDATE/DELETE 均可) */
async exec(sql: string, args: relationalStore.ValueType[] = []): Promise<void> {
await this.require().executeSql(sql, args);
}
/** 查询单个数值(COUNT/MAX 之类) */
/**
* 查询单个数值(COUNT / MAX 之类)。
*
* ⚠️ 必须走 `FbRow.num()` 而不是 `typeof v === 'number' ? v : 0`:
* SQLite 的 `COUNT(*)` 经 `getValue()` 取回来可能是 **bigint**
* (或字符串),只认 `number` 会把**真实结果错判成 0** ——
* 实测踩过:库里明明有 200 条,自检却报 `ev0`,
* 一度误判成"数据没写进去",白查了很久。
* ⇒ 交给 `num()` 统一做类型收敛。
*/
async scalar(sql: string, args: relationalStore.ValueType[] = []): Promise<number> {
const row: FbRow | null = await this.queryOne(sql, args);
if (row === null) {
return 0;
}
const keys: string[] = row.keys();
if (keys.length === 0) {
return 0;
}
return row.num(keys[0], 0);
}
/**
* 在事务里跑一段操作(**用 `createTransaction` 的对象式 API**)。
*
* 🔴🔴 这里踩过一个很深的坑,务必记住(完整排查过程见文末):
* · 老的 `store.beginTransaction()` + `store.commit()`(无参三件套)
* 在中间有 `await` 时会**丢事务上下文** ⇒
* "数据看似写进去了(同连接能查到)、`COUNT(*)` 却是 0、
* 且 Promise 永不返回"。
* · 换成 `createTransaction()` 拿到**事务对象**后,
* 在它上面用 `batchInsertSync`(同步版)仍然**卡住**
* (实测停在"4/6 组装数据"→写入之间,几分钟不返回)。
* ⇒ 最终可行组合:**`createTransaction()` 对象 + 在事务对象上 `await` 异步版**
* (`batchInsert` / `execute`)。事务上下文由**对象**持有,
* 所以这里的 `await` 不会像老三件套那样丢上下文。
*
* ⚠️ 回调必须是 `async` 且**逐个 await 完成后再返回** ——
* 回调返回后才会 `commit()`,中途还有未完成的写入就会提交不完整。
*/
async transaction(work: (tx: relationalStore.Transaction) => Promise<void>): Promise<void> {
const store: relationalStore.RdbStore = this.require();
const tx: relationalStore.Transaction = await store.createTransaction();
try {
await work(tx);
await tx.commit();
} catch (err) {
// ⚠️ 回滚也 await;失败时不往上抛(避免掩盖原始错误)
try {
await tx.rollback();
} catch (rollErr) {
hilog.error(DOMAIN, TAG, '回滚也失败了:%{public}s', JSON.stringify(rollErr));
}
hilog.error(DOMAIN, TAG, '事务回滚:%{public}s', JSON.stringify(err));
throw err as Error;
}
}
/** 关闭本地库(一般只在退出应用时调用) */
async close(): Promise<void> {
if (this.store !== null) {
await this.store.close();
this.store = null;
this.openPromise = null;
}
}
// ---- 单例 ----
private static instance: FbDb | null = null;
static inst(): FbDb {
if (FbDb.instance === null) {
FbDb.instance = new FbDb();
}
return FbDb.instance;
}
}
/**
* 一行查询结果(列名 → 值)。
*
* ⚠️ 为什么不用 `Record<string, relationalStore.ValueType>` 直接返回:
* ResultSet 的列值要**逐个按列名取**(`getString` / `getLong` / `isColumnNull`),
* 直接塞进 Record 会丢掉"这一列是否为 NULL"的信息
* (SQLite 里 NULL 与空串是两回事,把 NULL 变成 '' 会让"有没有填备注"判错)。
* 这里用一个小包装类把 ResultSet 的语义完整带出来。
*/
export class FbRow {
private data: Map<string, relationalStore.ValueType> = new Map<string, relationalStore.ValueType>();
private nulls: Set<string> = new Set<string>();
/** 从 ResultSet 当前行读全部列 */
static fromResultSet(rs: relationalStore.ResultSet): FbRow {
const row: FbRow = new FbRow();
const names: string[] = rs.getColumnNames();
for (const name of names) {
const idx: number = rs.getColumnIndex(name);
if (rs.isColumnNull(idx)) {
row.nulls.add(name); // 记住"这列是 NULL",不是空值
row.data.set(name, '');
} else {
// ⚠️ 用 `getValue(idx)` 而不是 `getColumnType(idx)` 再分支:
// · `getColumnType` 返回的是 **Promise**(异步!),在同步循环里用会拿到
// Promise 对象、所有比较都成立不了(第一版就这么错的);
// · 就算换成 `getColumnTypeSync`,也还是"先问类型再取值"两步。
// `getValue` 直接按列的真实类型返回 `ValueType`,一步到位、不丢类型。
row.data.set(name, rs.getValue(idx));
}
}
return row;
}
keys(): string[] {
const out: string[] = [];
this.data.forEach((_v: relationalStore.ValueType, k: string) => {
out.push(k);
});
return out;
}
/** 该列是否为 NULL */
isNull(name: string): boolean {
return this.nulls.has(name);
}
get(name: string): relationalStore.ValueType {
const v: relationalStore.ValueType | undefined = this.data.get(name);
return v !== undefined ? v : '';
}
/** 取字符串;NULL 会返回 null(与"空串"区分开) */
str(name: string): string | null {
if (this.isNull(name)) {
return null;
}
const v: relationalStore.ValueType = this.get(name);
if (typeof v === 'string') {
return v;
}
if (typeof v === 'number' || typeof v === 'bigint' || typeof v === 'boolean') {
return String(v); // ⚠️ ValueType 含 bigint/boolean,都要能转
}
return null; // Uint8Array / Asset 之类不走字符串通道
}
/** 取字符串,NULL 折成空串(表单显示用) */
strOr(name: string, fallback: string): string {
const v: string | null = this.str(name);
return v !== null ? v : fallback;
}
/** 取整数;NULL 折成 fallback */
num(name: string, fallback: number): number {
if (this.isNull(name)) {
return fallback;
}
const v: relationalStore.ValueType = this.get(name);
if (typeof v === 'number') {
return v;
}
if (typeof v === 'bigint') {
return Number(v);
}
if (typeof v === 'string') {
const n: number = Number(v);
return Number.isNaN(n) ? fallback : n;
}
if (typeof v === 'boolean') {
return v ? 1 : 0;
}
return fallback;
}
/** 取布尔(SQLite 用 0/1 存) */
bool(name: string): boolean {
return this.num(name, 0) !== 0;
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* 家书 FamilyBook · 设备身份(鸿蒙端)
*
* 只干一件事:告诉服务器「我是哪台设备」。
*
* 🔴 v57(用户 2026-10-17):
* 「我们在请求服务端的时候,就需要把我们的客户端的这个设备信息,
* 就是我的设备信息,提交到我们的服务器端。」
* 「只有 uuid 用户无法确定是哪台设备」——所以还要一个**人看得懂**的名字。
*
* ⚠️ **为什么 device_id 是 App 自己生成的 UUID,而不是硬件号**:
* HarmonyOS 三方应用**拿不到** UDID / 序列号(`ohos.permission.sec.ACCESS_UDID`
* 是系统级权限,上架审核也会拒)。能拿到的只有机型、系统版本这类**非唯一**信息。
* ⇒ 首次启动生成一个 UUID 存在本地,之后一直用它。**卸载重装 = 一台新设备**
* (用户 2026-10-17 已确认:"卸载重装就是新设备,没问题")。
*
* ⚠️ 设备名默认取机型(`marketName`),用户可以在设置页改 ——
* 家里两台一模一样的手机时,"我的 Mate 40" 变成 "爸爸的手机" 才认得出。
*
* ⚠️ 与 `FbTokenStore` 同构(启动预加载 + 同步读):
* 网络层要**同步**拿到 device_id 拼请求体,异步读会把简单逻辑打碎。
*/
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { util } from '@kit.ArkTS';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbDevice';
const STORE_NAME: string = 'fb_device';
const KEY_ID: string = 'device_id';
const KEY_NAME: string = 'device_name';
/**
* 客户端版本号(与 `AppScope/app.json5` 里的 versionName 对齐)。
*
* ⚠️ 写常量而不是从 bundleManager 读:这里只需要一个"能对账"的字符串,
* 读 bundle 要走异步 API + 多一个系统依赖,收益不值。
* 发版时记得改这一行(设备管理页会显示它,用户能看出"这台还是旧版本")。
*/
export const FB_APP_VERSION: string = '0.7.0'; // 与 AppScope/app.json5 的 versionName 保持一致
/** 平台描述:`harmonyos 5.0.1`(拿不到系统名就退回 API 版本) */
function platformText(): string {
const name: string = deviceInfo.osFullName;
if (name !== undefined && name !== '') {
return ('harmonyos ' + name).substring(0, 30);
}
return ('harmonyos api' + deviceInfo.sdkApiVersion.toString()).substring(0, 30);
}
/** 机型默认名:`HUAWEI DEL-AL20` 这种;拿不到就给个通用称呼 */
function defaultDeviceName(): string {
const market: string = deviceInfo.marketName;
const brand: string = deviceInfo.brand;
if (market !== undefined && market !== '') {
return market.substring(0, 60);
}
if (brand !== undefined && brand !== '') {
return (brand + ' 鸿蒙设备').substring(0, 60);
}
return '我的手机';
}
class FbDeviceStoreImpl {
private deviceId: string = '';
private deviceName: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/**
* 预加载设备身份;**首次启动时生成** UUID 并落盘。
* 应在应用启动时调用一次(见 EntryAbility)。
*/
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const id: preferences.ValueType = await this.store.get(KEY_ID, '');
let sid: string = typeof id === 'string' ? id : '';
if (sid === '') {
// 首次启动:生成一个并**立刻落盘**(不然每次启动都是一台"新设备",
// 十几次就把服务端的 10 台上限撞满,账号被自己锁死)
sid = util.generateRandomUUID(false);
await this.store.put(KEY_ID, sid);
await this.store.flush();
hilog.info(DOMAIN, TAG, '首次启动,已生成设备 ID');
}
this.deviceId = sid;
const nm: preferences.ValueType = await this.store.get(KEY_NAME, '');
const snm: string = typeof nm === 'string' ? nm : '';
this.deviceName = snm !== '' ? snm : defaultDeviceName();
this.ready = true;
hilog.info(DOMAIN, TAG, '设备身份就绪');
} catch (err) {
// ⚠️ 读不到不能挡住启动:给一个**进程内临时**身份,本次能用,
// 只是下次启动会变成"另一台设备"(服务端表现为多一台,用户可踢掉)。
hilog.warn(DOMAIN, TAG, '设备身份初始化失败:%{public}s', JSON.stringify(err));
if (this.deviceId === '') {
this.deviceId = util.generateRandomUUID(false);
}
if (this.deviceName === '') {
this.deviceName = defaultDeviceName();
}
this.ready = true;
}
}
/** 设备号(同步;未 init 时也保证非空 —— 临时身份) */
getDeviceId(): string {
if (this.deviceId === '') {
this.deviceId = util.generateRandomUUID(false);
}
return this.deviceId;
}
/** 设备名(同步) */
getDeviceName(): string {
if (this.deviceName === '') {
this.deviceName = defaultDeviceName();
}
return this.deviceName;
}
/** 平台描述(同步,如 `harmonyos HarmonyOS 5.0.1`) */
platform(): string {
return platformText();
}
/** 预加载是否完成(同步) */
isReady(): boolean {
return this.ready;
}
/** 改设备名("爸爸的手机"这种),只影响这台机器在服务端列表里的显示名 */
async setDeviceName(ctx: common.Context, name: string): Promise<boolean> {
const v: string = name.trim().substring(0, 60);
if (v === '') {
return false;
}
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_NAME, v);
await this.store.flush();
this.deviceName = v;
return true;
} catch (err) {
hilog.error(DOMAIN, TAG, '设备名保存失败:%{public}s', JSON.stringify(err));
return false;
}
}
}
/** 全局单例 */
export const FB_DEVICE_STORE: FbDeviceStoreImpl = new FbDeviceStoreImpl();
+385
View File
@@ -0,0 +1,385 @@
/**
* 家书 FamilyBook · 网络层(鸿蒙端)
*
* 只做一件事:把 HTTP 请求发出去、把服务端的统一信封拆开。
* **不碰数据库、不碰 UI** —— 保持"进数据的通道"单一职责。
*
* 服务端信封(`app/Api.php` 的 `Api::ok` / `Api::fail`):
* 成功:`{ "ok": true, "data": <载荷>, ...额外字段 }`
* 失败:`{ "ok": false, "error": "人话错误说明", "code": "机器码" }`
* ⚠️ HTTP 状态码同时也有意义(401/403/404/422…),别只看 `ok`。
*
* 🔴 为什么把"信封拆解"放在这一层,而不是各页面自己判断 `resp.ok`:
* 服务端换了信封格式(历史上改过一次),分散判断就要改 N 处;
* 收在这一层,只改一个地方。
*/
import { http } from '@kit.NetworkKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_SERVER_STORE } from './FbServerStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbHttp';
/**
* 预填用的默认服务器地址(**仅用于"首次登录"输入框的初始值**)。
*
* 🔴 v55(用户 2026-10-17「应用第一次打开,让用户输入服务器地址」)——
* 地址从"编译期常量"变成了"运行期配置"(见 `FbServerStore`)。
* 这个常量**不再是请求目标**:请求一律走 `fbApiBase()` 读用户配置。
* ⚠️ 留着它只是为了让老用户升级后不用手打域名 —— 输入框预填、点一下就连上。
* 真正生效的永远是用户确认过的那一份。
*/
export const FB_DEFAULT_SERVER: string = 'https://family.yangyq.net';
/**
* 当前服务器基址(用户配置的那一台;未配置返回空串)。
*
* ⚠️ **每次调用都读一次内存缓存**,不要在模块顶层 `const BASE = fbApiBase()` ——
* 那样用户"更换服务器"之后,本进程里发出去的请求还在打旧地址
* (模块顶层只求值一次,且本模块在启动时就加载了)。
* ⇒ 所有拼 URL 的地方都调这个函数。
*/
export function fbApiBase(): string {
return FB_SERVER_STORE.get();
}
/**
* 拼一个可直接给 `Image()` 用的图片地址。
*
* 🔴 为什么图片要走 `media.php` 而不是直连存储:
* 服务端所有附件(海报/头像/照片)都由 `media.php?k=<相对键>` 代理出去 ——
* 存储可能是本地目录、也可能是 OSS,代理让地址形态恒定(服务端注释里写明)。
* 客户端只认这一个形式即可。
*
* 🔴 为什么令牌要放在 **URL query** 里(`&token=`):
* `Image()` 加载网络地址时**无法附加自定义请求头**,
* 所以令牌只能走 URL。(服务端 v45 起 `media.php` 支持 `?token=` 鉴权,
* 与 `api.php` 的"也接受 `?token=`"是同一约定。)
*
* ⚠️ 必须对 key 做 `encodeURIComponent`:key 里有 `/`(如
* `posters/xxx.jpg`)和可能的特殊字符,不编码会把 query 弄坏。
* ⚠️ 但 serve 端会做键净化(拒绝 `..`),所以这里编码是"正确性"需要,
* 不是安全防线(安全由服务端负责)。
*
* @param key 附件的相对键(如 `posters/963dd25f.jpg`);空串返回空串
* @param token API 令牌;空串时也返回地址(服务端会给 401,界面显示占位)
*/
export function fbImageUrl(key: string, token: string): string {
if (key === '') {
return '';
}
const base: string = fbApiBase();
if (base === '') {
return '';
}
let url: string = base + '/media.php?k=' + encodeURIComponent(key);
if (token !== '') {
url += '&token=' + encodeURIComponent(token);
}
return url;
}
/** 一次接口调用的结果 */
export interface FbApiResult {
/** 是否成功(HTTP 2xx 且信封 ok=true) */
ok: boolean;
/** 成功时的载荷(未成功时为 null) */
data: string; // 原始 JSON 片段,由调用方按类型解析
/** 失败时的人话说明(面向用户展示) */
error: string;
/** 失败时的机器码(用于分支判断,如 'unauthorized') */
code: string;
/** HTTP 状态码 */
status: number;
}
/** 请求参数(所有字段都可选,避免调用方写一堆占位) */
export interface FbApiOptions {
/** 请求方法,默认 GET */
method?: string;
/** 查询参数(会被 URL 编码拼到 path 后) */
query?: Record<string, string>;
/**
* 请求体(会被 JSON 序列化;GET 时忽略)。
*
* 🔴 值类型放宽到 `Object`(v52,用户 2026-10-15:名册要"全字段可编辑"):
* 之前只允许 `string|number|boolean|null`,于是**数组/嵌套对象传不进去** ——
* 而工作经历(一组行)、补充联系方式(按 kind 分组)、标签(字符串数组)
* 都是**结构化的**。旧限制下只能把这些字段漏掉或自己拼字符串,
* 那正是"编辑时只能改几项"的来源。
* ⇒ 允许任意可 JSON 化的值,由 `JSON.stringify` 统一处理。
*/
body?: Record<string, Object>;
/** API 令牌(不带则请求匿名,服务端会 401) */
token?: string;
/**
* `true` = 这次请求的 401 **不要**触发全局"踢回登录页"。
*
* 🔴 v57(用户 2026-10-17)。为什么需要这个开关:
* 服务端把所有"令牌无效/过期/被踢"都回 401,而**密码错**也是 401。
* 如果无脑拦截,用户在登录页输错一次密码就会被"全局锁定"逻辑再踢一次,
* 表现为"输错密码后整个 App 闪一下"。
* ⇒ 只有**带着令牌**的业务请求才该触发锁定;登录、探测令牌这类
* 自己会处理结果的请求,显式传 `authProbe: true`。
*/
authProbe?: boolean;
}
/**
* 「令牌失效了」的全局回调(v57)。
*
* 🔴 为什么放在网络层而不是每个页面自己判:
* 用户 2026-10-17:「运行期间任何一个请求返回 401,立刻锁定跳登录页 ——
* 这是真正的兜底,因为同步/写入本来就在不断发请求。」
* 页面有十几个,任何一个漏判都会出现"页面空着但没回登录页"。
* 收在网络层 = **只有一处**,永远漏不掉。
*
* ⚠️ 回调由 `EntryAbility` 注册(它拿得到 UIAbilityContext,能做真正的跳转),
* 网络层自己不碰路由/状态。
*/
export type FbUnauthorizedCb = () => void;
let unauthorizedCb: FbUnauthorizedCb | null = null;
/** 注册「令牌失效」回调(重复注册会覆盖,只保留最后一个) */
export function fbOnUnauthorized(cb: FbUnauthorizedCb): void {
unauthorizedCb = cb;
}
/**
* 发一次 API 请求。
*
* ⚠️ 超时给 15 秒:家庭应用走公网 + 可能跨运营商,5 秒太容易误判"网络不可用";
* 但也别给太长(60 秒),否则用户点了"刷新"要干等。
*/
export async function fbApi(path: string, opts: FbApiOptions = {}): Promise<FbApiResult> {
const method: string = opts.method !== undefined ? opts.method : 'GET';
// 🔴 没配服务器地址时**立刻失败**,不要拼一个 `/api/v1/xxx` 的相对地址发出去
// (那会落到一个说不清的网络上,报出来的错误跟"地址没填"毫无关系,
// 用户根本猜不到是哪里错了)
const base: string = fbApiBase();
if (base === '') {
return {
ok: false, data: '', error: '还没有设置服务器地址', code: 'no_server', status: 0
};
}
const url: string = base + path + buildQuery(opts.query);
const req: http.HttpRequest = http.createHttp();
try {
const header: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (opts.token !== undefined && opts.token !== '') {
header['Authorization'] = 'Bearer ' + opts.token;
}
const extra: http.HttpRequestOptions = {
method: toMethod(method),
header: header,
connectTimeout: 15000,
readTimeout: 15000,
expectDataType: http.HttpDataType.STRING
};
if (opts.body !== undefined && method !== 'GET') {
extra.extraData = JSON.stringify(opts.body);
}
const resp: http.HttpResponse = await req.request(url, extra);
const body: string = typeof resp.result === 'string' ? resp.result : '';
const out: FbApiResult = parseEnvelope(body, resp.responseCode);
/* v57:401 = 令牌无效/过期/被踢 → 立刻通知全局锁定(见 FbUnauthorizedCb 的说明)。
⚠️ 判的是 **HTTP 状态码**而不是 `code` 字段:服务端对三种情况都回 401,
端上不需要(也不该)区分是"过期"还是"被踢"——处理动作完全一样。 */
if (out.status === 401 && opts.authProbe !== true && unauthorizedCb !== null) {
unauthorizedCb();
}
return out;
} catch (err) {
// ⚠️ 网络异常(断网/DNS/超时)走这里,**不是** HTTP 错误码。
// 必须与"服务端返回 4xx"区分开:前者可重试,后者重试无意义。
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '请求失败 %{public}s:%{public}s', path, JSON.stringify(e));
return {
ok: false,
data: '',
error: '网络连接不上,请检查手机网络',
code: 'network',
status: 0
};
} finally {
req.destroy(); // ⚠️ 必须销毁;不销毁会占着连接(HttpRequest 不是自动回收的)
}
}
/** 拆服务端信封 */
function parseEnvelope(body: string, status: number): FbApiResult {
if (body === '') {
return { ok: false, data: '', error: '服务端没有返回内容', code: 'empty', status: status };
}
let obj: Record<string, Object> | null = null;
try {
obj = JSON.parse(body) as Record<string, Object>;
} catch (err) {
// 非 JSON(例如 Apache 的 502 页面)—— 别把 HTML 当错误信息弹给用户
hilog.warn(DOMAIN, TAG, '响应不是 JSON(HTTP %{public}d)', status);
return {
ok: false, data: '', error: '服务端返回异常(HTTP ' + status + ')',
code: 'bad_json', status: status
};
}
const okVal: Object | undefined = obj['ok'];
const isOk: boolean = okVal === true;
if (isOk) {
// data 可能是对象/数组/标量,统一序列化回字符串交给调用方解析,
// 避免在这一层就假定形状(不同接口的 data 结构差别很大)
const d: Object | undefined = obj['data'];
return {
ok: true,
data: d !== undefined ? JSON.stringify(d) : '',
error: '', code: '', status: status
};
}
const errObj: Object | undefined = obj['error'];
const codeObj: Object | undefined = obj['code'];
return {
ok: false,
data: '',
error: typeof errObj === 'string' ? errObj : '请求失败(HTTP ' + status + ')',
code: typeof codeObj === 'string' ? codeObj : '',
status: status
};
}
/** 拼查询串(值做 URL 编码,避免关键词里的空格/中文把 URL 弄坏) */
function buildQuery(query?: Record<string, string>): string {
if (query === undefined) {
return '';
}
const parts: string[] = [];
const keys: string[] = Object.keys(query);
for (const k of keys) {
const v: string = query[k];
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
}
return parts.length > 0 ? '?' + parts.join('&') : '';
}
/**
* 字符串方法名 → `http.RequestMethod` 枚举。
*
* ⚠️ 必须显式映射:`RequestMethod` 是**枚举**,不能直接把字符串赋过去
* (ArkTS 是强类型,`method: 'GET'` 编译不过)。
*/
function toMethod(m: string): http.RequestMethod {
switch (m.toUpperCase()) {
case 'POST': return http.RequestMethod.POST;
case 'PUT': return http.RequestMethod.PUT;
case 'DELETE': return http.RequestMethod.DELETE;
case 'PATCH': return http.RequestMethod.PATCH;
case 'HEAD': return http.RequestMethod.HEAD;
case 'OPTIONS': return http.RequestMethod.OPTIONS;
case 'TRACE': return http.RequestMethod.TRACE;
case 'CONNECT': return http.RequestMethod.CONNECT;
default: return http.RequestMethod.GET;
}
}
/* ═══════════════════════════════════════════════════════════════════
服务器地址探测(登录第一步:确认"这个地址上真的是家书")
═══════════════════════════════════════════════════════════════════ */
/** 探测结果 */
export interface FbProbeResult {
ok: boolean;
/** 服务端自报的应用名(通常是「家书」) */
app: string;
/** 失败原因(ok=false 时有值,可直接展示) */
error: string;
}
/** 服务端 `GET /api/v1/ping` 的 data 形状 */
interface FbPingData {
service?: string;
api?: string;
app?: string;
}
interface FbPingEnvelope {
ok?: boolean;
error?: string;
code?: string;
data?: FbPingData;
}
/**
* 探测一个服务器地址是不是家书服务端。
*
* 🔴 为什么"只回 HTTP 200"不够(必须要这个函数):
* 用户填错地址时,最常见的是"填到了另一个网站"或"填成了家书的某个网页路径"——
* 两者都会返回 200。此时若直接放行,用户会在登录页反复试密码、永远失败,
* 而且**看不出原因**。
* ⇒ 必须验**内容**:`data.service === 'familybook'`。
*
* ⚠️ 超时给 8 秒(比业务请求的 15 秒短):探测是"用户点了按钮在等"的动作,
* 等太久他会以为卡死并反复点。
* ⚠️ 这里**不读 store**:探测的就是"用户刚输入的那个地址",
* 验通了才让它写进 store(见登录页的 `connect()`)。
*
* @param baseUrl 已归一化的基址(见 `fbNormalizeServer`)
*/
export async function fbProbeServer(baseUrl: string): Promise<FbProbeResult> {
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(baseUrl + '/api/v1/ping', {
method: http.RequestMethod.GET,
header: { 'Accept': 'application/json' },
connectTimeout: 8000,
readTimeout: 8000,
expectDataType: http.HttpDataType.STRING
});
const body: string = typeof resp.result === 'string' ? resp.result : '';
return parseProbe(body, resp.responseCode);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '探测服务器失败 %{public}s:%{public}s', baseUrl, JSON.stringify(e));
return { ok: false, app: '', error: '连不上这个地址,请检查域名是否正确、手机是否联网' };
} finally {
req.destroy();
}
}
/** 拆 ping 响应,把"填错了"的几种情况翻成人话 */
function parseProbe(body: string, status: number): FbProbeResult {
if (body === '') {
return { ok: false, app: '', error: '这个地址没有响应(HTTP ' + status.toString() + ')' };
}
let env: FbPingEnvelope;
try {
env = JSON.parse(body) as FbPingEnvelope;
} catch (err) {
// 返回了 HTML —— 十有八九是"填成了别的网站"或"填的是网页路径"
return {
ok: false, app: '',
error: '这个地址返回的不是家书接口(可能填成了别的网站,或少了子目录)'
};
}
const d: FbPingData | undefined = env.data;
const svc: string = d !== undefined && d.service !== undefined ? d.service : '';
if (env.ok === true && svc === 'familybook') {
return {
ok: true,
app: d !== undefined && d.app !== undefined ? d.app : '家书',
error: ''
};
}
if (env.ok !== true && env.error !== undefined && env.error !== '') {
// 服务端明确说了原因(如"还没安装")—— 原样透出
return { ok: false, app: '', error: env.error };
}
return { ok: false, app: '', error: '这个地址不是家书服务器' };
}
+192
View File
@@ -0,0 +1,192 @@
/**
* 家书 FamilyBook · 登录客户端(鸿蒙端)
*
* 只做一件事:把「账号 + 密码」换成「API 令牌」。
*
* 🔴 为什么单独一个文件、不塞进 `FbHttp`:
* `FbHttp` 是"带令牌发请求"的通道;而登录是"**还没令牌时**怎么拿到令牌"
* —— 它在鉴权链路**之前**,语义上是另一件事。
* 放一起会让 `fbApi` 那层出现"要不要带令牌"的分支(现在它假设一定带)。
*
* ⚠️ 密码**不落盘、不进日志**。这里只在函数参数里存在,
* 调用方在拿到结果后立刻清掉自己的副本(见 `FbLoginPage.submit`)。
*
* ⚠️ 地址**从 store 读**(`fbApiBase()`),不接收调用方传的 base:
* 登录页在"连接服务器"那一步已经把地址写进去了,
* 这里再收一个参数会多出一条"传的和存的不一样"的路(迟早出问题)。
* 探测要验"还没存的地址",那是 `fbProbeServer()` 的事。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { http } from '@kit.NetworkKit';
import { fbApiBase } from './FbHttp';
import { FB_DEVICE_STORE, FB_APP_VERSION } from './FbDeviceStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbLogin';
/** 登录结果 */
export interface FbLoginResult {
ok: boolean;
/** 成功时的 API 令牌(fb_ 开头) */
token: string;
/** 令牌过期时间('' = 永久) */
expiresAt: string;
personId: number;
personName: string;
/** 服务端角色(admin / member / guest)—— ⚠️ 不是 `/me` 里那个中文的 role */
role: string;
/** 家庭名('' = 服务端没给,不影响登录) */
familyName: string;
/** 服务端时间(ISO8601)—— 用来记"刚校验过",'' = 老服务端没给 */
serverTime: string;
/** 失败原因(ok=false 时有值,可直接展示给用户) */
error: string;
}
/** 服务端 `POST /api/v1/login` 的响应形状 */
interface FbApiLoginData {
token?: string;
expires_at?: string | null;
/** 令牌有效期(小时)。v57 起服务端一律 24(滑动续期) */
ttl_hours?: number;
/** 服务端当前时间(ISO8601)。端上用它记"上次校验成功的时间" */
server_time?: string;
person?: FbApiLoginPerson;
family?: FbApiLoginFamily;
}
interface FbApiLoginPerson {
id?: number;
name?: string;
role?: string;
}
interface FbApiLoginFamily {
id?: number;
name?: string;
}
interface FbApiEnvelope {
ok?: boolean;
error?: string;
code?: string;
data?: FbApiLoginData;
}
class FbLoginClientImpl {
/**
* 登录:账号(手机号或用户名)+ 密码 → 令牌。
*
* ⚠️ 超时给 15 秒(与 `fbApi` 一致):登录要过密码哈希(服务端故意加了延迟),
* 比普通 GET 慢,但也不该让用户干等。
*/
async login(account: string, password: string): Promise<FbLoginResult> {
const base: string = fbApiBase();
if (base === '') {
return fail('还没有设置服务器地址');
}
const url: string = base + '/api/v1/login';
/* v57(用户 2026-10-17):登录必须带上**设备身份**。
「我们在请求服务端的时候,就需要把我们的客户端的这个设备信息,
就是我的设备信息,提交到我们的服务器端。」
⇒ 服务端才能做到"一台设备一个会话、重登换新令牌、网页端能踢掉这一台"。
⚠️ `device_id` 是本地生成的 UUID(不是硬件号,见 FbDeviceStore 的说明);
`device_name` 是机型,用户可以在设置里改成人话("爸爸的手机")。 */
const body: string = JSON.stringify({
// 🔴 参数名用 `account`(服务端 v55 起认「手机号或用户名」);
// 老服务端只认 `phone` —— 所以**两个都带**,谁认识哪个用哪个。
account: account,
phone: account,
password: password,
device_id: FB_DEVICE_STORE.getDeviceId(),
device_name: FB_DEVICE_STORE.getDeviceName(),
platform: FB_DEVICE_STORE.platform(),
app_version: FB_APP_VERSION,
// ⚠️ `label` 只给"还没升级的旧服务端"看(v57 起服务端用 device_name);
// `days` 同理,v57 起服务端**不再读**它(一律 24h 滑动)。
label: '鸿蒙 App',
});
const req = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/json' },
extraData: body,
connectTimeout: 15000,
readTimeout: 15000,
// ⚠️ 与 `fbApi` 保持一致:默认 `result` 可能是 ArrayBuffer,
// 显式要 STRING 才能直接当 JSON 解析。
expectDataType: http.HttpDataType.STRING,
});
const text: string = typeof resp.result === 'string' ? resp.result : '';
return this.parse(resp.responseCode, text);
} catch (err) {
// ⚠️ 网络异常**不要**把原始错误直接抛给用户看(一串英文 exception),
// 给一句人话;详细原因进日志。
hilog.error(DOMAIN, TAG, '登录请求失败:%{public}s', JSON.stringify(err));
return fail('连不上服务器,请检查网络后重试');
} finally {
// ⚠️ 必须销毁:`http.createHttp()` 每次都会占用一个连接对象,
// 不 destroy 会持续泄漏(登录页反复重试时尤其明显)。
req.destroy();
}
}
/** 解析响应(服务端信封:{ok, data} / {ok:false, error}) */
private parse(status: number, text: string): FbLoginResult {
if (text === '') {
return fail('服务器没有返回内容(HTTP ' + status.toString() + ')');
}
let env: FbApiEnvelope;
try {
env = JSON.parse(text) as FbApiEnvelope;
} catch (err) {
hilog.error(DOMAIN, TAG, '登录响应不是 JSON:%{public}s', text.substring(0, 200));
return fail('服务器返回了无法识别的内容');
}
if (env.ok !== true) {
// ⚠️ 服务端给的 `error` 是**可以直接给用户看**的人话
// ("手机号或密码不对。" / "尝试太频繁了…"),原样透出即可。
const msg: string = env.error !== undefined && env.error !== '' ? env.error : '登录失败';
return fail(msg);
}
const d: FbApiLoginData | undefined = env.data;
const token: string = d !== undefined && d.token !== undefined ? d.token : '';
if (token === '') {
return fail('登录成功但没拿到令牌(服务端返回异常)');
}
const p: FbApiLoginPerson | undefined = d !== undefined ? d.person : undefined;
const f: FbApiLoginFamily | undefined = d !== undefined ? d.family : undefined;
const res: FbLoginResult = {
ok: true,
token: token,
expiresAt: d !== undefined && d.expires_at !== null && d.expires_at !== undefined
? d.expires_at : '',
personId: p !== undefined && p.id !== undefined ? p.id : 0,
personName: p !== undefined && p.name !== undefined ? p.name : '',
role: p !== undefined && p.role !== undefined ? p.role : '',
familyName: f !== undefined && f.name !== undefined ? f.name : '',
serverTime: d !== undefined && d.server_time !== undefined ? d.server_time : '',
error: '',
};
return res;
}
}
/** 造一个失败结果 */
function fail(msg: string): FbLoginResult {
const r: FbLoginResult = {
ok: false, token: '', expiresAt: '', personId: 0, personName: '',
role: '', familyName: '', serverTime: '', error: msg
};
return r;
}
/** 全局单例 */
export const FbLoginClient: FbLoginClientImpl = new FbLoginClientImpl();
+92
View File
@@ -0,0 +1,92 @@
/**
* 家书 FamilyBook · 页面数据接入辅助(鸿蒙端)
*
* 各模块页面的数据装配**都是同一套动作**:
* ① 页面出现 → ② 调仓库 `load()` → ③ 先拿缓存渲染、再拿网络刷新 → ④ 解析成端上类型。
* 这份文件把 ②④ 收成一处,页面只写"我关心哪个模块、怎么解析"。
*
* 🔴 为什么要抽(而不是每页各写一遍):
* 10 个模块 × 同一段 20 行样板 = 200 行重复代码,
* 而重复代码的代价不是"多打几个字",是**改一处漏九处**
* (本项目在别的地方已经吃过这个亏)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbModuleLoad, FbModuleName, FbModuleRepo } from './FbModuleRepo';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbModuleLoad';
/**
* 一次性加载(**直接给路径**,不必登记在上面的静态表里)。
*
* ⚠️ 给"两级接口"用(如账本明细 `/ledger/<id>/entries` —— 路径带 id,
* 没法在静态表里穷举)。缓存键也用完整路径,天然互不冲突。
*/
export async function loadPath<T>(cacheKey: string, path: string, parse: (json: string) => T,
sink: (data: T, fromCache: boolean) => void,
onNote?: (note: string) => void): Promise<void> {
await FbModuleRepo.inst().load(cacheKey, path, (r: FbModuleLoad): void => {
let data: T;
try {
data = parse(r.json);
} catch (err) {
hilog.error(DOMAIN, TAG, '解析 %{public}s 失败:%{public}s', cacheKey, JSON.stringify(err));
return;
}
if (onNote !== undefined && r.error !== '') {
onNote(r.error);
}
sink(data, r.fromCache);
});
}
/** 模块 → 接口路径 */
export const FB_MODULE_PATH: Record<string, string> = {
'home': '/api/v1/home',
'calendar': '/api/v1/calendar',
'days': '/api/v1/days',
'tasks': '/api/v1/tasks',
'trips': '/api/v1/trips',
'ledger': '/api/v1/ledger',
'resources': '/api/v1/resources',
'books': '/api/v1/books',
'notes': '/api/v1/notes',
'notifications': '/api/v1/notifications',
'kin': '/api/v1/kin',
};
/**
* 装配一个模块:读缓存 + 拉网络,每次拿到**解析后**的数据回调。
*
* @param name 模块名
* @param parse JSON → 端上类型的解析函数
* @param sink 数据到位回调(会被调 1~2 次:缓存一次、网络一次)
* @param onNote 可选:提示文案(同步失败/未登录时给页面显示一行小字)
*/
export async function loadModule<T>(name: FbModuleName, parse: (json: string) => T,
sink: (data: T, fromCache: boolean) => void,
onNote?: (note: string) => void): Promise<void> {
const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : '';
if (path === '') {
hilog.warn(DOMAIN, TAG, '模块 %{public}s 没有配路径', name);
return;
}
await FbModuleRepo.inst().load(name, path, (r: FbModuleLoad): void => {
// ⚠️ 解析失败**不能抛**:缓存/接口的形状变了(或确实是空),
// 抛出去会让页面白屏。这里给个空数组兜住,并留一条日志。
let data: T;
try {
data = parse(r.json);
} catch (err) {
hilog.error(DOMAIN, TAG, '解析 %{public}s 失败:%{public}s', name, JSON.stringify(err));
return;
}
if (onNote !== undefined && r.error !== '') {
onNote(r.error);
}
sink(data, r.fromCache);
});
}
+160
View File
@@ -0,0 +1,160 @@
/**
* 家书 FamilyBook · 模块数据仓库(鸿蒙端)
*
* 一份代码服务**所有**业务模块(日历/任务/纪念日/旅程/账本/资源/藏书/笔记/通知/首页)。
*
* 🔴 为什么不做成"每模块一个 Repo":
* 观影和名册各写一套是因为它们**有本地表、要落库、要做增量同步**。
* 而其余模块目前都是"**拉一次、展示、缓存 JSON**"——
* 为它们各写一个 `xxxRepo` 只会得到 10 份 90% 相同的代码。
* ⇒ 抽一个通用仓库:**请求 → 缓存到本地库(一张 `module_cache` 表)→ 给 UI 解析后的对象**。
* ⚠️ 将来某个模块要"离线可写/增量同步"(比如任务打勾、账本记账),
* 那时再把它单独拆出来落实体表 —— **不要提前抽象**。
*
* 数据流(与观影/名册同一套骨架):
* `load()` → 先给**本地缓存**(快、断网可用)→ 再拉接口 → 覆盖缓存 → 再给一次。
*/
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbModuleRepo';
/** 模块名(同时也是缓存键) */
export type FbModuleName =
| 'home' | 'calendar' | 'days' | 'tasks' | 'trips'
| 'ledger' | 'resources' | 'books' | 'notes' | 'notifications' | 'kin';
/** 一次加载的结果 */
export interface FbModuleLoad {
/** 原始 JSON 字符串(空 = 没有数据) */
json: string;
/** 这份数据来自本地缓存还是网络 */
fromCache: boolean;
/** 网络失败原因(空 = 没失败;来自缓存时也可能非空) */
error: string;
}
/** 加载回调(先缓存、后网络,会调两次) */
export type FbModuleSink = (r: FbModuleLoad) => void;
export class FbModuleRepo {
private static _inst: FbModuleRepo | null = null;
private db: FbDb = FbDb.inst();
static inst(): FbModuleRepo {
if (FbModuleRepo._inst === null) {
FbModuleRepo._inst = new FbModuleRepo();
}
return FbModuleRepo._inst;
}
/**
* 加载一个模块的数据。
*
* ⚠️ **先给缓存、再给网络**(两次回调):这是"本地优先"的关键 ——
* 等网络回来才渲染 = 转圈好几秒,断网则全白。
*
* ⚠️ 网络失败**不清缓存**、也不抛异常:把 `error` 交给调用方,
* 让它决定是"提示一行小字"还是"忽略"(多数情况应忽略,
* 因为用户手上还有缓存数据可用)。
*/
/**
* @param name 缓存键(**不限于预定义模块**:两级接口用完整路径当键,
* 如 'ledger_detail_1')。
*/
async load(name: string, path: string, sink: FbModuleSink): Promise<void> {
// ① 本地缓存(若还没读过本地库,`readCache` 会返回空串,不影响)
const cached: string = await this.readCache(name);
if (cached !== '') {
sink({ json: cached, fromCache: true, error: '' });
}
// ② 拉接口
const token: string = this.token();
if (token === '') {
// 未登录:只能给缓存(可能为空)
sink({ json: cached, fromCache: true, error: '未登录,显示的是本地数据' });
return;
}
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi(path, opts);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '加载 %{public}s 失败:%{public}s', name, res.error);
sink({ json: cached, fromCache: true, error: res.error });
return;
}
// ③ 写缓存 + 再给一次
await this.writeCache(name, res.data);
sink({ json: res.data, fromCache: false, error: '' });
}
/** 读缓存 */
private async readCache(name: string): Promise<string> {
try {
if (!this.db.isReady()) {
return '';
}
const row: FbRow | null = await this.db.queryOne(
'SELECT payload FROM module_cache WHERE name = ?', [name]);
return row !== null ? row.strOr('payload', '') : '';
} catch (err) {
hilog.warn(DOMAIN, TAG, '读缓存 %{public}s 失败:%{public}s', name, JSON.stringify(err));
return '';
}
}
/** 写缓存 */
private async writeCache(name: string, payload: string): Promise<void> {
if (payload === '') {
return;
}
try {
await this.db.exec(
'INSERT OR REPLACE INTO module_cache (name, payload, fetched_at) VALUES (?,?,?)',
[name, payload, Date.now()]);
} catch (err) {
hilog.warn(DOMAIN, TAG, '写缓存 %{public}s 失败:%{public}s', name, JSON.stringify(err));
}
}
/** 缓存时间(0 = 没有) */
async cachedAt(name: string): Promise<number> {
try {
const row: FbRow | null = await this.db.queryOne(
'SELECT fetched_at FROM module_cache WHERE name = ?', [name]);
return row !== null ? row.num('fetched_at', 0) : 0;
} catch (err) {
return 0;
}
}
/** 令牌(延迟取,避免仓库初始化时 Store 还没就绪) */
private token(): string {
// ⚠️ 这里直接 import 常量会形成循环依赖风险,改为运行时读
return FB_MODULE_TOKEN.get();
}
}
/**
* 令牌读取口(由 `FbTokenStore` 在启动时注入)。
*
* ⚠️ 为什么不直接 `import { FB_TOKEN_STORE }`:
* `FbTokenStore` 与仓库之间容易形成循环 import(它俩将来都会用到 `FbDb`)。
* 用一个**可注入的读取器**把依赖方向固定成"单向"。
*/
class FbModuleToken {
private reader: () => string = (): string => '';
set(fn: () => string): void {
this.reader = fn;
}
get(): string {
return this.reader();
}
}
export const FB_MODULE_TOKEN: FbModuleToken = new FbModuleToken();
+890
View File
@@ -0,0 +1,890 @@
/**
* 家书 FamilyBook · 观影仓库(鸿蒙端)
*
* 「接口 → 本地库 → UI」链路里的**枢纽**:
* · `sync()` —— 从接口拉数据、写进本地库(拉);
* · `list()` —— 从本地库读出来给 UI(**UI 只读这里,永不直接读接口**)。
*
* 🔴 这一层的存在意义(本地优先的核心):
* UI 调 `list()` 时**不关心网络**——有网没网都读同一份本地数据。
* 于是"断网可用"+"卡片零等待"这两件事是**天然成立**的,
* 而不是靠"缓存命中率"侥幸。
*
* 🔴 数据形状的真相(决定了这里的写法):
* 「观影」在服务端**不是一张表**,而是 6 张表组合出的视图:
* events(kind='movie') + movie_details + cinemas + movie_reviews
* + event_attendees + persons
* ⇒ 同步时要把这几张**分别落库**(按实体),读取时再 JOIN 拼回观影页要的样子。
* ⚠️ 别把接口返回的扁平结构直接存成一张 `movies` 表 ——
* 那样同一条日程会在「观影」和「日程」里各存一份、互相打架。
*/
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbMovieRepo';
/**
* 同步进度打点(**临时诊断用**,定位完删除)。
*
* ⚠️ 必须是**模块级的 `let`**(在 class **外面**):
* 同步会长时间不返回,需要知道它卡在哪一步。
* 这台设备的 hilog 缓冲区读不出内容(本项目多次确认),
* 所以用"全局变量 + 界面定时读取"的方式来观测。
* ⚠️ 它**不是** `@Local`,改它不会触发 UI 刷新 ——
* 界面那侧靠 setInterval 主动读、赋给自己的 @Local 才会刷新。
*/
export let FB_SYNC_PROGRESS: string = '';
/**
* 每批写入的行数。
*
* 🔴 取 20 是**实测**结果:一次塞 200 行时 `batchInsert` 卡死不返回;
* 分批到 20 行/批能正常完成。别随手改大 —— 大了就会重现卡死。
*/
const FB_BATCH_SIZE: number = 20;
/**
* 观影列表项(**UI 用的形状**,不是数据库形状)。
*
* ⚠️ 字段与服务端 `Movie::all()` 的返回对齐(已实测过真实响应),
* 这样从"接口 → 本地 → UI"一路不改名字,排查时一眼对得上。
*/
export interface FbMovieListItem {
id: number;
title: string;
/** 观影时间('YYYY-MM-DD HH:mm:ss') */
startsAt: string;
endsAt: string;
note: string;
/** 海报相对键(没有则空串,UI 画占位) */
posterKey: string;
cinemaName: string;
cinemaColor: string;
/** 同看人数 */
attendeeCount: number;
/** 评价条数 */
reviewCount: number;
/** 平均分(无人评价时为 0) */
reviewAvg: number;
}
/** 同步结果(供 UI 显示"刚更新了几条") */
/**
* 一家影院(观影模块的一部分,不是独立模块)。
*
* ⚠️ 与 Web 端一致:「影院」**不占抽屉里的位置** —— 它是观影功能的子页面。
*/
export interface FbCinemaItem {
id: number;
/** 影院名,如「横店电影城」 */
name: string;
/** 场所,如「东明县京都大润发」 */
place: string;
address: string;
/** 主题色(Web 端每家影院一个色,便于在列表里区分) */
color: string;
/** 在这家看过几部 */
movieCount: number;
}
export interface FbSyncResult {
ok: boolean;
/** 拉到并写进本地的条数 */
saved: number;
/** 失败原因(ok=false 时有值) */
error: string;
}
/** 观影详情(点卡片后展示) */
export interface FbMovieDetail {
ok: boolean;
error: string;
id: number;
title: string;
startsAt: string;
endsAt: string;
note: string;
/** 剧情简介(movie_details.info) */
info: string;
posterKey: string;
/** 票根图(movie_details.ticket_key) */
ticketKey: string;
cinemaName: string;
cinemaPlace: string;
cinemaAddress: string;
cinemaColor: string;
/** 我的评分(0 = 没评)。⚠️ 本版恒为 0:详情接口不返回"我的评价" */
myRating: number;
/** 我的评价正文。⚠️ 同上 */
myReview: string;
/**
* 全体评价条数 / 平均分。
*
* ⚠️ **详情接口不返回这两个值**(实测确认:那是列表侧
* `MovieReview::attachStats()` 算出来附上去的)。
* ⇒ 由调用方从**列表项**(本地库里存的)带进来,见 `detail(id, token, listItem)`。
* 详情页要显示"评分"时用这个,别指望接口给。
*/
reviewCount: number;
reviewAvg: number;
/** 同看的人 */
attendees: FbMovieAttendeeRow[];
/** 照片(相对键) */
photos: string[];
}
/**
* 观影仓库。
*
* ⚠️ 与 FbDb 一样用单例:仓库无状态(数据都在库里),
* 但保持单例便于将来加内存缓存时行为一致。
*/
export class FbMovieRepo {
private db: FbDb = FbDb.inst();
/**
* 从服务端同步观影数据到本地库。
*
* 流程:拉接口 → 拆信封 → **事务内**分别写入 events / movie_details /
* cinemas / persons / event_attendees → 更新水位线。
*
* ⚠️ 写库必须包在事务里(见 FbDb.transaction 的说明):
* 半途失败会留下"写了一半"的库,而水位线一旦推进,
* 缺的那部分就永远不会再被拉下来。
*
* ⚠️ 影院(cinemas)走**独立接口** `/api/v1/cinemas`,不从观影列表里推:
* 实测观影列表只返回 `cinema_name` / `cinema_color`(给列表显示用),
* **不返回 `cinema_id`** —— 没有主键就建不了 cinemas 表。
* (`cinema_id` 只在**观影详情** `Movie::find()` 里返回。)
* ⇒ 影院是独立的实体,就该用它的独立接口同步。
*
* @param token API 令牌
*/
async sync(token: string): Promise<FbSyncResult> {
// ---- ① 拉影院(先拉,供观影行的影院名反查 id)----
// ⚠️ 对象字面量必须有显式类型(ArkTS 的 arkts-no-untyped-obj-literals):
// 写在调用实参位置的裸字面量不会靠参数类型"渗入"推断。
FB_SYNC_PROGRESS = '1/6 拉取影院…';
const cinemaOpts: FbApiOptions = { method: 'GET', token: token };
const cinemaRes: FbApiResult = await fbApi('/api/v1/cinemas', cinemaOpts);
let cinemas: FbCinemaRow[] = [];
if (cinemaRes.ok) {
cinemas = parseCinemaRows(cinemaRes.data);
} else {
hilog.warn(DOMAIN, TAG, '影院同步失败(不影响观影):%{public}s', cinemaRes.error);
}
// ---- ② 拉观影列表 ----
FB_SYNC_PROGRESS = '2/6 拉取观影列表(影院 ' + cinemas.length.toString() + ' 家)…';
const movieQuery: Record<string, string> = { 'per_page': '200', 'page': '1' };
const movieOpts: FbApiOptions = { method: 'GET', query: movieQuery, token: token };
const res: FbApiResult = await fbApi('/api/v1/movies', movieOpts);
if (!res.ok) {
FB_SYNC_PROGRESS = '2/6 拉取失败:' + res.error;
const bad: FbSyncResult = { ok: false, saved: 0, error: res.error };
return bad;
}
const movies: FbMovieRow[] = parseMovieRows(res.data);
FB_SYNC_PROGRESS = '3/6 已解析 ' + movies.length.toString() + ' 条,准备落库…';
// ---- ③ 落库(一个事务内批量写完)----
// 🔴 这里必须用**同步**的 batchInsertSync / executeSync,且事务回调**不能 async**。
// 第一次写成 `await this.db.transaction(async () => { await batchInsert(...) })`,
// 结果是**同步函数永不返回**(界面卡在"正在同步…",实测 97 秒仍未结束),
// 而数据**其实已经写进库了** —— 典型的"事务跨 await 丢上下文"。
// 详见 FbDb.transaction() 的说明。
FB_SYNC_PROGRESS = '4/6 组装数据…';
let saved: number = 0;
// ---- 组装(纯内存操作,不碰数据库)----
const cinemaRows: relationalStore.ValuesBucket[] = [];
const cinemaIdByName: Map<string, number> = new Map<string, number>();
for (const c of cinemas) {
cinemaRows.push(this.cinemaBucket(c));
if (c.name !== '') {
cinemaIdByName.set(c.name, c.id);
}
}
const eventRows: relationalStore.ValuesBucket[] = [];
const detailRows: relationalStore.ValuesBucket[] = [];
const personRows: relationalStore.ValuesBucket[] = [];
const attendeeRows: relationalStore.ValuesBucket[] = [];
const now: number = Date.now();
for (const m of movies) {
const cid: number = cinemaIdByName.get(m.cinemaName) ?? 0;
eventRows.push(this.eventBucket(m, now));
detailRows.push(this.detailBucket(m, cid, now));
for (const a of m.attendees) {
personRows.push(this.personBucket(m, a, now));
attendeeRows.push(this.attendeeBucket(m, a, now));
}
saved++;
}
// ---- 写库 ----
// 🔴🔴 这里**刻意不用显式事务**,原因记在下面(很重要的一个踩坑结论)。
//
// 排查过程:
// · 第一版(逐条 `await exec`):数据能写进去,但**极慢**(1~2 分钟),
// 且 `sync()` 不返回;
// · 第二版(老事务 API `beginTransaction` + `commit`):
// 出现**诡异矛盾** —— 界面显示"共 200 部"(数据在),
// 但 `SELECT COUNT(*)` = **0**,且 Promise 永不返回。
// ⇒ 老事务绑在调用栈上,中间有 `await` 就丢上下文,
// `commit()` 变空操作,数据留在未提交状态。
// · 第三版(`createTransaction()` 对象 + `batchInsertSync`):
// **卡在 createTransaction 本身**,几分钟不返回;
// · 第四版(`createTransaction()` 对象 + `await batchInsert`):
// 同样卡住。
// ⇒ 结论:**这些事务 API 在本机 SDK/设备组合上不可用**(至少我未能找到可用形态)。
// 继续硬啃事务的性价比很低 —— 于是**改成无事务的批量写入**:
// `batchInsert` 本身就把 N 次插入合成一次调用,**原子性由 SQLite 单条语句保证**
// (一条 INSERT ... VALUES (...),(...),(...) 要么全成功要么全失败)。
// ⚠️ 代价:跨表(events / movie_details / …)不再有整体原子性。
// 但这里的写入是**幂等**的(`INSERT OR REPLACE` 语义 + 每次都全量重拉),
// 所以"写到一半崩了"只会导致下次同步重来,不会产生脏数据。
// **幂等 + 全量重拉,比事务更适合这里**(见下方说明)。
//
// ⚠️🔴 **一次 `batchInsert` 不能塞太多行**:
// 实测把 200 行一次交给 `batchInsert`,**它不返回**(几分钟卡死),
// 进度停在"5/6 写入库(ev 200 / 详情 200 / 人 412 / 同看 412)…"
// —— 数据组装完全正确,就是这一次调用回不来。
// (`batchInsertSync` 同样卡,两者都试过。)
// ⇒ 改成**分小批**(每批 `BATCH` 行)+ 每批之间 `await` 一次让出主线程。
// 分批同时解决了"单次调用数据量过大"和"长时间霸占主线程"两个问题。
// ⚠️ 代价:跨批不再有原子性。但本流程是**幂等 + 全量重拉**
// (`INSERT OR REPLACE`、每次都从头拉 200 条),
// "写到一半崩了"只会让下次同步重来,不会产生脏数据。
FB_SYNC_PROGRESS = '5/6 写入库(ev ' + eventRows.length.toString() +
' / 详情 ' + detailRows.length.toString() +
' / 人 ' + personRows.length.toString() +
' / 同看 ' + attendeeRows.length.toString() + ')…';
const store: relationalStore.RdbStore = this.db.require();
await this.insertInBatches(store, 'cinemas', cinemaRows);
// ⚠️ 顺序:events 是主表,其余引用它的 id(便于排查,非外键强制)
await this.insertInBatches(store, 'events', eventRows);
FB_SYNC_PROGRESS = '5/6 events 已写入(' + eventRows.length.toString() + ' 条)…';
await this.insertInBatches(store, 'movie_details', detailRows);
// 🔴 `persons` 是跨模块共用表(名册 / 观影都写),不能先清空
// ⇒ 靠 REPLACE 语义更新(默认 batchInsert 在 PK 冲突时是 IGNORE,
// 已存在的人永远不更新)。详见 FbPersonRepo 里的说明。
await this.insertInBatches(store, 'persons', personRows, true);
await this.insertInBatches(store, 'event_attendees', attendeeRows);
FB_SYNC_PROGRESS = '6/6 写入完成,更新水位线…';
await this.setWatermark('events', nowIso());
await this.setWatermark('cinemas', nowIso());
hilog.info(DOMAIN, TAG, '观影同步完成:影片 %{public}d 条、影院 %{public}d 家',
saved, cinemas.length);
const done: FbSyncResult = { ok: true, saved: saved, error: '' };
return done;
}
/**
* 分批批量插入。
*
* 🔴 为什么要分批(很重要的实测结论):
* 把 200 行一次交给 `batchInsert` / `batchInsertSync`,**两者都不返回**
* (2032-10-12 实测卡死几分钟,进度停在写入那一步)。
* 分批后每批只有 `BATCH_SIZE` 行,实测能正常完成。
*
* ⚠️ 每批之间 `await` 一次:既是 `batchInsert` 本身要 await,
* 也顺便把主线程让出去,避免长时间独占(UI 不掉帧)。
*
* @param table 目标表
* @param rows 待写入的行(可为空 → 直接返回)
*/
private async insertInBatches(store: relationalStore.RdbStore, table: string,
rows: relationalStore.ValuesBucket[], replace: boolean = false): Promise<void> {
if (rows.length === 0) {
return;
}
for (let i = 0; i < rows.length; i += FB_BATCH_SIZE) {
const chunk: relationalStore.ValuesBucket[] = rows.slice(i, i + FB_BATCH_SIZE);
if (replace) {
await store.batchInsertWithConflictResolution(
table, chunk, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
} else {
await store.batchInsert(table, chunk);
}
}
}
/** 组装一家影院的 ValuesBucket */
private cinemaBucket(c: FbCinemaRow): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['id'] = c.id;
b['family_id'] = c.familyId;
b['place'] = c.place;
b['name'] = c.name;
b['address'] = c.address;
b['color'] = c.color;
b['created_at'] = c.createdAt;
b['updated_at'] = c.updatedAt;
b['deleted_at'] = c.deletedAt;
b['synced_at'] = Date.now();
b['dirty'] = 0;
return b;
}
/**
* 组装观影记录 → events 表的 bucket。
*
* ⚠️ 写成「列名 → 值」的映射(`ValuesBucket`)而不是 SQL 占位符:
* `batchInsert` 要的就是这个形式,不必再拼 SQL 字符串。
* ⚠️ 用对象下标赋值(`b['id'] = ...`)而不是 `b.id = ...`:
* `ValuesBucket = Record<string, ValueType>`,写属性名在 ArkTS 里
* 会被当成"访问未声明属性"报错(arkts-no-props-by-index 的镜像规则)。
*/
private eventBucket(m: FbMovieRow, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['id'] = m.id;
b['family_id'] = m.familyId;
b['creator_id'] = m.creatorId;
b['kind'] = 'movie';
b['title'] = m.title;
b['starts_at'] = m.startsAt;
b['ends_at'] = m.endsAt;
b['location'] = m.location;
b['note'] = m.note;
b['source_ref'] = m.sourceRef;
b['created_at'] = m.createdAt;
b['updated_at'] = m.updatedAt;
b['deleted_at'] = m.deletedAt;
b['review_count'] = m.reviewCount;
b['review_avg'] = m.reviewAvg;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/** 组装观影详情 → movie_details 的 bucket */
private detailBucket(m: FbMovieRow, cinemaId: number, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['event_id'] = m.id;
b['info'] = m.movieInfo;
b['poster_key'] = m.posterKey;
b['ticket_key'] = m.ticketKey;
b['cinema_id'] = cinemaId;
b['douban_meta'] = m.doubanMeta;
b['updated_at'] = m.updatedAt;
b['deleted_at'] = m.deletedAt;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/** 组装同看人 → persons 的 bucket */
private personBucket(m: FbMovieRow, a: FbMovieAttendeeRow, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['id'] = a.id;
b['family_id'] = m.familyId;
b['kind'] = 'member';
b['name'] = a.name;
b['nickname'] = a.myNickname;
b['gender'] = '';
b['birthday'] = '';
b['avatar'] = a.avatar;
b['note'] = '';
b['created_at'] = '';
b['updated_at'] = nowIso();
b['deleted_at'] = null;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/**
* 组装同看关系 → event_attendees 的 bucket。
*
* ⚠️ 接口里 `attendees[].id` 实测是 **persons.id**(服务端 SQL 是
* `SELECT a.event_id, p.id, ...`),**不是**这张关联表自己的行 id
* (接口根本没返回那个)。
* ⇒ 本地这张表用**复合主键 (event_id, person_id)**(见 FbSchema),
* 这里只填两个外键。
*/
private attendeeBucket(m: FbMovieRow, a: FbMovieAttendeeRow, now: number): relationalStore.ValuesBucket {
const b: relationalStore.ValuesBucket = {};
b['event_id'] = m.id;
b['person_id'] = a.id;
b['position'] = 0;
b['updated_at'] = nowIso();
b['deleted_at'] = null;
b['synced_at'] = now;
b['dirty'] = 0;
return b;
}
/**
* 拉一条观影的**详情**(点卡片时用)。
*
* 🔴 为什么详情要**单独拉接口**、不直接用本地库拼:
* 列表接口只给了"列表要显示的那几个字段"(片名/时间/影院/评分汇总)。
* 详情还要:剧情简介、票根、影院地址、**我的评价正文**、照片列表
* —— 这些列表接口都没返回(列表塞这些会让响应大很多)。
* ⇒ 列表读本地(快、断网可看),详情读接口(要什么拉什么)。
*
* ⚠️ 详情**不落库**:它是一次性展示,且含"我的评价"这种**因人而异**的数据,
* 落库反而要处理"这条评价属于谁"。
*/
async detail(id: number, token: string, listItem?: FbMovieListItem): Promise<FbMovieDetail> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/movies/' + id.toString(), opts);
if (!res.ok) {
return emptyDetail(id, res.error);
}
const d: FbMovieDetail = parseDetail(id, res.data);
// ⚠️ 评分汇总接口不给,从列表项带过来(见 FbMovieDetail.reviewCount 的说明)
if (listItem !== undefined) {
d.reviewCount = listItem.reviewCount;
d.reviewAvg = listItem.reviewAvg;
}
return d;
}
/**
* 从本地库读观影列表(**UI 的唯一数据来源**)。
*
* 🔴 这就是"本地优先"落到代码上的样子:读的是本地库,
* 没有网络请求、没有 loading 转圈。
*
* JOIN 三张表把观影页要的形状拼出来(与服务端 Movie::all 的语义一致):
* events(kind='movie') + movie_details + cinemas
*/
async list(limit: number = 100, keyword: string = ''): Promise<FbMovieListItem[]> {
const kw: string = keyword.trim();
let sql: string =
'SELECT e.id AS id, ' +
' e.title AS title, ' +
' e.starts_at AS starts_at, ' +
' e.ends_at AS ends_at, ' +
' e.note AS note, ' +
' d.poster_key AS poster_key, ' +
' c.name AS cinema_name, ' +
' c.color AS cinema_color, ' +
' (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id' +
' AND a.deleted_at IS NULL) AS attendee_count, ' +
' e.review_count AS review_count, ' +
' e.review_avg AS review_avg ' +
' FROM events e ' +
' LEFT JOIN movie_details d ON d.event_id = e.id ' +
' LEFT JOIN cinemas c ON c.id = d.cinema_id ' +
" WHERE e.kind = 'movie' AND e.deleted_at IS NULL ";
const args: relationalStore.ValueType[] = [];
if (kw !== '') {
sql += ' AND (e.title LIKE ? OR c.name LIKE ?) ';
args.push('%' + kw + '%', '%' + kw + '%');
}
sql += ' ORDER BY e.starts_at DESC, e.id DESC LIMIT ? ';
args.push(limit);
const rows: FbRow[] = await this.db.queryRows(sql, args);
const out: FbMovieListItem[] = [];
for (const r of rows) {
out.push({
id: r.num('id', 0),
title: r.strOr('title', ''),
startsAt: r.strOr('starts_at', ''),
endsAt: r.strOr('ends_at', ''),
note: r.strOr('note', ''),
posterKey: r.strOr('poster_key', ''),
cinemaName: r.strOr('cinema_name', ''),
cinemaColor: r.strOr('cinema_color', ''),
attendeeCount: r.num('attendee_count', 0),
reviewCount: r.num('review_count', 0),
reviewAvg: r.num('review_avg', 0)
});
}
return out;
}
/** 本地已有的观影条数(判断"要不要首次同步"用) */
async localCount(): Promise<number> {
return this.db.scalar("SELECT COUNT(*) FROM events WHERE kind = 'movie' AND deleted_at IS NULL");
}
/** 读同步水位线 */
/**
* 影院列表(**从本地库读**)。
*
* 🔴 为什么从库里读、而不是现拉 `/api/v1/cinemas`:
* 影院表本来就在观影同步里一起落库了(`events` + `cinemas` 同一批),
* 现拉等于"已经有的数据再要一次"。
* ⚠️ 但如果本地是空的(没同步过),调用方应自己兜底 —— 这里不替你决定。
*/
async cinemaList(): Promise<FbCinemaItem[]> {
const rows: FbRow[] = await this.db.queryRows(
'SELECT id, name, place, address, color FROM cinemas'
+ ' WHERE deleted_at IS NULL ORDER BY name ASC', []);
const out: FbCinemaItem[] = [];
for (const r of rows) {
const it: FbCinemaItem = {
id: r.num('id', 0),
name: r.strOr('name', ''),
place: r.strOr('place', ''),
address: r.strOr('address', ''),
color: r.strOr('color', '') !== '' ? r.strOr('color', '') : '#7A4E8C',
// 这一家看过几部(从观影记录数)—— 与 Web 端影院页的口径一致
movieCount: await this.db.scalar(
'SELECT COUNT(*) FROM events e JOIN movie_details d ON d.event_id = e.id'
+ ' WHERE d.cinema_id = ? AND e.deleted_at IS NULL', [r.num('id', 0)]),
};
out.push(it);
}
return out;
}
async watermark(table: string): Promise<string> {
const row: FbRow | null = await this.db.queryOne(
'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]);
return row !== null ? row.strOr('last_sync_at', '') : '';
}
/** 写同步水位线(**只在整批都写成功后**才调用) */
private async setWatermark(table: string, at: string): Promise<void> {
await this.db.exec(
'INSERT OR REPLACE INTO sync_state (table_name, last_sync_at, last_ok_at) VALUES (?,?,?)',
[table, at, Date.now()]
);
}
// ---- 单例 ----
private static instance: FbMovieRepo | null = null;
static inst(): FbMovieRepo {
if (FbMovieRepo.instance === null) {
FbMovieRepo.instance = new FbMovieRepo();
}
return FbMovieRepo.instance;
}
}
/** 当前时间('YYYY-MM-DD HH:mm:ss',与服务端 DATETIME 同格式) */
function nowIso(): string {
const d: Date = new Date();
const p = (n: number): string => (n < 10 ? '0' + n : String(n));
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
// ============================================================
// 接口响应的解析(服务端 → 本地结构)
// ============================================================
/** 服务端 `Movie::all()` 的一行(原始 JSON 形状) */
interface FbApiMovieRow {
id?: number;
title?: string;
starts_at?: string;
ends_at?: string;
note?: string | null;
poster_key?: string | null;
cinema_name?: string | null;
cinema_color?: string | null;
attendee_count?: number;
review_count?: number;
review_avg?: number | null;
attendees?: FbApiAttendee[];
}
/** 接口里的一个同看人 */
interface FbApiAttendee {
event_id?: number;
id?: number;
name?: string;
avatar?: string | null;
avatar_color?: string | null;
my_nickname?: string | null;
}
/** 解析后的观影行(本地内部用,字段已转成强类型) */
interface FbMovieRow {
id: number;
familyId: number;
creatorId: number;
title: string;
startsAt: string;
endsAt: string;
location: string;
note: string;
sourceRef: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
movieInfo: string;
posterKey: string;
ticketKey: string;
/** ⚠️ 观影**列表**接口不返回它(只有详情接口返回)⇒ 这里由"影院名反查"补上 */
cinemaName: string;
cinemaColor: string;
doubanMeta: string;
reviewCount: number;
reviewAvg: number;
attendees: FbMovieAttendeeRow[];
}
/** 解析后的一家影院(对应 `Cinema::all()` 的行) */
interface FbCinemaRow {
id: number;
familyId: number;
place: string;
name: string;
address: string;
color: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
/** 接口里的一家影院(原始 JSON 形状,`c.*` + movie_count) */
interface FbApiCinemaRow {
id?: number;
family_id?: number;
place?: string;
name?: string;
address?: string | null;
color?: string;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
}
/** 解析后的同看人 */
export interface FbMovieAttendeeRow {
id: number;
name: string;
avatar: string;
/** ⚠️ 接口返回的原始头像色(`avatar_color`)—— 之前建表时没存,
* 浮层要显示头像色所以带上;本地库里没这一列,列表页不用它。
* 这里做成可选(`?`)避免影响已有的构造处。 */
avatarColor?: string;
myNickname: string;
}
/**
* 把接口 JSON 解析成内部行。
*
* ⚠️ 每一步都做**防御性判空**:服务端字段可能缺(老数据)、可能是 null。
* ArkTS 里 `obj.a.b` 只要中间一环是 undefined 就直接崩,
* 而崩在"解析接口响应"里,用户看到的是白屏、排查时看不出是哪个字段。
*/
function parseMovieRows(json: string): FbMovieRow[] {
const out: FbMovieRow[] = [];
if (json === '') {
return out;
}
let arr: FbApiMovieRow[] = [];
try {
arr = JSON.parse(json) as FbApiMovieRow[];
} catch (err) {
hilog.error(DOMAIN, TAG, '观影列表解析失败:%{public}s', JSON.stringify(err));
return out;
}
if (!Array.isArray(arr)) {
return out;
}
for (const m of arr) {
const id: number = numOf(m.id);
if (id <= 0) {
continue; // 没有 id 的行没法落库(主键)
}
out.push({
id: id,
familyId: 1, // 家庭固定 1(服务端 Api::me() 返回的那个)
creatorId: 0,
title: strOf(m.title),
startsAt: strOf(m.starts_at),
endsAt: strOf(m.ends_at),
location: '',
note: strOf(m.note),
sourceRef: '',
createdAt: '',
updatedAt: nowIso(),
deletedAt: null,
movieInfo: '',
posterKey: strOf(m.poster_key),
ticketKey: '',
cinemaName: strOf(m.cinema_name),
cinemaColor: strOf(m.cinema_color),
doubanMeta: '',
reviewCount: numOf(m.review_count),
reviewAvg: numOf(m.review_avg),
attendees: parseAttendees(m.attendees)
});
}
return out;
}
/** 解析影院列表(`/api/v1/cinemas`) */
function parseCinemaRows(json: string): FbCinemaRow[] {
const out: FbCinemaRow[] = [];
if (json === '') {
return out;
}
let arr: FbApiCinemaRow[] = [];
try {
arr = JSON.parse(json) as FbApiCinemaRow[];
} catch (err) {
hilog.error(DOMAIN, TAG, '影院列表解析失败:%{public}s', JSON.stringify(err));
return out;
}
if (!Array.isArray(arr)) {
return out;
}
for (const c of arr) {
const id: number = numOf(c.id);
if (id <= 0) {
continue;
}
out.push({
id: id,
familyId: numOf(c.family_id) > 0 ? numOf(c.family_id) : 1,
place: strOf(c.place),
name: strOf(c.name),
address: strOf(c.address),
color: strOf(c.color),
createdAt: strOf(c.created_at),
updatedAt: strOf(c.updated_at) !== '' ? strOf(c.updated_at) : nowIso(),
deletedAt: c.deleted_at !== undefined && c.deleted_at !== null ? c.deleted_at : null
});
}
return out;
}
/** 解析同看人列表 */
function parseAttendees(list?: FbApiAttendee[]): FbMovieAttendeeRow[] {
const out: FbMovieAttendeeRow[] = [];
if (list === undefined || !Array.isArray(list)) {
return out;
}
for (const a of list) {
const id: number = numOf(a.id);
if (id <= 0) {
continue;
}
out.push({
id: id,
name: strOf(a.name),
avatar: strOf(a.avatar),
// 头像色(接口的 `avatar_color`)—— 浮层显示头像要用
avatarColor: strOf(a.avatar_color),
myNickname: strOf(a.my_nickname)
});
}
return out;
}
/** 安全取字符串(null/undefined → 空串) */
function strOf(v: string | null | undefined): string {
return v !== null && v !== undefined ? v : '';
}
/** 安全取数字(null/undefined → 0) */
function numOf(v: number | null | undefined): number {
return v !== null && v !== undefined ? v : 0;
}
// ============================================================
// 观影详情的解析
// ============================================================
/** 服务端 `Movie::find()` 的原始 JSON 形状 */
interface FbApiMovieDetail {
id?: number;
title?: string;
starts_at?: string;
ends_at?: string;
note?: string | null;
info?: string | null;
poster_key?: string | null;
ticket_key?: string | null;
cinema_name?: string | null;
cinema_place?: string | null;
cinema_address?: string | null;
cinema_color?: string | null;
attendees?: FbApiAttendee[];
/**
* ⚠️ 照片是**对象数组**(`[{id, key}]`),不是字符串数组 ——
* 这是**实测**过的真实形状(读代码时我一度猜成 `string[]`,是错的)。
* 服务端 `Movie::photos()` 返回带 id 的对象,便于以后按 id 删除。
*/
photos?: FbApiPhoto[];
}
/** 详情里的一张照片 */
interface FbApiPhoto {
id?: number;
key?: string;
}
/** 空的详情(出错时用,避免调用方判 null) */
function emptyDetail(id: number, error: string): FbMovieDetail {
return {
ok: false, error: error, id: id, title: '', startsAt: '', endsAt: '', note: '',
info: '', posterKey: '', ticketKey: '', cinemaName: '', cinemaPlace: '',
cinemaAddress: '', cinemaColor: '', myRating: 0, myReview: '',
reviewCount: 0, reviewAvg: 0, attendees: [], photos: []
};
}
/**
* 解析详情响应。
*
* ⚠️ 每一步都要判空:详情字段很多是可选(老记录可能没填简介/票根/评价)。
* ArkTS 里 `obj.a.b` 中间任一环是 undefined 就崩,而崩在解析里
* 用户只看到白屏、排查时不知道是哪个字段。
*/
function parseDetail(id: number, json: string): FbMovieDetail {
if (json === '') {
return emptyDetail(id, '服务端没有返回详情');
}
let o: FbApiMovieDetail;
try {
o = JSON.parse(json) as FbApiMovieDetail;
} catch (err) {
hilog.error(DOMAIN, TAG, '观影详情解析失败:%{public}s', JSON.stringify(err));
return emptyDetail(id, '详情数据格式不对');
}
if (o === null || o === undefined) {
return emptyDetail(id, '详情是空的');
}
// 照片:接口给的是 `[{id, key}]`,这里只留相对键(界面按 key 拼图片地址)
const photos: string[] = [];
if (o.photos !== undefined && Array.isArray(o.photos)) {
for (const ph of o.photos) {
const k: string = strOf(ph.key);
if (k !== '') {
photos.push(k);
}
}
}
return {
ok: true,
error: '',
id: numOf(o.id) > 0 ? numOf(o.id) : id,
title: strOf(o.title),
startsAt: strOf(o.starts_at),
endsAt: strOf(o.ends_at),
note: strOf(o.note),
info: strOf(o.info),
posterKey: strOf(o.poster_key),
ticketKey: strOf(o.ticket_key),
cinemaName: strOf(o.cinema_name),
cinemaPlace: strOf(o.cinema_place),
cinemaAddress: strOf(o.cinema_address),
cinemaColor: strOf(o.cinema_color),
// ⚠️ 「我的评价」与「评分汇总」详情接口都**不返回**(实测确认)——
// 前者要拉子资源,后者由调用方从列表项带过来。
myRating: 0,
myReview: '',
reviewCount: 0,
reviewAvg: 0,
attendees: parseAttendees(o.attendees),
photos: photos
};
}
+864
View File
@@ -0,0 +1,864 @@
/**
* 家书 FamilyBook · 接口数据解析(鸿蒙端)
*
* 把服务端 `GET /api/v1/<模块>` 返回的 JSON 转成端上的展示类型(`model/Types`)。
*
* 🔴 为什么单独一个文件、而且要**照着真实响应写**:
* 服务端返回的是**数据库行**(几十个字段、命名是 SQL 风格:
* `starts_at` / `entry_count` / `is_public` / `deleted_at`…),
* 而端上 UI 要的是**为显示准备过的形状**(`dateRange` / `daysLeft` / `netCent`…)。
* 这层转换必须在端上做(服务端接口是给"通用 API 用户"的,
* 不该为了 App 的展示需要去改它)。
*
* ⚠️ 每个解析函数都**只依赖实测字段**,没实测过的字段不写。
* 想看形状就跑:`curl -H 'Authorization: Bearer <token>' .../api/v1/<模块>`
*/
import {
FbAccount, FbAnniversaryItem, FbBookItem, FbCalEvent, FbHomeData, FbKinChip,
FbKinGraph, FbKinPerson, FbMark,
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem,
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem,
} from '../model/Types';
// ============================================================
// 小工具
// ============================================================
/** 安全转字符串(null/undefined → '') */
export function s(v: string | null | undefined): string {
return v !== null && v !== undefined ? v : '';
}
/** 安全转数字 */
export function n(v: number | null | undefined): number {
return v !== null && v !== undefined ? v : 0;
}
/** 安全取布尔(服务端用 0/1 表示) */
export function b(v: number | boolean | null | undefined): boolean {
if (typeof v === 'boolean') {
return v;
}
return n(v as number) !== 0;
}
/** 取数组(不是数组就返回空数组) */
export function arr<T>(v: T[] | null | undefined): T[] {
return Array.isArray(v) ? v : [];
}
/** 从 'YYYY-MM-DD HH:mm:ss' 里取日期部分 */
export function datePart(v: string): string {
const t: string = s(v);
const i: number = t.indexOf(' ');
return i > 0 ? t.substring(0, i) : t;
}
/** 从 'YYYY-MM-DD HH:mm:ss' 里取时间部分 'HH:mm' */
export function timePart(v: string): string {
const t: string = s(v);
const i: number = t.indexOf(' ');
if (i <= 0) {
return '';
}
return t.substring(i + 1, i + 6);
}
/** 'YYYY-MM-DD' → 'MM-DD' */
export function monthDay(v: string): string {
const t: string = datePart(v);
return t.length >= 10 ? t.substring(5, 10) : t;
}
/** 把金额(分)转成中文金额文案 */
export function money(cent: number): string {
const yuan: number = cent / 100;
const abs: number = Math.abs(yuan);
const sign: string = yuan < 0 ? '-' : '';
if (abs >= 10000) {
return sign + (abs / 10000).toFixed(2) + ' 万';
}
return sign + abs.toFixed(2);
}
/**
* 造一个人物 chip。
*
* ⚠️ `FbKinChip` 的字段是 `{id, shown, kinship, avatarColor, isAdmin}` ——
* **没有** `name` / `avatar`。之前按"想当然"写会直接编译不过。
* ⇒ 映射前先确认目标类型的真实字段。
*/
export function chip(shown: string, color: string, id: number = 0): FbKinChip {
const c: FbKinChip = {
id: id,
shown: s(shown),
kinship: '',
avatarColor: s(color) !== '' ? s(color) : '#A84A28',
isAdmin: false,
};
return c;
}
// ============================================================
// 原始行类型(只列用到的字段)
// ============================================================
interface RawCalEvent {
cal?: number; color?: string; calname?: string; title?: string;
date?: string; time?: string; end_time?: string; all_day?: boolean | number;
kind?: string; ref_id?: number; location?: string; note?: string;
repeat?: string; occ?: string;
}
interface RawTrip {
id?: number; title?: string; starts_at?: string; ends_at?: string;
location?: string | null; note?: string | null; cover_key?: string | null;
summary?: string | null; is_public?: number; attendee_count?: number;
node_count?: number; cities?: string[]; city?: string;
}
interface RawDay {
id?: number; title?: string; kind?: string; repeat_type?: string;
month?: number; day?: number; is_leap?: number;
person_id?: number | null; person_name?: string | null;
source?: string; born?: string | null;
}
interface RawTask {
id?: number; title?: string; status?: string; board_id?: number; category_id?: number | null;
note?: string | null; due_at?: string | null; done_at?: string | null;
assignee_id?: number | null; assignee_name?: string | null;
avatar?: string | null; avatar_color?: string | null;
category_name?: string | null; child_total?: number; child_done?: number;
comment_count?: number; attach_count?: number;
/** 看板名(`byStatus` 会 JOIN 出来;首页任务列表要用) */
board_name?: string | null;
/**
* 看板**派生色**(服务端算好的,v54 新增)。
*
* 🔴 用户 2026-10-08 报过:「首页近期任务,任务的文字和前面的圆点,
* 颜色并没有跟看板颜色一致」。
* ⚠️ 不能读 `board_color` —— 看板没设色时它是 `null`,
* 要走按名字派生的那套(同一个看板名永远同一个色,用户才记得住)。
*/
board_color_calc?: string | null;
}
/** 首页日子标记的原始行(`{word, kind}`) */
interface RawMark {
word?: string; kind?: string;
}
/** 往年今天的原始行(服务端 `Home::memories()` 给的那几列) */
interface RawMemory {
id?: number; title?: string; starts_at?: string; kind?: string;
years_ago?: number; n?: number;
}
interface RawTaskBoard {
id?: number; name?: string; color?: string; tasks?: RawTask[];
}
interface RawLedgerBook {
id?: number; name?: string; my_perm?: string | null; entry_count?: number;
share_perm?: string | null;
}
interface RawLedgerEntry {
id?: number; ledger_id?: number; kind?: string; title?: string;
amount_cents?: number; note?: string | null;
person_id?: number | null; person_name?: string | null;
person_nick?: string | null; avatar?: string | null; avatar_color?: string | null;
updated_at?: string; created_at?: string;
}
interface RawLedgerSummary {
asset?: number; debt?: number; net?: number; count?: number;
}
interface RawResource {
id?: number; title?: string; content?: string; owner_id?: number;
owner_name?: string | null; avatar?: string | null; avatar_color?: string | null;
owner_nick?: string | null; perm?: string | null;
created_at?: string; updated_at?: string;
}
interface RawBook {
id?: number; title?: string; author?: string | null; status?: string | null;
rating?: number | null; progress?: number | null; note_count?: number | null;
cover_key?: string | null; category?: string | null; publisher?: string | null;
}
interface RawNote {
id?: number; title?: string; is_shared?: number; updated_at?: string;
author_name?: string | null; avatar?: string | null; avatar_color?: string | null;
is_mine?: number; blocks?: RawNoteBlock[];
}
interface RawNoteBlock {
id?: number; kind?: string; content?: string; position?: number;
}
interface RawNotification {
id?: number; title?: string; body?: string; created_at?: string;
read_at?: string | null; actor_name?: string | null;
avatar?: string | null; avatar_color?: string | null;
}
// ============================================================
// 各模块解析
// ============================================================
/** 日历条目 → FbCalEvent */
export function parseCalendar(json: string): FbCalEvent[] {
return parseCalRows(asArray<RawCalEvent>(json));
}
/** 日历行的实际转换(`parseHome` 也要用,所以单独一个数组版) */
function parseCalRows(rows: RawCalEvent[]): FbCalEvent[] {
const out: FbCalEvent[] = [];
for (const r of rows) {
const e: FbCalEvent = {
id: n(r.ref_id),
// 🔴 v52:补上真实日历本 id / 结束时间 / 类型 / 备注
// (接口本来就给了,旧解析漏掉 → 新建日程没法指定往哪本写)
calId: n(r.cal),
title: s(r.title),
date: s(r.date),
time: s(r.time),
endTime: s(r.end_time),
calName: s(r.calname),
color: s(r.color) !== '' ? s(r.color) : '#A84A28',
allDay: b(r.all_day),
place: s(r.location),
note: s(r.note),
kind: s(r.kind)
};
out.push(e);
}
return out;
}
/** 旅程列表 → FbTripItem */
export function parseTrips(json: string): FbTripItem[] {
const rows: RawTrip[] = asArray<RawTrip>(json);
const out: FbTripItem[] = [];
for (const r of rows) {
const cities: string[] = arr<string>(r.cities);
const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at));
const it: FbTripItem = {
id: n(r.id),
title: s(r.title),
// 副标题:城市 + 天数(与 Web 端 trips 列表口径一致)
sub: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
dateRange: dateRangeText(s(r.starts_at), s(r.ends_at), dayCount),
nodeCount: n(r.node_count),
doneCount: 0,
};
out.push(it);
}
return out;
}
/** 纪念日 → FbAnniversaryItem */
export function parseDays(json: string): FbAnniversaryItem[] {
const rows: RawDay[] = asArray<RawDay>(json);
const out: FbAnniversaryItem[] = [];
const today: Date = new Date();
for (const r of rows) {
const m: number = n(r.month);
const d: number = n(r.day);
const people: FbKinChip[] = [];
if (n(r.person_id) > 0) {
people.push(chip(s(r.person_name), '#A84A28', n(r.person_id)));
}
const it: FbAnniversaryItem = {
id: n(r.id),
title: s(r.title),
date: pad2(m) + '-' + pad2(d),
daysLeft: daysUntil(today, m, d),
kind: kindCn(s(r.kind)),
people: people,
yearly: s(r.repeat_type) !== 'once',
};
out.push(it);
}
// 按"距今天数"升序(最近的在前)——纪念日列表页的默认排序
out.sort((a: FbAnniversaryItem, c: FbAnniversaryItem): number => a.daysLeft - c.daysLeft);
return out;
}
/** 任务(看板)→ 平铺的 FbTaskItem[] */
export function parseTasks(json: string): FbTaskItem[] {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return [];
}
const boards: RawTaskBoard[] = arr<RawTaskBoard>(root['boards'] as RawTaskBoard[]);
const out: FbTaskItem[] = [];
for (const bd of boards) {
const tasks: RawTask[] = arr<RawTask>(bd.tasks);
for (const t of tasks) {
out.push(taskRow(t, s(bd.name), s(bd.color)));
}
}
return out;
}
/**
* 一行任务 → `FbTaskItem`(看板视图与首页列表**共用**)。
*
* 🔴 抽出来的理由:首页的 `/home` 直接给**扁平的任务数组**(它跨看板按状态取),
* 而任务页给的是"按看板分组"。两处各自写一遍转换 ⇒ 改一处漏一处
* (本项目已经吃过好几轮这个亏)。
*
* @param boardName 看板名(首页那批的看板名在行里的 `board_name`,由本函数内部兜底取)
* @param boardColor 看板色原文(可能为空 ⇒ 用派生色)
*/
function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem {
const due: string = s(t.due_at);
// 看板色优先级:行里算好的派生色 > 入参(看板视图给的)> 兜底
const calc: string = s(t.board_color_calc);
const bc: string = calc !== '' ? calc : (boardColor !== '' ? boardColor : '#A8574F');
const it: FbTaskItem = {
id: n(t.id),
title: s(t.title),
status: s(t.status) !== '' ? s(t.status) : 'todo',
board: boardName !== '' ? boardName : s(t.board_name),
boardColor: bc,
dueText: due !== '' ? datePart(due) : '',
overdue: due !== '' && s(t.status) !== 'done' && isBeforeToday(due),
assignee: s(t.assignee_name),
childDone: n(t.child_done),
childTotal: n(t.child_total),
};
return it;
}
/** 账本列表 → FbLedgerBook[](netCent 由 /ledger/<id> 的 summary 补) */
export function parseLedger(json: string): FbLedgerBook[] {
const rows: RawLedgerBook[] = asArray<RawLedgerBook>(json);
const out: FbLedgerBook[] = [];
for (const r of rows) {
const bk: FbLedgerBook = {
id: n(r.id),
name: s(r.name),
netCent: 0, // 概览接口不返回净资产,进详情页时再取 summary
myPerm: s(r.my_perm) !== '' ? s(r.my_perm) : 'read',
entryCount: n(r.entry_count),
};
out.push(bk);
}
return out;
}
/**
* 账本明细 → { 账目, 净资产 }。
*
* ⚠️ 明细要走**两级接口**:`/ledger`(有哪些本)→ `/ledger/<id>/entries`(本里的账目 + 汇总)。
* 净资产(`summary.net`)只在这第二级里有 —— 第一级不返回。
*/
export function parseLedgerEntries(json: string): FbLedgerDetail {
const root: Record<string, Object> | null = asObject(json);
const empty: FbLedgerDetail = { entries: [], netCent: 0, assetCent: 0, debtCent: 0 };
if (root === null) {
return empty;
}
const rows: RawLedgerEntry[] = arr<RawLedgerEntry>(root['items'] as RawLedgerEntry[]);
const sm: RawLedgerSummary = (root['summary'] !== undefined && root['summary'] !== null)
? root['summary'] as RawLedgerSummary : {};
const entries: FbLedgerEntry[] = [];
for (const r of rows) {
entries.push({
id: n(r.id),
name: s(r.title),
kind: s(r.kind),
amountCent: n(r.amount_cents),
// 「挂名的人」优先用昵称(我习惯叫他什么),没有就用本名
ownerName: s(r.person_nick) !== '' ? s(r.person_nick) : s(r.person_name),
updatedAt: datePart(s(r.updated_at)),
});
}
const d: FbLedgerDetail = {
entries: entries,
netCent: n(sm.net),
assetCent: n(sm.asset),
debtCent: n(sm.debt),
};
return d;
}
/**
* 家谱 → FbKinGraph。
*
* ⚠️ 服务端给的关系是**数组**(`parents`/`children`/`spouses`/`siblings`
* 全是 id 数组),端上直接用 —— 不重新推导。
* ⚠️ 亲属称谓(`label`)也**由服务端算**(`Kinship::label`),
* 端上**不重算**:那依赖整个家族图谱,两边算法不一致就会出现
* "App 说堂兄、网页说表兄"。
*/
export function parseKin(json: string): FbKinGraph {
const root: Record<string, Object> | null = asObject(json);
const empty: FbKinGraph = { meId: 0, people: [], total: 0 };
if (root === null) {
return empty;
}
const rows: Record<string, Object>[] =
arr<Record<string, Object>>(root['people'] as Record<string, Object>[]);
const people: FbKinPerson[] = [];
for (const r of rows) {
people.push({
id: n(r['id'] as number),
name: s(r['name'] as string),
shown: s(r['shown'] as string) !== '' ? s(r['shown'] as string) : s(r['name'] as string),
label: s(r['label'] as string),
gen: n(r['gen'] as number),
avatarColor: s(r['avatar_color'] as string),
unit: n(r['unit'] as number),
// ⚠️ 从 `Record<string, Object>` 里取值必须**显式断言**成目标类型 ——
// ArkTS 不会自动把 `Object` 收窄成 `number[]`(编译期报
// "Argument of type 'Object' is not assignable to 'number[]'")。
parents: idList(r['parents'] as number[]),
children: idList(r['children'] as number[]),
spouses: idList(r['spouses'] as number[]),
siblings: idList(r['siblings'] as number[]),
});
}
const g: FbKinGraph = {
meId: n(root['meId'] as number),
people: people,
total: n(root['total'] as number),
};
return g;
}
/** id 数组的容错转换(服务端可能给 null) */
function idList(v: number[] | null | undefined): number[] {
const xs: number[] = arr<number>(v);
const out: number[] = [];
for (const x of xs) {
out.push(n(x));
}
return out;
}
/** 资源 → FbResourceItem[] */
export function parseResources(json: string): FbResourceItem[] {
const rows: RawResource[] = asArray<RawResource>(json);
const out: FbResourceItem[] = [];
for (const r of rows) {
const it: FbResourceItem = {
id: n(r.id),
name: s(r.title),
// 资源没有"类别"字段(服务端的 content 是自由文本),
// ⇒ 用「谁的」来分类更贴近实际用法(Web 端列表也是按归属分组)
kind: 'other',
ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name),
fileCount: 0,
shared: s(r.perm) !== 'owner',
updatedAt: datePart(s(r.updated_at)),
};
out.push(it);
}
return out;
}
/** 藏书 → FbBookItem[] */
export function parseBooks(json: string): FbBookItem[] {
const rows: RawBook[] = asArray<RawBook>(json);
const out: FbBookItem[] = [];
for (const r of rows) {
const st: string = s(r.status) !== '' ? s(r.status) : 'want';
const it: FbBookItem = {
id: n(r.id),
title: s(r.title),
author: s(r.author),
status: st,
rate: n(r.rating),
noteCount: n(r.note_count),
// 服务端 progress 记的是"读到第几页"还是"百分比"不确定
// ⇒ 只信 status:读完就是 100,否则按 progress 原样(0~100 之外夹紧)
progress: st === 'done' ? 100 : clamp(n(r.progress), 0, 100),
};
out.push(it);
}
return out;
}
/** 笔记 → FbNoteItem[] */
export function parseNotes(json: string): FbNoteItem[] {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return [];
}
const rows: RawNote[] = arr<RawNote>(root['items'] as RawNote[]);
const out: FbNoteItem[] = [];
for (const r of rows) {
out.push({
id: n(r.id),
title: s(r.title),
excerpt: firstTextOf(r.blocks),
tags: [],
updatedAt: datePart(s(r.updated_at)),
shared: n(r.is_shared) !== 0,
attachCount: 0,
});
}
return out;
}
/** 通知 → FbNotificationItem[] */
export function parseNotifications(json: string): FbNotificationItem[] {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return [];
}
const rows: RawNotification[] = arr<RawNotification>(root['items'] as RawNotification[]);
const out: FbNotificationItem[] = [];
for (const r of rows) {
out.push({
id: n(r.id),
title: s(r.title),
body: s(r.body),
timeText: relativeTime(s(r.created_at)),
unread: s(r.read_at) === '',
from: chip(s(r.actor_name), s(r.avatar_color)),
});
}
return out;
}
/** 首页 → FbHomeData */
/**
* 首页 → FbHomeData(v54:服务端补齐了农历/标记/天气/周统计)。
*
* ⚠️ **不允许**再回落到端上的演示常量 —— 用户 2026-10-16 报过
* 「寒露、重阳节、儿子的生日,这些都是乱写的」,
* 根因就是那些字段接口没给、端上拿 DemoData 顶着。
* 接口没给时应当是**空**(页面就不画那一块),而不是编一个出来。
*/
export function parseHome(json: string): FbHomeData {
const root: Record<string, Object> | null = asObject(json);
const empty: FbHomeData = {
greeting: '', date: '', weekday: '', todayLabel: '', lunarShort: '', lunarLine: '',
marks: [], weather: null,
stat: { todayEvents: 0, doing: 0, todo: 0, overdue: 0 },
memories: [],
todayEvents: [], tasks: [], unread: 0,
};
if (root === null) {
return empty;
}
// ⚠️ ArkTS **不能**从 `unknown[]` 反推泛型(arkts-no-inferred-generic-params)
// ⇒ 一律显式写类型参数,别靠推断。
// ⚠️ 这里直接把原始数组喂给 parseCalendar 的"数组版",
// 比 `JSON.stringify` 再 parse 一次更快、也更不容易出错。
const rawEvents: RawCalEvent[] = arr<RawCalEvent>(root['today_events'] as RawCalEvent[]);
const rawTasks: RawTask[] = arr<RawTask>(root['tasks'] as RawTask[]);
const d: FbHomeData = {
greeting: s(root['greeting'] as string),
date: s(root['date'] as string),
weekday: s(root['weekday'] as string),
todayLabel: s(root['today_label'] as string),
lunarShort: s(root['lunar_short'] as string),
lunarLine: s(root['lunar_line'] as string),
marks: parseMarks(root['marks']),
weather: parseWeather(root['weather']),
stat: parseStat(root['stat']),
memories: parseMemories(root['memories']),
todayEvents: parseCalRows(rawEvents),
tasks: parseTaskRows(rawTasks),
unread: n(root['unread'] as number),
};
return d;
}
/** 日子标记 `[{word, kind}]`(坏行直接丢,不让它污染页面) */
export function parseMarks(v: Object | null | undefined): FbMark[] {
const rows: RawMark[] = arr<RawMark>(v as RawMark[]);
const out: FbMark[] = [];
for (const r of rows) {
const word: string = s(r.word);
if (word === '') {
continue;
}
const mk: FbMark = { word: word, kind: s(r.kind) !== '' ? s(r.kind) : 'anniv' };
out.push(mk);
}
return out;
}
/**
* 天气:服务端没配地点时给 `null` ⇒ 端上**整块不画**(与网页版一致)。
* ⚠️ `temp` 服务端已经带单位(`21°C`),原样承接,端上不再拼。
*/
export function parseWeather(v: Object | null | undefined): FbWeather | null {
if (v === null || v === undefined) {
return null;
}
const o: Record<string, Object> | null = asObjectOf(v);
if (o === null) {
return null;
}
const w: FbWeather = {
place: s(o['place'] as string),
temp: s(o['temp'] as string),
text: s(o['text'] as string),
icon: s(o['icon'] as string),
tone: s(o['tone'] as string) !== '' ? s(o['tone'] as string) : 'cloud',
wind: s(o['wind'] as string),
wdir: s(o['wdir'] as string),
tmax: s(o['tmax'] as string),
tmin: s(o['tmin'] as string),
};
// 温度都没有 ⇒ 当没取到(别画一个空壳)
return w.temp !== '' ? w : null;
}
/** 概述卡底部的四个数字(与网页版 `hb-stats` 同源) */
export function parseStat(v: Object | null | undefined): FbStat {
const o: Record<string, Object> | null = asObjectOf(v);
if (o === null) {
return { todayEvents: 0, doing: 0, todo: 0, overdue: 0 };
}
return {
todayEvents: n(o['today_events'] as number),
doing: n(o['doing'] as number),
todo: n(o['todo'] as number),
overdue: n(o['overdue'] as number),
};
}
/**
* 往年的今天 → `FbMemoryItem[]`(与电脑端 `Home::memories()` 同一份数据)。
*
* ⚠️ 字段是 SQL 风格(`starts_at` / `years_ago`),端上要驼峰 —— 所以有这一层。
*/
export function parseMemories(v: Object | null | undefined): FbMemoryItem[] {
const rows: RawMemory[] = arr<RawMemory>(v as RawMemory[]);
const out: FbMemoryItem[] = [];
for (const r of rows) {
const title: string = s(r.title);
if (title === '') {
continue;
}
const it: FbMemoryItem = {
id: n(r.id),
title: title,
startsAt: s(r.starts_at),
kind: s(r.kind) !== '' ? s(r.kind) : 'movie',
yearsAgo: n(r.years_ago),
};
out.push(it);
}
return out;
}
/** 一批任务行 → FbTaskItem[](首页与任务页共用同一段形状转换) */
export function parseTaskRows(rows: RawTask[]): FbTaskItem[] {
const out: FbTaskItem[] = [];
for (const t of rows) {
out.push(taskRow(t, '', ''));
}
return out;
}
// ============================================================
// 内部工具
// ============================================================
/** 解析成数组(失败给空数组) */
function asArray<T>(json: string): T[] {
if (json === '') {
return [];
}
try {
const v: Object = JSON.parse(json) as Object;
// 服务端信封是 {ok, data};但仓库已经把 data 传出来,这里两种都认
if (Array.isArray(v)) {
return v as T[];
}
const data = (v as Record<string, Object>)['data'];
if (Array.isArray(data)) {
return data as T[];
}
return [];
} catch (e) {
return [];
}
}
/**
* 把一个**已经是对象**的值转成 `Record<string, Object>`(不是对象则 null)。
*
* ⚠️ 与 `asObject()` 的区别:那个吃的是**整份 JSON 字符串**、还要往下钻 `data` 一层;
* 这个吃的是"已经从根对象里取出来的某个字段值"(如 `root['weather']`)。
* ArkTS 里 `as` 是编译期的,运行时 `null`/数组/标量 都能骗过它 ——
* 所以必须自己判一遍类型,别直接 `v as Record<...>`(那样标量上取键会拿到 undefined,
* 然后被当成"字段是空的",症状是"接口明明给了却显示为空")。
*/
function asObjectOf(v: Object | null | undefined): Record<string, Object> | null {
if (v === null || v === undefined) {
return null;
}
if (typeof v !== 'object' || Array.isArray(v)) {
return null;
}
return v as Record<string, Object>;
}
function asObject(json: string): Record<string, Object> | null {
if (json === '') {
return null;
}
try {
const v: Object = JSON.parse(json) as Object;
if (v === null || !Array.isArray(v) && typeof v === 'object') {
const o = v as Record<string, Object>;
const data = o['data'];
if (data !== undefined && data !== null && typeof data === 'object' && !Array.isArray(data)) {
return data as Record<string, Object>;
}
return o;
}
return null;
} catch (e) {
return null;
}
}
/** 两位补零 */
function pad2(v: number): string {
return v < 10 ? '0' + v.toString() : v.toString();
}
/** 夹紧 */
function clamp(v: number, lo: number, hi: number): number {
if (v < lo) {
return lo;
}
if (v > hi) {
return hi;
}
return v;
}
/** 两个时间之间差几天(同一天算 1 天) */
function daySpan(a: string, c: string): number {
const da: string = datePart(a);
const dc: string = datePart(c);
if (da === '' || dc === '') {
return 0;
}
const t1: number = Date.parse(da + 'T00:00:00');
const t2: number = Date.parse(dc + 'T00:00:00');
if (isNaN(t1) || isNaN(t2)) {
return 0;
}
return Math.max(1, Math.round((t2 - t1) / 86400000) + 1);
}
/** 时间范围文案:同一天显示 'M月D日';跨天显示 'M月D日 - M月D日' */
function dateRangeText(a: string, c: string, days: number): string {
const da: string = datePart(a);
const dc: string = datePart(c);
if (da === '') {
return '';
}
const ta: string = cnDate(da);
if (dc === '' || da === dc) {
return ta + (days > 1 ? ' · ' + days.toString() + ' 天' : '');
}
return ta + ' - ' + cnDate(dc) + ' · ' + days.toString() + ' 天';
}
/** 'YYYY-MM-DD' → 'M月D日' */
function cnDate(v: string): string {
if (v.length < 10) {
return v;
}
const m: number = parseInt(v.substring(5, 7), 10);
const d: number = parseInt(v.substring(8, 10), 10);
return m.toString() + '月' + d.toString() + '日';
}
/** 距下一个 (month, day) 还有几天(0 = 今天) */
function daysUntil(today: Date, month: number, day: number): number {
if (month <= 0 || day <= 0) {
return 9999;
}
const y: number = today.getFullYear();
let target: number = Date.UTC(y, month - 1, day);
const base: number = Date.UTC(y, today.getMonth(), today.getDate());
if (target < base) {
target = Date.UTC(y + 1, month - 1, day); // 今年已过 ⇒ 看明年
}
return Math.round((target - base) / 86400000);
}
/** 是否早于今天 */
function isBeforeToday(v: string): boolean {
const d: string = datePart(v);
if (d === '') {
return false;
}
const now: Date = new Date();
const today: string = now.getFullYear().toString() + '-'
+ pad2(now.getMonth() + 1) + '-' + pad2(now.getDate());
return d < today;
}
/** 相对时间文案 */
function relativeTime(v: string): string {
const t: number = Date.parse(s(v).replace(' ', 'T'));
if (isNaN(t)) {
return datePart(s(v));
}
const diff: number = Date.now() - t;
const min: number = Math.floor(diff / 60000);
if (min < 1) {
return '刚刚';
}
if (min < 60) {
return min.toString() + ' 分钟前';
}
const hr: number = Math.floor(min / 60);
if (hr < 24) {
return hr.toString() + ' 小时前';
}
const day: number = Math.floor(hr / 24);
if (day < 30) {
return day.toString() + ' 天前';
}
return datePart(s(v));
}
/** 纪念日类别中文 */
function kindCn(kind: string): string {
if (kind === '生日') {
return 'birthday';
}
if (kind === '节日') {
return 'holiday';
}
if (kind === '节气') {
return 'solar_term';
}
if (kind === '纪念日') {
return 'anniversary';
}
return kind;
}
/** 笔记第一条文本块的正文 */
function firstTextOf(blocks?: RawNoteBlock[]): string {
const bs: RawNoteBlock[] = arr<RawNoteBlock>(blocks);
for (const bl of bs) {
const c: string = s(bl.content).trim();
if (c !== '') {
return c.length > 60 ? c.substring(0, 60) + '…' : c;
}
}
return '';
}
+985
View File
@@ -0,0 +1,985 @@
/**
* 家书 FamilyBook · 名册仓库(鸿蒙端)
*
* 数据流与观影一致:「**接口 → 本地库 → UI**」(本地优先)。
* · `list()` 从**本地库**读(UI 的唯一数据来源,快、断网可看);
* · `sync()` 拉接口 → 落本地库 → 再读一次。
*
* 🔴 本仓库用的是 `GET /api/v1/roster`,**不是** `/api/v1/people`:
* 前者是「**我**名册里的人」(我加的 + 别人分享给我的),
* 后者是"全家的人"(本家 800+,是通讯录底账)。
* Web 端名册页用的也是前者(`Person::roster()`)。
* ⚠️ 用错接口的症状:名册里冒出 800 人,而且全是"别人的通讯录"。
*/
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbPersonRepo';
/**
* 每批写入的行数。
*
* ⚠️ 20 是**实测**值(与观影仓库同一个约束):一次塞几百行时
* `batchInsert` 会卡死不返回。别随手改大。
*/
const FB_BATCH_SIZE: number = 20;
/** 名册里一个人(UI 直接渲染的形状) */
export interface FbRosterPerson {
id: number;
/** 本名 */
name: string;
/** 我怎么叫他(昵称优先)—— 卡片主标题用这个 */
shown: string;
/** 我该怎么称呼他(如"父亲""妻子") */
label: string;
/** 标签(一个人可以有多个) */
tags: string[];
avatarColor: string;
avatar: string;
/** 阳历生日 'MM-DD' */
birthdaySolar: string;
/**
* 完整生日 `YYYY-MM-DD`(空 = 没录)。
*
* 🔴 为什么要**两个**生日字段:
* · `birthdaySolar` = `MM-DD`,给**列表/详情显示**用(不显示年份,避免"暴露年龄");
* · `birthdayFull` = `YYYY-MM-DD`,给**编辑**与**入库**用。
* ⚠️ 之前只存了 `MM-DD`,**把年份弄丢了** ——
* 结果"编辑生日"根本没法回填(输入框要 `YYYY-MM-DD`)。
* 这是一个真实的取整错误,不是设计取舍。
*/
birthdayFull: string;
/** 阴历生日(如"三月初七") */
birthdayLunar: string;
/** 手机号明文(服务端解密后给的;空 = 没录)。= `phones[0]` */
phone: string;
/**
* **全部电话**(主号在前,`person_contacts` 里的补充电话在后)。
*
* 🔴 为什么需要(用户 2026-10-15):
* 「一个人可能有多个电话号码……应该拨打最近的那个电话。」
* 实测 **161 人有多个号码**(如"杨永华"有 4 个:手机 + 座机 + 两个副号)
* ⇒ 只带主号的话,用户想打座机就没法从应用里发起拨号。
* ⚠️ 服务端保证「主号排第一」⇒ 端上"有主号打主号"就是取 `phones[0]`。
* ⚠️ 空数组表示这人一个号码都没录 ⇒ 点行时走"打开详情"而不是拨号。
*/
phones: string[];
/** 首字母("输入即筛选"用) */
initials: string;
org: string;
title: string;
slogan: string;
email: string;
addrCity: string;
gender: string;
note: string;
/** 是管理员(显示徽标) */
isAdmin: boolean;
// ============================================================
// 🔴 v52(用户 2026-10-15):**全字段**。
//
// 「我们现在这个应用打造的目的就是为了让用户可以完全脱离电脑端……
// 应该把所有的信息都显示出来,而且编辑的时候,所有的内容都应该可以编辑。」
//
// ⇒ 下面这些是**详情/编辑**才用得上的字段。
// 列表页不显示它们,但它们必须能取到 —— 否则"编辑"时回填不了、
// "详情"时看不到,就还是得回电脑端。
// ============================================================
/** 结构化地址:国家 / 省 / 市 / 区(详细街道在 `address`) */
addrCountry: string;
addrProvince: string;
addrDistrict: string;
/** 详细地址(街道门牌) */
address: string;
/** 家中排行(1 = 老大;空 = 没填) */
birthRank: string;
/** 农历生日的**闰月**标记 */
birthdayLunarLeap: boolean;
/**
* 农历生日的**完整日期** `YYYY-MM-DD`(空 = 没录)。
*
* 🔴 与 `birthdayLunar`("十月初五"这种**给人看的**月日)分开:
* · `birthdayLunar` —— 显示用(服务端算好的中文月日)
* · `birthdayLunarRaw` —— **编辑**用(要能回填进 `YYYY-MM-DD` 输入框)
* ⚠️ 只存中文月日就没法回填("十月初五"不是日期),
* 这正是"农历生日改不了"的原因。服务端在详情里给了完整值。
*/
birthdayLunarRaw: string;
/** 补充联系方式(电话/邮箱/地址/网址,各可多行) */
contacts: FbContacts;
/** 工作经历(可多段) */
jobs: FbJobRow[];
/**
* 「**我对他的称呼**」(只有我看得到;空 = 用姓名显示)。
*
* 🔴 用户 2026-10-15:「编辑信息的时候,应该有我对他的称呼才对呀。」
* 称呼是**视角属性**:妈妈叫"明明"、姐姐叫"小弟",各人不同
* ⇒ 存在服务端 `nicknames` 表(viewer × target),**不挂在被称呼者身上**。
* ⚠️ 与 `shown` 的区别:`shown` 是"显示名"(有称呼用称呼、没有用姓名),
* 编辑框要回填**原始称呼** ⇒ 反推不出来,必须有这个字段。
*/
myNickname: string;
/**
* 「我写的私人备注」。
*
* 🔴 与公共 `note` **不是一回事**:
* `note` = 大家的公共备注(谁都能看、谁都能改)
* `myNote`= **只有我**看得到的备注(我对这个人的印象/提醒)
* 两者在详情里**分两块显示**,别混成一条。
*/
myNote: string;
/** 身份证(**打码**过的,如 `372****4856`;编辑时重新输入才改) */
idCardMasked: string;
/** 当前登录者能不能改这人的全部字段(对齐服务端 `can_edit_all`) */
canEditAll: boolean;
/** 这人是不是"我" */
isSelf: boolean;
}
/** 名册同步结果 */
export interface FbRosterSyncResult {
ok: boolean;
saved: number;
error: string;
}
/** 一条补充联系方式(vCard 对齐:label 是"工作/家庭"这种说明,可空) */
export interface FbContactRow {
label: string;
value: string;
}
/** 补充联系方式(按类型分组) */
export interface FbContacts {
phone: FbContactRow[];
email: FbContactRow[];
address: FbContactRow[];
url: FbContactRow[];
}
/** 一段工作经历 */
export interface FbJobRow {
org: string;
title: string;
/** 职称(如 副教授) */
rankName: string;
/** 起始年月 `YYYY-MM`(空 = 没填) */
startYm: string;
/** 结束年月(**空 = 现在还在**,列表里显示为「现在」) */
endYm: string;
note: string;
}
/** 名册整体(人 + 标签云 + 我的 id) */
export interface FbRosterBundle {
people: FbRosterPerson[];
/** 标签云:[{tag, n}] */
tagCloud: FbTagCount[];
meId: number;
}
/** 标签云的一项 */
export interface FbTagCount {
tag: string;
n: number;
}
// ============================================================
// 接口原始形状(**按真实响应写**,字段名以实测为准)
// ============================================================
interface FbApiRosterPerson {
id?: number;
name?: string;
shown?: string;
label?: string;
tags?: string[];
avatar_color?: string | null;
avatar?: string | null;
/** 服务端给的是 `[['阳历','03-30'], ['农历','三月初七']]` 这种二元组数组 */
/** 完整生日 `YYYY-MM-DD HH:mm:ss`(服务端给年份;编辑要用) */
birthday?: string | null;
birthdays?: string[][];
phone_plain?: string | null;
/** 🔴 **全部电话**(主号在前 + 补充电话)。App 点人名拨号用 */
phones?: string[] | null;
initials?: string | null;
org?: string | null;
title?: string | null;
slogan?: string | null;
email?: string | null;
addr_city?: string | null;
gender?: string | null;
note?: string | null;
account_role?: string | null;
account_status?: string | null;
// ---- v52:单人详情接口(`GET /api/v1/people/<id>`)才有的字段 ----
/** 结构化地址(详情接口给;列表只给到 `addr_city`) */
addr_country?: string | null;
addr_province?: string | null;
addr_district?: string | null;
address?: string | null;
birth_rank?: number | string | null;
is_placeholder?: number | null;
/** 农历生日完整日期 `YYYY-MM-DD`(详情接口给;编辑要用) */
birthday_lunar?: string | null;
/** 补充联系方式,按类型分组;电话已解密 */
contacts?: Record<string, FbApiContactRaw[]> | null;
/** 工作经历(服务端列名是下划线风格) */
jobs?: FbApiJobRaw[] | null;
/** 「我写的私人备注」 */
my_note?: string | null;
/** 「我对他的称呼」(视角属性;空 = 用姓名显示) */
my_nickname?: string | null;
/** 身份证(打码) */
id_card_masked?: string | null;
/** 能不能改全部字段 */
can_edit_all?: boolean | null;
is_self?: boolean | null;
}
/** 详情接口里的一条补充联系方式 */
interface FbApiContactRaw {
label?: string | null;
value?: string | null;
}
/** 详情接口里的一段工作经历(列名是下划线) */
interface FbApiJobRaw {
org?: string | null;
title?: string | null;
rank_name?: string | null;
start_ym?: string | null;
end_ym?: string | null;
note?: string | null;
}
interface FbApiRosterPayload {
people?: FbApiRosterPerson[];
tagCloud?: FbApiTagItem[];
meId?: number;
}
interface FbApiTagItem {
tag?: string;
n?: number;
}
// ============================================================
export class FbPersonRepo {
private static _inst: FbPersonRepo | null = null;
private db: FbDb = FbDb.inst();
/** 单例(与服务端 `Person` 一样是纯静态工具,端上用单例便于将来加缓存) */
static inst(): FbPersonRepo {
if (FbPersonRepo._inst === null) {
FbPersonRepo._inst = new FbPersonRepo();
}
return FbPersonRepo._inst;
}
/**
* 拉名册接口 → 落本地库 → 返回同步结果。
*
* ⚠️ 与观影仓库同样的两条约束(都是实测踩出来的):
* ① **不用显式事务**(本机设备上 `createTransaction` / 老事务都卡死);
* ② `batchInsert` **分小批**(每批 20 行),一次塞几百行会卡死。
* 换取的是"幂等 + 全量重拉",对本场景足够。
*/
async sync(token: string): Promise<FbRosterSyncResult> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/roster', opts);
if (!res.ok) {
const bad: FbRosterSyncResult = { ok: false, saved: 0, error: res.error };
return bad;
}
const parsed: FbParsedRoster = parseRoster(res.data);
const meId: number = parsed.meId;
const now: number = Date.now();
// ---- 人员(persons 表;只补名册用到的字段)----
const personRows: relationalStore.ValuesBucket[] = [];
for (const p of parsed.people) {
const b: relationalStore.ValuesBucket = {};
b['id'] = p.id;
// ⚠️ family_id 接口不返回(名册行里没有)—— 本 App 只服务一个家庭,
// 填 0 不影响;真要取值应另拉 `/me`。这里**不猜**。
b['family_id'] = 0;
b['kind'] = 'member';
b['name'] = p.name;
b['nickname'] = p.shown !== p.name ? p.shown : null;
b['gender'] = p.gender;
// ⚠️ 存**完整**生日(`birthdayFull`),不是只有月日的 `birthdaySolar`
b['birthday'] = p.birthdayFull;
b['avatar'] = p.avatar;
b['note'] = p.note;
b['avatar_color'] = p.avatarColor;
b['email'] = p.email;
b['org'] = p.org;
b['title'] = p.title;
b['slogan'] = p.slogan;
b['address'] = p.addrCity; // 名册只给到城市
b['addr_city'] = p.addrCity;
b['updated_at'] = '';
b['synced_at'] = now;
b['dirty'] = 0;
personRows.push(b);
}
// ---- 名册关系(person_roster)----
const rosterRows: relationalStore.ValuesBucket[] = [];
for (let i = 0; i < parsed.people.length; i++) {
const p: FbRosterPerson = parsed.people[i];
const b: relationalStore.ValuesBucket = {};
b['owner_id'] = meId;
b['person_id'] = p.id;
b['family_id'] = 0;
b['group_id'] = 0;
// 顺序就用接口给的顺序(服务端 ORDER BY sort_order),保住用户的拖动排序
b['sort_order'] = i;
b['source'] = 'self';
b['updated_at'] = '';
b['synced_at'] = now;
// 🔴 全部电话(JSON 数组,主号在前)。见 FbRosterPerson.phones 的说明。
// ⚠️ 存 JSON 而不是建表:电话不做跨人聚合,整组读出来就够。
b['phones'] = JSON.stringify(p.phones);
rosterRows.push(b);
}
// ---- 标签(person_tags)----
const tagRows: relationalStore.ValuesBucket[] = [];
for (const p of parsed.people) {
for (const t of p.tags) {
const b: relationalStore.ValuesBucket = {};
b['owner_id'] = meId;
b['person_id'] = p.id;
b['tag'] = t;
b['updated_at'] = '';
b['synced_at'] = now;
tagRows.push(b);
}
}
const store: relationalStore.RdbStore = this.db.require();
// 🔴 顺序:先 persons(被引用者),再 roster / tags。
// 🔴🔴 **必须用 REPLACE 语义**(用户 2026-10-15 实测踩出来的真 bug):
// `persons` 是**跨模块共用**的表(名册、观影的"同看人"都写它),
// 它的行**不能"先清空再插"**(会连带删掉观影那边引用的人),
// 所以只能靠主键冲突来解决 —— 而普通的 `batchInsert` 在 PK 冲突时
// 是 **ON_CONFLICT_IGNORE 语义(什么都不做)**。
// 后果:**已存在的人永远不会被更新** ⇒
// 服务端后来补的 `email / 座右铭 / 地址 / 单位` 这些字段
// 在端上**永远是旧的(空)** —— 实测"杨永全"的详情浮层里
// "座右铭 / 邮箱 / 所在地"三行直接不显示(服务端明明有值)。
// ⇒ 用 `batchInsertWithConflictResolution(..., ON_CONFLICT_REPLACE)`。
// ⚠️ 该 API `@since 18`,本工程 compatibleSdkVersion 24,可用。
await this.insertInBatches(store, 'persons', personRows, true);
await this.insertInBatches(store, 'person_roster', rosterRows);
// ⚠️ 标签是**全量重拉**:先清掉我这个 owner 的旧标签再写新的。
// 否则"删掉一个标签"在本地永远消不掉(旧行还在)。
await store.executeSql('DELETE FROM person_tags WHERE owner_id = ?', [meId]);
await this.insertInBatches(store, 'person_tags', tagRows);
// ⚠️ 名册关系同样全量替换(服务端返回的就是"我名册的全部")
await store.executeSql('DELETE FROM person_roster WHERE owner_id = ?', [meId]);
await this.insertInBatches(store, 'person_roster', rosterRows);
// 🔴 **标签云以服务端下发的为准**(用户 2026-10-15 报"标签只有全部")。
//
// 为什么不能只靠本地 `GROUP BY person_tags` 聚合:
// · 它只能在**标签行都写成功**时才对;一旦写入环节有任何遗漏
// (批量插入失败、字段为空…),标签条就残了 —— 而用户看到的是
// "标签条只剩一个『全部』",根本无从判断是哪一环坏的;
// · 服务端 `/roster` **本来就返回了权威的 `tagCloud`**
// (实测 15 个:大学同学 55 / 同事 40 / 实验室 23 …),
// 且与网页端同源 ⇒ 用它才能保证"App 与网页的标签条完全一致"。
// ⚠️ 缓存键用 `roster_tagcloud`(存进通用的 `module_cache`,
// 不必新建表;见 FbModuleRepo 的说明)。
await this.saveTagCloud(parsed.tagCloud);
const done: FbRosterSyncResult = {
ok: true, saved: parsed.people.length, error: ''
};
return done;
}
/**
* 把服务端下发的标签云存进 `module_cache`。
*
* ⚠️ 存的是**接口原样的 JSON**,读取时再解析 —— 与其它模块缓存同一套做法,
* 这样"标签云"将来加字段(如颜色)不用改表结构。
*/
private async saveTagCloud(list: FbTagCount[]): Promise<void> {
try {
const payload: string = JSON.stringify(list);
await this.db.require().executeSql(
'INSERT OR REPLACE INTO module_cache (name, payload, fetched_at) VALUES (?, ?, ?)',
['roster_tagcloud', payload, Date.now()]);
} catch (err) {
// 存缓存失败**不该**让整次同步失败(名册本体已经写好了)。
hilog.warn(DOMAIN, TAG, '标签云缓存失败:%{public}s', JSON.stringify(err));
}
}
/**
* 读标签云。
*
* ⚠️ **优先用服务端下发的**(`module_cache` 里的 `roster_tagcloud`);
* 读不到(首次安装、还没同步过)才**回落到本地聚合**。
* ⇒ 这样"已同步过"的设备一定与网页一致,而"没同步过"的设备
* 至少还能从已有的 `person_tags` 里凑出一份。
*/
async tagCloud(): Promise<FbTagCount[]> {
// ① 服务端下发的(权威)
try {
const rows: FbRow[] = await this.db.queryRows(
"SELECT payload FROM module_cache WHERE name = 'roster_tagcloud'", []);
for (const r of rows) {
const raw: string = r.strOr('payload', '');
if (raw !== '') {
const list: FbTagCount[] = JSON.parse(raw) as FbTagCount[];
if (Array.isArray(list) && list.length > 0) {
return list;
}
}
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '读标签云缓存失败,回落到本地聚合:%{public}s',
JSON.stringify(err));
}
// ② 回落:本地 `person_tags` 聚合
return this.tagCloudLocal();
}
/**
* 本地聚合(回落用)。
*
* ⚠️ 排序与服务端 `PersonTag::cloud()` 对齐:**按人数降序、同数按标签名升序**。
* 两边顺序不一致的话,用户会看到"网页和 App 的标签条顺序不一样"。
*/
private async tagCloudLocal(): Promise<FbTagCount[]> {
const rows: FbRow[] = await this.db.queryRows(
'SELECT tag, COUNT(*) AS n FROM person_tags GROUP BY tag ORDER BY n DESC, tag ASC');
const out: FbTagCount[] = [];
for (const r of rows) {
const tag: string = r.strOr('tag', '');
if (tag !== '') {
const c: FbTagCount = { tag: tag, n: r.num('n', 0) };
out.push(c);
}
}
return out;
}
/**
* 从本地库读名册(**UI 的唯一数据来源**)。
*
* ⚠️ 标签用 `GROUP_CONCAT` 在 SQL 里拼成一列(再在解析时拆开),
* 而不是"每人再查一次标签表" —— 800 人会变成 800 次查询,
* 手机上是实打实的卡顿。
*/
async list(keyword: string = ''): Promise<FbRosterPerson[]> {
const sql: string =
'SELECT p.id, p.name, p.nickname, p.gender, p.birthday, p.avatar, p.avatar_color,' +
' p.note, p.email, p.org, p.title, p.slogan, p.addr_city,' +
' r.sort_order, r.phones,' +
' (SELECT GROUP_CONCAT(t.tag, "\\u0001") FROM person_tags t' +
' WHERE t.person_id = p.id AND t.owner_id = r.owner_id) AS tags' +
' FROM person_roster r JOIN persons p ON p.id = r.person_id' +
' ORDER BY r.sort_order ASC, p.id ASC';
const rows: FbRow[] = await this.db.queryRows(sql, []);
const out: FbRosterPerson[] = [];
for (const r of rows) {
const p: FbRosterPerson = this.rowToPerson(r);
if (keyword !== '' && !matches(p, keyword)) {
continue;
}
out.push(p);
}
return out;
}
/**
* 读名册的元信息(人 + 标签云 + 我的 id)。
*
* ⚠️ 标签云**从本地库当场聚合**(`GROUP BY tag`),不从接口另存一份:
* 数据本来就在 `person_tags` 里,存两份迟早不一致。
*/
async meta(): Promise<FbRosterBundle> {
const people: FbRosterPerson[] = await this.list();
const tagCloud: FbTagCount[] = await this.tagCloud();
// 「我的 id」从名册关系里取(owner_id 就是我自己)
const meId: number = await this.db.scalar(
'SELECT IFNULL(MAX(owner_id), 0) FROM person_roster');
const bundle: FbRosterBundle = { people: people, tagCloud: tagCloud, meId: meId };
return bundle;
}
/** 本地库里名册人数(自检/显示用) */
async count(): Promise<number> {
return this.db.scalar('SELECT COUNT(*) FROM person_roster');
}
/**
* 拉**单人完整详情**(`GET /api/v1/people/<id>`)。
*
* 🔴 为什么详情不走本地库(v52,用户 2026-10-15):
* 本地库的 `persons` 表是为**列表**设计的(只存画一栏要用的字段),
* 而"全功能"要求详情能看到**工作经历、补充电话、私人备注**这些
* 一对多 / 只有我看得到的数据 —— 把它们全搬进本地库要再加两张表 +
* 一套同步,收益只是"省一次请求"。
* ⇒ 详情**直接联网取**(一次往返、几百毫秒),取到就画。
* ⚠️ 断网时返回 `null`,调用方要能退化成"用列表里那份画"(见调用处)。
*/
async fetchDetail(id: number, token: string): Promise<FbRosterPerson | null> {
if (id <= 0) {
return null;
}
const res: FbApiResult = await fbApi('/api/v1/people/' + id.toString(), { token: token });
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '详情拉取失败(%{public}d):%{public}s', id, res.error);
return null;
}
try {
const o: FbApiRosterPerson = JSON.parse(res.data) as FbApiRosterPerson;
return personFromApi(o);
} catch (err) {
hilog.error(DOMAIN, TAG, '详情解析失败:%{public}s', JSON.stringify(err));
return null;
}
}
/**
* 保存一个人(`PATCH /api/v1/people/<id>`),全字段。
*
* @param fields 要改的字段(**只传变化的**;服务端对没传的字段沿用旧值,
* 所以不会出现"改一项、其余被清空")。
* @returns 成功时返回服务端回写的**完整详情**(拿它刷新界面最准)
*/
async save(id: number, fields: Record<string, Object>, token: string): Promise<FbApiResult> {
const res: FbApiResult = await fbApi('/api/v1/people/' + id.toString(),
{ method: 'PATCH', body: fields, token: token });
return res;
}
/** 新建一个人(`POST /api/v1/people`),全字段 */
async create(fields: Record<string, Object>, token: string): Promise<FbApiResult> {
const res: FbApiResult = await fbApi('/api/v1/people',
{ method: 'POST', body: fields, token: token });
return res;
}
/** 删除一个人(`DELETE /api/v1/people/<id>`) */
async remove(id: number, token: string): Promise<FbApiResult> {
return await fbApi('/api/v1/people/' + id.toString(), { method: 'DELETE', token: token });
}
/** 一行的解析(不做标签/生日的额外查询) */
private rowToPerson(r: FbRow): FbRosterPerson {
const name: string = r.strOr('name', '');
// 电话(JSON 数组;列可能为空串/非法 JSON ⇒ 容错成空数组)
const phonesArr: string[] = [];
const phonesRaw: string = r.strOr('phones', '');
if (phonesRaw !== '') {
try {
const parsedArr: string[] = JSON.parse(phonesRaw) as string[];
if (Array.isArray(parsedArr)) {
for (const ph of parsedArr) {
if (typeof ph === 'string' && ph !== '') {
phonesArr.push(ph);
}
}
}
} catch (err) {
hilog.warn(DOMAIN, TAG, 'phones JSON 解析失败:%{public}s', JSON.stringify(err));
}
}
const nick: string = r.strOr('nickname', '');
const tagsRaw: string = r.strOr('tags', '');
const tags: string[] = [];
if (tagsRaw !== '') {
for (const t of tagsRaw.split('\u0001')) {
if (t !== '') {
tags.push(t);
}
}
}
const birthday: string = r.strOr('birthday', '');
const p: FbRosterPerson = {
id: r.num('id', 0),
name: name,
shown: nick !== '' ? nick : name,
// ⚠️ 称谓("父亲""妻子")由服务端算好下发,**本地库不存** ——
// 它依赖整个家族图谱(Kinship),端上重算既费时又容易算错。
// ⇒ 这里只能从名字/昵称兜底;要显示准确称谓需 `listWithLabel()`(拉接口)。
label: '',
tags: tags,
avatarColor: r.strOr('avatar_color', ''),
avatar: r.strOr('avatar', ''),
birthdaySolar: birthday.length >= 10 ? birthday.substring(5, 10) : '',
birthdayFull: birthday.length >= 10 ? birthday.substring(0, 10) : '',
birthdayLunar: '',
// 🔴 电话从 `person_roster.phones`(JSON 数组)读。
// ⚠️ 之前这里是**空串** —— 结果是"列表里点人拨号"根本没号码可拨
// (只有走接口的 `listWithLabel()` 才带 phone)。这是个真 bug。
phone: phonesArr.length > 0 ? phonesArr[0] : '',
phones: phonesArr,
initials: '',
org: r.strOr('org', ''),
title: r.strOr('title', ''),
slogan: r.strOr('slogan', ''),
email: r.strOr('email', ''),
addrCity: r.strOr('addr_city', ''),
gender: r.strOr('gender', ''),
note: r.strOr('note', ''),
isAdmin: false,
// ---- v52:本地库**不存**这些(详情接口才有)----
// ⚠️ 本地库是为**列表**设计的(只存画一栏要用的字段)。
// 工作经历/补充电话/私人备注/各级地址是"详情专用",
// 它们在服务端而是**一对多**(工作经历、补充电话)或**按查看者不同**
// (私人备注、标签),搬进本地库要再加两张表 + 一套同步 ——
// 为省一次请求不值得。
// ⇒ 详情**联网取**(见 `fetchDetail`),断网时这几项为空、其余照常显示。
addrCountry: '',
addrProvince: '',
addrDistrict: '',
address: r.strOr('address', ''),
birthRank: '',
birthdayLunarLeap: false,
birthdayLunarRaw: '',
contacts: { phone: [], email: [], address: [], url: [] },
jobs: [],
myNote: '',
myNickname: '',
idCardMasked: '',
canEditAll: false,
isSelf: false
};
return p;
}
/** 分批批量插入(理由见 FB_BATCH_SIZE 的说明) */
/**
* 批量写入(分片防单条 SQL 过长)。
*
* @param replace 主键冲突时**覆盖**还是**忽略**。
* ⚠️ `persons` 必须传 `true`(跨模块共用表,不能先清空,只能靠 REPLACE 更新)。
* 为什么(用户 2026-10-15 实测的真 bug):
* 默认 `batchInsert` 在 PK 冲突时是 **IGNORE 语义**(什么都不做)⇒
* **已存在的人永远不更新** ⇒ 服务端后来补的 email / 座右铭 / 地址
* 在端上永远为空("杨永全"的详情浮层里那三行干脆不显示)。
* ⚠️ `person_roster` 传 `false` 即可 —— 它在写入前已经全量 DELETE,
* 不存在冲突。(两个调用点的 linter 也会提示重复写入,见下。)
*/
private async insertInBatches(store: relationalStore.RdbStore, table: string,
rows: relationalStore.ValuesBucket[], replace: boolean = false): Promise<void> {
if (rows.length === 0) {
return;
}
for (let i = 0; i < rows.length; i += FB_BATCH_SIZE) {
const chunk: relationalStore.ValuesBucket[] = rows.slice(i, i + FB_BATCH_SIZE);
if (replace) {
await store.batchInsertWithConflictResolution(
table, chunk, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
} else {
await store.batchInsert(table, chunk);
}
}
}
}
// ============================================================
// 解析
// ============================================================
interface FbParsedRoster {
people: FbRosterPerson[];
tagCloud: FbTagCount[];
meId: number;
}
/** 安全取字符串 */
function strOf(v: string | null | undefined): string {
return v !== null && v !== undefined ? v : '';
}
/** 安全取数字 */
function numOf(v: number | null | undefined): number {
return v !== null && v !== undefined ? v : 0;
}
/**
* 数字 → 字符串(用于排行这种"可能是 number 也可能是 string"的字段)。
* ⚠️ 0 视为"没填" ⇒ 返回空串(服务端 NULL 会变 0,别在界面上显示一个 0)。
*/
function numToStr(v: number | string | null | undefined): string {
if (v === null || v === undefined) {
return '';
}
if (typeof v === 'number') {
return v > 0 ? v.toString() : '';
}
return v;
}
/** 归一化补充联系方式(按类型分组,丢掉空值行) */
function normaliseContacts(raw?: Record<string, FbApiContactRaw[]> | null): FbContacts {
const out: FbContacts = { phone: [], email: [], address: [], url: [] };
if (raw === null || raw === undefined) {
return out;
}
const kinds: string[] = ['phone', 'email', 'address', 'url'];
for (const kind of kinds) {
const rows: FbApiContactRaw[] = raw[kind] !== undefined && raw[kind] !== null ? raw[kind] : [];
const list: FbContactRow[] = [];
for (const r of rows) {
const val: string = strOf(r.value);
if (val !== '') {
const row: FbContactRow = { label: strOf(r.label), value: val };
list.push(row);
}
}
if (kind === 'phone') {
out.phone = list;
} else if (kind === 'email') {
out.email = list;
} else if (kind === 'address') {
out.address = list;
} else {
out.url = list;
}
}
return out;
}
/** 归一化工作经历 */
function normaliseJobs(raw?: FbApiJobRaw[] | null): FbJobRow[] {
const out: FbJobRow[] = [];
if (raw === null || raw === undefined) {
return out;
}
for (const r of raw) {
const row: FbJobRow = {
org: strOf(r.org),
title: strOf(r.title),
rankName: strOf(r.rank_name),
startYm: strOf(r.start_ym),
endYm: strOf(r.end_ym),
note: strOf(r.note)
};
// 全空的行不显示(服务端可能留了占位行)
if (row.org !== '' || row.title !== '' || row.rankName !== '') {
out.push(row);
}
}
return out;
}
/**
* 关键词匹配(**本地筛选**,与服务端 `PersonQuery` 的口径尽量贴近)。
*
* ⚠️ 本地能匹配的只有"库里有明文的东西":姓名 / 昵称 / 手机号 / 标签 /
* 单位职务 / 首字母。
* ⚠️ **称谓("父亲")匹配不到** —— 那个值依赖家族图谱、端上没算。
* 这是**有意的取舍**:本地搜索快、断网可用;要按称谓搜就调接口(`?q=`)。
*/
function matches(p: FbRosterPerson, q: string): boolean {
const k: string = q.trim().toLowerCase();
if (k === '') {
return true;
}
const hay: string[] = [
p.name.toLowerCase(), p.shown.toLowerCase(), p.phone,
p.org.toLowerCase(), p.title.toLowerCase(), p.initials.toLowerCase(),
];
for (const t of p.tags) {
hay.push(t.toLowerCase());
}
for (const h of hay) {
if (h !== '' && h.indexOf(k) >= 0) {
return true;
}
}
return false;
}
/** 把接口的标签云规整成端上的形状 */
/**
* 归一化"全部电话"。
*
* ⚠️ 优先用服务端的 `phones`(**它保证主号在前**);
* 没有(老接口)就回落成 `[phone_plain]`。
* ⚠️ 去重 + 去空:服务端理论上已经做过,但端上再兜一层(数据脏了不至于重复显示)。
*/
function normalisePhones(o: FbApiRosterPerson): string[] {
const out: string[] = [];
const arr: string[] | null | undefined = o.phones;
if (arr !== null && arr !== undefined && Array.isArray(arr)) {
for (const ph of arr) {
if (typeof ph === 'string' && ph !== '' && out.indexOf(ph) < 0) {
out.push(ph);
}
}
}
if (out.length === 0) {
const main: string = strOf(o.phone_plain);
if (main !== '') {
out.push(main);
}
}
return out;
}
function normaliseTagCloud(list?: FbApiTagItem[]): FbTagCount[] {
const out: FbTagCount[] = [];
if (list === undefined || !Array.isArray(list)) {
return out;
}
for (const it of list) {
const tag: string = strOf(it.tag);
if (tag !== '') {
const c: FbTagCount = { tag: tag, n: numOf(it.n) };
out.push(c);
}
}
return out;
}
/**
* 把**接口给的一份人员数据**映射成端上的 `FbRosterPerson`。
*
* 🔴 抽成函数的原因(v52):列表(`/roster` 的 people[])与
* 详情(`GET /people/<id>`)**形状几乎一样**,只是详情多带几个字段。
* ⇒ 共用同一套映射,字段一旦新增/改名只改一处 ——
* 两份映射必然漂移(本项目已经在"两个 tagCloud 分支"上踩过一次)。
* ⚠️ 列表接口不带 `contacts`/`jobs`/`my_note` 等字段时,这里会缺省成空,
* 由详情接口补齐;这不影响列表渲染。
*/
function personFromApi(o: FbApiRosterPerson): FbRosterPerson {
const id: number = numOf(o.id);
// 生日:从二元组数组里挑"阳历 / 农历"
let solar: string = '';
let lunar: string = '';
const bdays: string[][] = o.birthdays !== undefined ? o.birthdays : [];
for (const pair of bdays) {
if (pair.length >= 2) {
const kind: string = strOf(pair[0]);
const val: string = strOf(pair[1]);
if (kind === '阳历') {
solar = val;
} else if (kind === '农历') {
lunar = val;
}
}
}
const name: string = strOf(o.name);
const shown: string = strOf(o.shown) !== '' ? strOf(o.shown) : name;
const tags: string[] = [];
if (o.tags !== undefined && Array.isArray(o.tags)) {
for (const t of o.tags) {
if (typeof t === 'string' && t !== '') {
tags.push(t);
}
}
}
const phones: string[] = normalisePhones(o);
const p: FbRosterPerson = {
id: id,
name: name,
shown: shown,
label: strOf(o.label),
tags: tags,
avatarColor: strOf(o.avatar_color),
avatar: strOf(o.avatar),
birthdaySolar: solar,
// 🔴 完整生日取自**接口的 `birthday`**(`1955-03-30 00:00:00`),
// 而不是 `birthdays` 数组 —— 后者**只有月日**(服务端刻意不显示年份)。
// ⚠️ 编辑生日要完整日期 ⇒ 必须有这个字段。
birthdayFull: strOf(o.birthday).length >= 10 ? strOf(o.birthday).substring(0, 10) : '',
birthdayLunar: lunar,
// 🔴 全部电话:优先用服务端下发的 `phones`(主号在前);
// 老版本接口没这个字段时,回落到 `phone_plain`(至少主号能打)。
phones: phones,
phone: phones.length > 0 ? phones[0] : '',
initials: strOf(o.initials),
org: strOf(o.org),
title: strOf(o.title),
slogan: strOf(o.slogan),
email: strOf(o.email),
addrCity: strOf(o.addr_city),
gender: strOf(o.gender),
note: strOf(o.note),
// 管理员徽标:服务端给的是 account_role / account_status
isAdmin: strOf(o.account_role) === 'admin' && strOf(o.account_status) === 'active',
// ---- v52 全字段(列表接口一般不带,缺省成空;详情接口会补齐)----
addrCountry: strOf(o.addr_country),
addrProvince: strOf(o.addr_province),
addrDistrict: strOf(o.addr_district),
address: strOf(o.address),
birthRank: numToStr(o.birth_rank),
birthdayLunarLeap: false,
birthdayLunarRaw: strOf(o.birthday_lunar),
contacts: normaliseContacts(o.contacts),
jobs: normaliseJobs(o.jobs),
myNote: strOf(o.my_note),
myNickname: strOf(o.my_nickname),
idCardMasked: strOf(o.id_card_masked),
canEditAll: o.can_edit_all === true,
isSelf: o.is_self === true
};
return p;
}
/**
* 解析名册响应。
*
* ⚠️ 生日的形状是**实测**来的:服务端 `PersonQuery::birthdaysOf()` 返回
* `[['阳历','03-30'], ['农历','三月初七']]` —— 二元组数组,
* 不是 `{solar,lunar}` 对象。写解析前**先打接口看真实形状**,别猜。
*/
function parseRoster(json: string): FbParsedRoster {
const empty: FbParsedRoster = { people: [], tagCloud: [], meId: 0 };
if (json === '') {
return empty;
}
let payload: FbApiRosterPayload;
try {
payload = JSON.parse(json) as FbApiRosterPayload;
} catch (err) {
hilog.error(DOMAIN, TAG, '名册解析失败:%{public}s', JSON.stringify(err));
return empty;
}
if (payload === null || payload === undefined) {
return empty;
}
const people: FbRosterPerson[] = [];
const raw: FbApiRosterPerson[] = payload.people !== undefined ? payload.people : [];
for (const o of raw) {
const p: FbRosterPerson = personFromApi(o);
if (p.id > 0) {
people.push(p);
}
}
const parsed: FbParsedRoster = {
people: people,
tagCloud: normaliseTagCloud(payload.tagCloud),
meId: numOf(payload.meId)
};
return parsed;
}
+297
View File
@@ -0,0 +1,297 @@
/**
* 家书 FamilyBook · 本地数据库结构(鸿蒙端)
*
* 设计立场:**本地优先(local-first)**。
* · 本地库是 UI 的**唯一数据源**,界面永远不直接读网络;
* · 网络只在后台把服务端数据"补进来"、把本地改动"推出去";
* · 断网时所有功能照常可用(因为数据本来就在本地)。
*
* 🔴 建表原则:**按实体建表,不按模块建表**。
* 「观影」看起来是一个模块,但服务端它是 6 张表组合出的视图:
* events(kind='movie') + movie_details + cinemas + movie_reviews
* + event_attendees + event_photos
* ⇒ 本地也照**实体**存:`events` 一张、`movie_details` 一张……
* 观影页要的数据用 VIEW(或 JOIN 查询)拼出来。
* ⚠️ 若按模块建表(一张 movies 表塞所有字段),会出现:
* 同一条日程在「日程模块」和「观影模块」各存一份、互相打架。
* 这是本地优先最容易踩的坑。
*
* 🔴 同步字段(每张业务表都有,与服务端 v33 结构对齐):
* · `updated_at` —— 服务端最后修改时间(**由服务端 MySQL 自动维护**);
* · `deleted_at` —— 软删除墓碑(NULL = 未删);
* · `synced_at` —— **本地专有**:上次把它同步下来的时间,
* 用来判断"这条是否已被本地改动覆盖"。
* · `dirty` —— **本地专有**:1 = 有未推送的本地改动。
* ⚠️ 后两个字段是本地专有的,**不属于服务端结构**,回推时不发送。
*/
import { relationalStore } from '@kit.ArkData';
/** 本地库文件名(放在应用沙箱的 databases 目录下) */
export const FB_DB_NAME: string = 'familybook.db';
/**
* 本地库版本号。
*
* ⚠️ 每次改表结构都要 +1,并在 `migrate()` 里补上对应的 ALTER。
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
*/
export const FB_DB_VERSION: number = 4;
/**
* 建表语句。
*
* ⚠️ 只对**首装**(onCreate)执行。老库升级走 `migrate()`。
*/
export const FB_SCHEMA_SQL: string[] = [
// ---------- 观影(4 张实体表)----------
`CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
family_id INTEGER NOT NULL,
creator_id INTEGER,
kind TEXT NOT NULL,
title TEXT NOT NULL,
starts_at TEXT,
ends_at TEXT,
location TEXT,
note TEXT,
source_ref TEXT,
created_at TEXT,
updated_at TEXT NOT NULL DEFAULT '',
deleted_at TEXT,
-- ⚠️ 下面两列是**派生/聚合值**(服务端 Movie::attachStats 算出来的),
-- 不是 events 表的原生列。放这里是为了"观影列表一次查询出结果",
-- 免得每渲染一屏都去 GROUP BY movie_reviews。
-- ⚠️ 它们**会被同步覆盖**,所以本地改动不能写这两列。
review_count INTEGER NOT NULL DEFAULT 0,
review_avg REAL NOT NULL DEFAULT 0,
synced_at INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0
)`,
// 增量拉取的主力索引(与服务端 idx_events_sync 同语义)
`CREATE INDEX IF NOT EXISTS idx_events_sync ON events (family_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS movie_details (
event_id INTEGER PRIMARY KEY,
info TEXT,
poster_key TEXT,
ticket_key TEXT,
cinema_id INTEGER,
douban_meta TEXT,
updated_at TEXT NOT NULL DEFAULT '',
deleted_at TEXT,
synced_at INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS cinemas (
id INTEGER PRIMARY KEY,
family_id INTEGER NOT NULL,
place TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
address TEXT,
color TEXT NOT NULL DEFAULT '',
created_at TEXT,
updated_at TEXT NOT NULL DEFAULT '',
deleted_at TEXT,
synced_at INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_cinemas_sync ON cinemas (family_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS movie_reviews (
id INTEGER PRIMARY KEY,
family_id INTEGER NOT NULL,
event_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
rating INTEGER NOT NULL DEFAULT 0,
content TEXT NOT NULL DEFAULT '',
feelings TEXT,
created_at TEXT,
updated_at TEXT NOT NULL DEFAULT '',
deleted_at TEXT,
synced_at INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_movie_reviews_event ON movie_reviews (event_id)`,
// ---------- 共用实体(观影页也要用)----------
`CREATE TABLE IF NOT EXISTS persons (
id INTEGER PRIMARY KEY,
family_id INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
nickname TEXT,
gender TEXT,
birthday TEXT,
avatar TEXT,
note TEXT,
-- ⚠️ v2 新增(名册要用):这些在服务端 persons 表里本来就有,
-- 只是之前只为观影的"同看人"建了最小字段集。
-- 名册页要显示单位/职务/口号/邮箱/地址/头像色,所以补齐。
-- 🔴 注意:本段是 SQL 注释、而整条建表语句是 JS 模板字符串,
-- 注释里**绝不能出现反引号** —— 一个反引号就会把模板串提前闭合,
-- 后续全被当成代码(报 "Cannot find name 'persons'")。
-- 这个坑本项目已踩第 4 次,写 SQL 注释时手别抖。
avatar_color TEXT,
email TEXT,
org TEXT,
title TEXT,
slogan TEXT,
address TEXT,
addr_city TEXT,
created_at TEXT,
updated_at TEXT NOT NULL DEFAULT '',
deleted_at TEXT,
synced_at INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_persons_sync ON persons (family_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS event_attendees (
-- ⚠️ 用**复合主键**而不是自增 id:
-- 接口返回的 attendees[].id 实测是 persons.id(服务端 SQL 是
-- "SELECT a.event_id, p.id, ..."),**不是**这张关联表自己的行 id
-- (服务端压根没返回那个)。
-- 我们既然不掌握行 id,就用「事件 + 人」这个业务唯一键做主键 ——
-- 一个事件里同一个人本来就只会出现一次,语义正确且幂等。
--
-- 🔴 注意:本行注释里**不能出现反引号** —— 整个建表语句是 JS 模板字符串,
-- 注释里一个反引号就会把它**提前闭合**,后面全变成代码、编译直接报
-- "Cannot find name 'SELECT'"(这个坑刚踩过)。
event_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT '',
deleted_at TEXT,
synced_at INTEGER NOT NULL DEFAULT 0,
dirty INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (event_id, person_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_attendees_event ON event_attendees (event_id)`,
// ---------- 同步元数据(本地专有,不对应任何服务端表)----------
/**
* 每张表各存一条"上次同步到哪"的水位线。
*
* 🔴 为什么**按表**而不是全局一个水位:
* 各表是分别拉取的。若共用一个全局水位,A 表拉成功了、B 表失败了,
* 水位推进之后 B 表那段数据**永远不会再被拉下来**(静默丢数据)。
*/
`CREATE TABLE IF NOT EXISTS sync_state (
table_name TEXT PRIMARY KEY,
last_sync_at TEXT NOT NULL DEFAULT '',
last_ok_at INTEGER NOT NULL DEFAULT 0
)`,
/**
* 待推送队列(本地改动)。
*
* 🔴 为什么要有队列、而不是"改完立刻发请求":
* ① 断网时改动不能丢 —— 排队等着,有网再发;
* ② 用户连点几下,不该发几个请求 —— 同一行合并成一条;
* ③ 请求失败要能重试 —— 记录次数,避免无限重试打爆。
*/
`CREATE TABLE IF NOT EXISTS pending_ops (
id INTEGER PRIMARY KEY,
table_name TEXT NOT NULL,
row_id INTEGER NOT NULL,
op TEXT NOT NULL,
payload TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
)`,
// 同一行只保留最后一次操作(合并重复改动)
`CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_row ON pending_ops (table_name, row_id)`,
// ---------- 名册(v2 新增)----------
// 🔴 为什么名册需要**自己**的表,而不是直接用 `persons`:
// `persons` 是"全家的通讯录底账"(本家 800+ 人);
// 而名册是「**我**挑出来的那一小撮」—— 我加的人 + 别人分享给我的。
// 服务端用 `person_roster(family_id, owner_id, person_id)` 表达这层"谁的",
// 端上必须同样有一张,否则分不清"这人是我名册里的,还是只是全家的人"。
// ⚠️ 主键用 (owner_id, person_id):一个人在一个人的名册里只会出现一次。
`CREATE TABLE IF NOT EXISTS person_roster (
owner_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
family_id INTEGER NOT NULL,
group_id INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT '',
synced_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner_id, person_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_roster_owner ON person_roster (owner_id, sort_order)`,
// 标签:一个人可以有多个标签(服务端 v28 起把"分组"降级成"标签词表",
// 理由:一个人既是小学同学又是初中同学,分组放不下、标签可以)。
`CREATE TABLE IF NOT EXISTS person_tags (
owner_id INTEGER NOT NULL,
person_id INTEGER NOT NULL,
tag TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT '',
synced_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (owner_id, person_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_tags_owner ON person_tags (owner_id, tag)`,
// ---------- 通用模块缓存(v3 新增)----------
// 🔴 为什么要这张表:日历/任务/纪念日/旅程/账本/资源/藏书/笔记/通知/首页
// 这些模块的数据**目前只需要"拉一次 + 展示"**,没有离线写入的需求。
// 为它们各建实体表(几十列 × 10 张)成本极高、收益极低;
// 而完全不缓存又会让"断网打不开、每次转圈"。
// ⇒ 折中:**原样缓存接口 JSON**,UI 每次解析。
// ⚠️ 代价:拉回来就要解析一遍(10 个模块毫秒级,可接受)。
// ⚠️ 将来某模块要"离线可写"(任务打勾、账本记账)时,
// **那个模块**单独拆实体表 —— 不要整体重构。
`CREATE TABLE IF NOT EXISTS module_cache (
name TEXT PRIMARY KEY,
payload TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL DEFAULT 0
)`,
];
/** 建表语句分组说明(供日志/排查用) */
export interface FbTableGroup {
/** 分组名(如「观影」「同步元数据」) */
group: string;
/** 该组包含的表名 */
tables: string[];
}
/** 本地库里的表清单(按用途分组)—— 删除库/排查时用 */
export const FB_TABLE_GROUPS: FbTableGroup[] = [
{
group: '观影',
tables: ['events', 'movie_details', 'cinemas', 'movie_reviews']
},
{
group: '共用实体',
tables: ['persons', 'event_attendees']
},
{
group: '名册',
tables: ['person_roster', 'person_tags']
},
{
group: '同步元数据(本地专有)',
tables: ['sync_state', 'pending_ops']
}
];
/** 需要参与同步的业务表(与服务端 v33 的 53 张对齐,这里先放已接入的部分) */
export const FB_SYNCED_TABLES: string[] = [
'events', 'movie_details', 'cinemas', 'movie_reviews', 'persons', 'event_attendees',
'person_roster', 'person_tags'
];
/** 配置本地数据库(SecurityLevel 取 S1:家庭应用,不含敏感凭证) */
export const FB_STORE_CONFIG: relationalStore.StoreConfig = {
name: FB_DB_NAME,
securityLevel: relationalStore.SecurityLevel.S1,
// ⚠️ 不开 encrypt:库内是家庭日程/观影记录这类普通数据;
// 真要加密应走"整库加密 + 密钥进 KeyStore",不是在这里开个开关了事。
encrypt: false
};
+179
View File
@@ -0,0 +1,179 @@
/**
* 家书 FamilyBook · 服务器地址存储(鸿蒙端)
*
* 只存一件事:**这台手机连的是哪台服务器**(形如 `https://family.example.com`)。
*
* 🔴 为什么它必须存在(用户 2026-10-17「应用第一次打开,让用户输入服务器地址」):
* 在此之前地址是 `FbHttp.ets` 里的一个**编译期常量** —— 意味着这个 App
* 只能连我们自己那一台,别人装上去就是"登录永远失败",且用户看不到任何原因
* (他甚至不知道"地址"这个东西可以被改)。
* ⇒ 地址必须是**运行期可配置 + 持久化**的:第一次打开问一次,之后一直用它。
*
* ⚠️ 为什么与令牌**分两个 preferences 文件**(`fb_auth` 存令牌 / `fb_server` 存地址):
* 两者生命周期不同 —— 「更换服务器」要清令牌(旧令牌在新服务器上无效),
* 但地址要保留(用户刚填的);「退出登录」清令牌但绝对不能清地址
* (否则下次打开又从头问一遍)。放同一个文件里,清/留很容易写错。
*
* ⚠️ 同样的"同步读"门面(见 FbTokenStore 的说明):
* `fbApi()` 是纯同步的拼 URL 逻辑,不可能为了一句地址把它改成 async。
* ⇒ 启动时 `init()` 预加载进内存,之后 `get()` 同步读。
* **前提**:必须在发第一个请求之前 init 完(见 `EntryAbility.onCreate`)。
* 调用方若拿不准,先用 `isReady()` 确认(见 `Index.bootCheck`)。
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbServer';
/** preferences 实例名(沙箱内的文件名) */
const STORE_NAME: string = 'fb_server';
/** 服务器基址的键名 */
const KEY_BASE: string = 'base_url';
/**
* 把用户输入整成可用的基址。
*
* 用户可能输的东西(都见过):
* `family.example.com` → 补 `https://`
* `family.example.com/` → 去掉尾斜杠
* `http://192.168.1.8:8080` → 原样(内网调试常用 http)
* `https://x.com/familybook/` → 保留子目录,只去尾斜杠
* ` https://x.com ` → 去首尾空白
* 空串 / 只有协议 → 返回空串(调用方据此判"没填")
*
* ⚠️ **只去尾部的 `/`**,不动路径中间的任何东西:
* 子目录部署(`https://x.com/familybook`)是真实存在的形态,
* 在这里"聪明地"截断成 `https://x.com` 会让请求全部 404。
* ⚠️ 默认补 `https://` 而不是 `http://`:
* 公网部署都应该有证书;内网调试时用户自己会输 `http://`(我们照抄不改)。
*/
export function fbNormalizeServer(input: string): string {
let s: string = input.trim();
if (s === '') {
return '';
}
// 协议:认 http / https(大小写都不管),其它一律当"没写协议"
if (!/^https?:\/\//i.test(s)) {
// 用户可能写了 `//x.com` 这种协议相对写法,先剥掉
if (s.startsWith('//')) {
s = s.substring(2);
}
s = 'https://' + s;
}
// 去尾部所有斜杠(`https://x.com///` → `https://x.com`)
while (s.endsWith('/')) {
s = s.substring(0, s.length - 1);
}
return s;
}
/**
* 是否是"看着像样"的基址。
*
* ⚠️ 这只是**形状检查**(拦住"只写了几个字母"这种明显错误),
* 真正的"这服务器对不对"必须靠 `fbProbeServer()` 打 `/api/v1/ping` ——
* 形状对但域名不存在/不是家书的服务器,这里一律判不出来。
*/
export function fbServerLooksValid(base: string): boolean {
// 主机部分:字母数字、点、横线、冒号(端口)、方括号(IPv6),且不能为空
return /^https?:\/\/[^\s/]+$/i.test(base);
}
/**
* 服务器地址存储(同步读、异步写的门面)。
*/
class FbServerStoreImpl {
private cached: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/** 预加载进内存。应在应用启动时调用一次(见 EntryAbility)。⚠️ 幂等 */
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(KEY_BASE, '');
const raw: string = typeof v === 'string' ? v : '';
// ⚠️ 读出来也过一遍归一:老版本可能存了带尾斜杠的值
this.cached = fbNormalizeServer(raw);
this.ready = true;
hilog.info(DOMAIN, TAG, '服务器地址预加载完成,%{public}s',
this.cached === '' ? '未配置' : this.cached);
} catch (err) {
// 读不到就当"没配"——用户会被引导去填,比崩掉好
hilog.warn(DOMAIN, TAG, '服务器地址预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
/**
* 同步取基址(未配置返回空串)。
* ⚠️ 需先 init(),否则会误判成"未配置"(见 `isReady()`)。
*/
get(): string {
if (!this.ready) {
hilog.warn(DOMAIN, TAG, '服务器地址还没预加载完,本次读到的可能是空值');
}
return this.cached;
}
/** 有没有配置过服务器地址(同步) */
has(): boolean {
return this.cached !== '';
}
/** 预加载是否完成(同步)—— 启动分流必须看它,否则会闪一下"填地址"页 */
isReady(): boolean {
return this.ready;
}
/**
* 保存基址(先归一,再落盘)。
* 返回归一后的值(空串 = 输入不合法/为空,未写入)。
*/
async set(ctx: common.Context, input: string): Promise<string> {
const base: string = fbNormalizeServer(input);
if (!fbServerLooksValid(base)) {
return '';
}
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_BASE, base);
// ⚠️ `put` 只写内存,**必须 `flush`** —— 不 flush 进程被杀就丢
// (表现:填了地址、重启后又让填)
await this.store.flush();
this.cached = base;
this.ready = true;
hilog.info(DOMAIN, TAG, '服务器地址已保存:%{public}s', base);
return base;
} catch (err) {
hilog.error(DOMAIN, TAG, '服务器地址保存失败:%{public}s', JSON.stringify(err));
return '';
}
}
/** 清除(一般只在"彻底重置"时用;「更换服务器」不该清空,直接覆盖) */
async clear(ctx: common.Context): Promise<void> {
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.delete(KEY_BASE);
await this.store.flush();
this.cached = '';
hilog.info(DOMAIN, TAG, '服务器地址已清除');
} catch (err) {
hilog.error(DOMAIN, TAG, '服务器地址清除失败:%{public}s', JSON.stringify(err));
}
}
}
/** 全局单例 */
export const FB_SERVER_STORE: FbServerStoreImpl = new FbServerStoreImpl();
+227
View File
@@ -0,0 +1,227 @@
/**
* 家书 FamilyBook · 会话状态(鸿蒙端)
*
* 负责回答一个问题:**现在能不能用?**
*
* 🔴 v57(用户 2026-10-17)原话:
* 「这样一个 Token 我们给它 24 小时的寿命。就是每过 24 小时,我们就要请求一下
* 服务器,去验证一下这个 Token 是不是还是有效的。」
* 「只要是一直保持联网,就不需要每天都输密码。」
* 「如果手机丢了,我们就在网页端把这个设备去掉……这个应用就要自动退出,
* 退到登录页面。退到登录页面之后,里面所有的资源、所有的内容都是不允许看的。」
* 「运行期间任何一个请求返回 401,立刻锁定跳登录页。」
*
* 于是这里有**两种锁**,必须分开(混成一种就会违背"一直联网不用重输密码"):
*
* ┌─ need_login(必须重输密码)
* │ · 本地没有令牌
* │ · 服务端回 401(被踢 / 被吊销 / 账号停用)
* │ ⇒ 清掉本地令牌。v58 起还要删加密密钥。
* │
* └─ offline_expired(锁界面,但**不**清令牌)
* · 有令牌,但这台设备已经超过 24 小时没能跟服务器通过话
* ⇒ 只是"先别看了,联网确认一下"。一旦联网校验通过,**自动恢复**,
* 用户不用重新输密码 —— 这正是用户要的"一直联网就不用重登"。
*
* ⚠️ 离线时"过了多久"只能用**本机时钟**算(没网问不到服务器)。
* 所以把手机时间调回去是可以绕过的 —— 这是离线判定的固有缺陷,
* 由**服务端**兜住:`expires_at` 在服务端,联网后一律按服务端时间判。
* 端上这个计时只负责"离线期间别一直敞着"。
*/
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { fbApi, FbApiResult } from './FbHttp';
import { FB_TOKEN_STORE } from './FbTokenStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbSession';
const STORE_NAME: string = 'fb_session';
const KEY_VERIFIED_AT: string = 'verified_at_ms';
const KEY_SERVER_TIME: string = 'server_time';
/**
* 离线宽限:超过这么久没跟服务器通过话,就锁界面(用户定的 24 小时)。
*
* ⚠️ 与令牌的 24h 滑动窗口是**同一个数**,但不是同一件事:
* 令牌由服务端续期;这里量的是"端上最后一次成功校验距今多久"。
*/
export const OFFLINE_LIMIT_MS: number = 24 * 3600 * 1000;
/** 会话状态码 */
export const SESSION_OK: string = 'ok'; // 可用
export const SESSION_NEED_LOGIN: string = 'need_login'; // 必须重新登录
export const SESSION_OFFLINE: string = 'offline_expired'; // 离线超时,锁界面
/** `/me` 里我们用得到的字段 */
interface FbMeDevice {
expires_at?: string | null;
ttl_hours?: number;
id?: string;
name?: string;
}
interface FbMePayload {
server_time?: string;
device?: FbMeDevice;
}
/** 一次校验的结果 */
export interface FbSessionCheck {
/** 见上面的 SESSION_* 常量 */
state: string;
/** 给人看的原因('' = 正常) */
reason: string;
/** 服务端时间(ISO8601;拿不到为空) */
serverTime: string;
/** 续期后的令牌到期时间(拿不到为空) */
expiresAt: string;
}
class FbSessionImpl {
private store: preferences.Preferences | null = null;
private verifiedAtMs: number = 0;
private serverTime: string = '';
private ready: boolean = false;
/** 启动时预加载(与其它 store 同构) */
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(KEY_VERIFIED_AT, 0);
this.verifiedAtMs = typeof v === 'number' ? v : 0;
const s: preferences.ValueType = await this.store.get(KEY_SERVER_TIME, '');
this.serverTime = typeof s === 'string' ? s : '';
this.ready = true;
} catch (err) {
hilog.warn(DOMAIN, TAG, '会话记录预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
/** 预加载是否完成 */
isReady(): boolean {
return this.ready;
}
/** 最后一次成功校验的时间(毫秒;0 = 从没校验过) */
lastVerifiedAt(): number {
return this.verifiedAtMs;
}
/** 离线已经多久(毫秒)。从没校验过时返回一个"足够大"的值 */
offlineMs(): number {
if (this.verifiedAtMs <= 0) {
return OFFLINE_LIMIT_MS + 1;
}
const d: number = Date.now() - this.verifiedAtMs;
return d > 0 ? d : 0;
}
/**
* 记一次"刚刚跟服务器通过话"。
*
* @param serverTime 服务端返回的时间(ISO8601)。**只用来展示/对账**,
* 不参与离线计时 —— 离线计时只能靠本机时钟(见文件头)。
*/
async markVerified(ctx: common.Context, serverTime: string): Promise<void> {
this.verifiedAtMs = Date.now();
if (serverTime !== '') {
this.serverTime = serverTime;
}
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_VERIFIED_AT, this.verifiedAtMs);
await this.store.put(KEY_SERVER_TIME, this.serverTime);
await this.store.flush();
} catch (err) {
// 记不上就记不上:下次启动会算成"离线超时",用户联网一下就恢复
hilog.warn(DOMAIN, TAG, '会话时间保存失败:%{public}s', JSON.stringify(err));
}
}
/** 清掉校验记录(退出登录 / 被踢时) */
async clear(ctx: common.Context): Promise<void> {
this.verifiedAtMs = 0;
this.serverTime = '';
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.delete(KEY_VERIFIED_AT);
await this.store.delete(KEY_SERVER_TIME);
await this.store.flush();
} catch (err) {
hilog.warn(DOMAIN, TAG, '会话记录清除失败:%{public}s', JSON.stringify(err));
}
}
/**
* 校验一次会话。
*
* 调用时机(两个,缺一不可):
* ① **每次启动**(用户 2026-10-17 明确要求);
* ② 前台恢复 / 用户手动"刷新"时(可选,见 Index)。
*
* ⚠️ 这里传 `authProbe: true`:401 由本方法自己处理(清令牌 + 返回 need_login),
* 不让网络层的全局回调再踢一次 —— 否则会出现"两个地方同时跳登录页"。
*/
async check(ctx: common.Context): Promise<FbSessionCheck> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
return { state: SESSION_NEED_LOGIN, reason: '还没登录', serverTime: '', expiresAt: '' };
}
const r: FbApiResult = await fbApi('/api/v1/me', { token: token, authProbe: true });
if (r.status === 200 && r.ok) {
let payload: FbMePayload = {};
try {
payload = JSON.parse(r.data) as FbMePayload;
} catch (e) {
payload = {};
}
const st: string = payload.server_time !== undefined ? payload.server_time : '';
await this.markVerified(ctx, st);
const exp: string = (payload.device !== undefined && payload.device.expires_at !== null
&& payload.device.expires_at !== undefined) ? payload.device.expires_at : '';
return { state: SESSION_OK, reason: '', serverTime: st, expiresAt: exp };
}
if (r.status === 401) {
// 被踢 / 被吊销 / 过期 / 账号停用 —— 一律当"必须重新登录"
await FB_TOKEN_STORE.clear(ctx);
await this.clear(ctx);
hilog.info(DOMAIN, TAG, '令牌已失效(可能被网页端远程移除了设备)');
return {
state: SESSION_NEED_LOGIN,
reason: '这台设备已经被退出登录,请重新登录',
serverTime: '',
expiresAt: ''
};
}
/* 网络层失败(断网/超时/服务器挂了)—— **不能**当成"被踢",
否则用户坐一趟飞机回来就得重输密码。
只按"离线多久"决定要不要锁。 */
const off: number = this.offlineMs();
if (off > OFFLINE_LIMIT_MS) {
return {
state: SESSION_OFFLINE,
reason: '已经超过 24 小时没能连上服务器,需要联网确认一次',
serverTime: this.serverTime,
expiresAt: ''
};
}
return { state: SESSION_OK, reason: '离线可用', serverTime: this.serverTime, expiresAt: '' };
}
}
/** 全局单例 */
export const FB_SESSION: FbSessionImpl = new FbSessionImpl();
+176
View File
@@ -0,0 +1,176 @@
/**
* 家书 FamilyBook · 全量同步(鸿蒙端)
*
* 一次把所有模块都同步一遍。
*
* 🔴 为什么需要它(用户 2026-10-12:"把同步做成全局的,接进来一个模块,
* 就自动同步一个模块"):
* 之前每个页面**自己同步自己**(进入观影页同步观影、进名册页同步名册),
* 结果是"用户必须把每个页面都点一遍才拿到全量数据",
* 而且设置页那个「保存并同步」只同步了观影 —— 一个入口只干一部分活,
* 用户没法理解。
* ⇒ 加这个"全量同步":**一处触发、全部模块都更新**。
*
* ⚠️ 它与"页面各自同步"**不冲突**,两者都要有:
* · 页面各自同步:保证"刚进这一页就有最新数据";
* · 全量同步:保证"不用点遍每一页"。
* 两条路的终点是同一个缓存(`module_cache` / 实体表),所以谁先谁后都对。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbMovieRepo, FbSyncResult } from './FbMovieRepo';
import { FbPersonRepo, FbRosterSyncResult } from './FbPersonRepo';
import { FbModuleLoad, FbModuleRepo } from './FbModuleRepo';
import { FB_MODULE_PATH } from './FbModuleLoad';
import { FB_TOKEN_STORE } from './FbTokenStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbSyncAll';
/** 全量同步结果 */
export interface FbSyncAllResult {
ok: boolean;
/** 成功同步的模块数 */
done: number;
/** 总模块数 */
total: number;
/** 失败模块名(用户看得到的人话) */
failed: string[];
/** 当前进度文案(同步中实时更新用) */
note: string;
}
/**
* 全量同步的实时进度(供 UI 显示)。
*
* ⚠️ 为什么用一个**模块级变量**而不是回调:
* 本机设备 `hilog` 读不出内容(已知限制),
* 同步又可能跑十几秒 —— 页面必须能显示"现在到哪一步了",
* 否则用户以为卡死。用轮询读这个变量的方式最省事(见设置页)。
*/
export let FB_SYNC_ALL_PROGRESS: string = '';
/** 模块的中文名(进度提示用) */
const MODULE_CN: Record<string, string> = {
'movies': '观影',
'roster': '名册',
'calendar': '日程',
'tasks': '任务',
'days': '纪念日',
'trips': '旅程',
'ledger': '账本',
'resources': '资源',
'books': '藏书',
'notes': '笔记',
'notifications': '通知',
'home': '首页',
};
/**
* 进度回调(可选)。
*
* 🔴 为什么加这个参数,而不是让调用方去轮询 `FB_SYNC_ALL_PROGRESS`:
* 模块级 `export let` 的"实时值"依赖打包器是否保留 live binding ——
* 这在 esmodule / 混淆 / tree-shaking 下**不保证**,一旦被内联成常量,
* 进度条就永远停在初始值(而调用方还以为在正常工作)。
* ⇒ 登录页这种"用户正盯着屏幕等"的场景,进度必须**从函数里推出来**。
*
* @param label 正在同步的模块中文名(如「观影」)
* @param done 已完成的模块数(**不含**当前这个)
* @param total 模块总数
*/
export type FbSyncStepCb = (label: string, done: number, total: number) => void;
export async function syncAll(onStep?: FbSyncStepCb): Promise<FbSyncAllResult> {
const token: string = FB_TOKEN_STORE.get();
const failed: string[] = [];
if (token === '') {
const r: FbSyncAllResult = {
ok: false, done: 0, total: 12, failed: [],
note: '未登录,无法同步',
};
FB_SYNC_ALL_PROGRESS = r.note;
return r;
}
let done: number = 0;
const total: number = 12;
/** 跑一个模块并记账 */
const step = async (label: string, work: () => Promise<boolean>): Promise<void> => {
FB_SYNC_ALL_PROGRESS = label;
if (onStep !== undefined) {
onStep(label, done, total);
}
try {
const ok: boolean = await work();
if (ok) {
done += 1;
} else {
failed.push(label);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '同步 %{public}s 失败:%{public}s', label, JSON.stringify(err));
failed.push(label);
}
};
// ---- ① 有实体表的两个模块(先做:它们的数据量大、最该先落地)----
await step(MODULE_CN['movies'], async (): Promise<boolean> => {
const r: FbSyncResult = await FbMovieRepo.inst().sync(token);
return r.ok;
});
await step(MODULE_CN['roster'], async (): Promise<boolean> => {
const r: FbRosterSyncResult = await FbPersonRepo.inst().sync(token);
return r.ok;
});
// ---- ② 其余模块(走通用缓存)----
const cacheModules: string[] = [
'calendar', 'tasks', 'days', 'trips', 'ledger', 'resources', 'books', 'notes',
'notifications', 'home',
];
const repo: FbModuleRepo = FbModuleRepo.inst();
for (const name of cacheModules) {
const label: string = MODULE_CN[name] !== undefined ? MODULE_CN[name] : name;
const path: string = FB_MODULE_PATH[name] !== undefined ? FB_MODULE_PATH[name] : '';
if (path === '') {
continue;
}
await step(label, async (): Promise<boolean> => {
// 这里只关心"有没有成功写进缓存":`load` 的 sink 回调是同步的,
// 用一个 Promise 把它包起来,等到网络那次回来(fromCache=false)才算成功。
const ok: boolean = await new Promise<boolean>((resolve: (v: boolean) => void): void => {
let settled: boolean = false;
repo.load(name, path, (r: FbModuleLoad): void => {
if (r.fromCache || settled) {
return; // 缓存那次不算结果
}
settled = true;
resolve(r.error === '');
});
// 兜底超时(网络卡住时不能让全量同步永远挂着)
setTimeout((): void => {
if (!settled) {
settled = true;
resolve(false);
}
}, 20000);
});
return ok;
});
}
const result: FbSyncAllResult = {
ok: failed.length === 0,
done: done,
total: total,
failed: failed,
note: failed.length === 0
? ('全部 ' + done.toString() + ' 个模块同步完成')
: (done.toString() + '/' + total.toString() + ' 完成,失败:' + failed.join('、')),
};
FB_SYNC_ALL_PROGRESS = result.note;
return result;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* 家书 FamilyBook · API 令牌存储(鸿蒙端)
*
* 只存一件事:移动端 API 令牌(`api_tokens` 表签发的那一串)。
*
* 🔴 为什么用 `preferences` 而不是本地库 RDB:
* 这是**单个键值**(一把令牌),不是"多行记录"。
* KV 存储天生适合,也不必为它建表、迁移。
* 令牌**不参与同步**(服务端 v33 的 53 张业务表里也不含 api_tokens)——
* 它本来就是"这台设备与服务器之间的凭据",各设备各自一份。
*
* ⚠️ 令牌是敏感凭证:
* · 放 `preferences` 会落在应用沙箱内(其它应用读不到),这对家庭应用够用;
* · 若将来要更严(防 root 提取),应迁到 `@ohos.security.asset`(系统密钥库)。
* 现在不做的原因:Asset Store 的 API 面更复杂,而本应用的数据敏感度
* (家庭日程/观影)还不至于需要它。**这是个有意识的取舍,不是遗漏。**
* · ⚠️ 无论如何**不要**把令牌写进日志或错误信息里往外抛。
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbToken';
/** preferences 实例名(沙箱内的文件名) */
const STORE_NAME: string = 'fb_auth';
/** 令牌的键名 */
const KEY_TOKEN: string = 'api_token';
/**
* 令牌存储(同步读、异步写的门面)。
*
* 🔴 为什么提供一个**同步的 `get()`**:
* 页面渲染时经常需要立刻知道"有没有令牌"(决定要不要发起同步)。
* 若只能异步读,每个调用点都要 await,把简单逻辑搞得支离破碎。
* ⇒ 用"启动时预加载进内存 + 写时同时更新内存"的方式提供同步读。
* ⚠️ 前提是**启动时必须先 `init()`**,否则内存里是空的(会误判成"未登录")。
*/
class FbTokenStoreImpl {
private cached: string = '';
private store: preferences.Preferences | null = null;
private ready: boolean = false;
/**
* 预加载令牌进内存。应在应用启动时调用一次(见 EntryAbility)。
* ⚠️ 幂等:重复调用直接返回,不会重复读盘。
*/
async init(ctx: common.Context): Promise<void> {
if (this.ready) {
return;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
const v: preferences.ValueType = await this.store.get(KEY_TOKEN, '');
this.cached = typeof v === 'string' ? v : '';
this.ready = true;
hilog.info(DOMAIN, TAG, '令牌预加载完成,%{public}s',
this.cached === '' ? '未设置' : '已设置');
} catch (err) {
// ⚠️ 读不到就当"没有令牌",**不要**让启动流程挂掉 ——
// 用户仍能用本地数据(本地优先的好处)。
hilog.warn(DOMAIN, TAG, '令牌预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
}
}
/** 同步取令牌(未设置返回空串)。⚠️ 需先 init(),否则会误判为未登录 */
get(): string {
if (!this.ready) {
hilog.warn(DOMAIN, TAG, '令牌还没预加载完,本次读到的可能是空值');
}
return this.cached;
}
/** 是否已设置令牌(同步) */
has(): boolean {
return this.cached !== '';
}
/**
* 预加载是否已完成(同步)。
*
* 🔴 为什么需要它:`get()` 在**没预加载完**时会返回空串(看起来像"未登录")。
* 启动流程里若据此判断,就会出现"明明有令牌却显示登录页"。
* ⇒ 调用方应先用 `isReady()` 确认预加载完成,再做登录态判断。
* (见 Index.bootCheck 里的等待循环。)
*/
isReady(): boolean {
return this.ready;
}
/** 保存令牌(同时更新内存缓存与磁盘) */
async set(ctx: common.Context, token: string): Promise<boolean> {
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.put(KEY_TOKEN, token);
// ⚠️ `put` 只写内存,**必须 `flush`** 才落盘 ——
// 不 flush 的话进程被杀就丢了(表现为"登录后又变未登录")。
await this.store.flush();
this.cached = token;
this.ready = true;
hilog.info(DOMAIN, TAG, '令牌已保存');
return true;
} catch (err) {
hilog.error(DOMAIN, TAG, '令牌保存失败:%{public}s', JSON.stringify(err));
return false;
}
}
/** 清除令牌(登出) */
async clear(ctx: common.Context): Promise<void> {
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.delete(KEY_TOKEN);
await this.store.flush();
this.cached = '';
hilog.info(DOMAIN, TAG, '令牌已清除');
} catch (err) {
hilog.error(DOMAIN, TAG, '令牌清除失败:%{public}s', JSON.stringify(err));
}
}
}
/** 全局单例 */
export const FB_TOKEN_STORE: FbTokenStoreImpl = new FbTokenStoreImpl();
+251 -26
View File
@@ -1,7 +1,15 @@
import { AbilityConstant, Configuration, ConfigurationConstant, EnvironmentCallback,
UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { UIContext, display, window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_SERVER_STORE } from '../data/FbServerStore';
import { FB_DEVICE_STORE } from '../data/FbDeviceStore';
import { FB_SESSION } from '../data/FbSession';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbDb } from '../data/FbDb';
import { FB_MODULE_TOKEN } from '../data/FbModuleRepo';
const DOMAIN: number = 0x0000;
@@ -18,6 +26,57 @@ export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onCreate');
// 🔴 预加载 API 令牌进内存。
// 页面里需要**同步**判断"有没有登录"(决定要不要发起同步),
// 所以必须在页面出现之前把令牌读进内存(见 FbTokenStore 的说明)。
// ⚠️ 不 await:读盘几百毫秒,不该挡住启动;页面那边读到空值也只是
// "这次不同步",本地数据照样能看(本地优先的好处)。
FB_TOKEN_STORE.init(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '令牌预加载失败:%{public}s', JSON.stringify(err));
});
// 🔴 v55:预加载**服务器地址**(用户 2026-10-17「第一次打开输入服务器地址」)。
// 理由与令牌相同:`fbApi()` 是同步拼 URL 的,不可能为了地址改成 async。
// ⚠️ 顺序很重要 —— 地址没读出来时发请求会直接判成"没配服务器"
// (见 `fbApi` 的 `no_server` 分支)。页面那边用 `isReady()` 等它。
FB_SERVER_STORE.init(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '服务器地址预加载失败:%{public}s', JSON.stringify(err));
});
// 🔴 v57(用户 2026-10-17):预加载**设备身份**与会话时间。
// 设备身份要同步读(登录请求体里必须带 device_id),
// 所以同样得在页面出现之前准备好;首次启动会在这里生成 UUID 并落盘。
// ⚠️ 会话时间(上次校验成功的那一刻)也在这里读 —— 离线时判定
// "超过 24 小时没联网"就靠它(见 FbSession)。
FB_DEVICE_STORE.init(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '设备身份初始化失败:%{public}s', JSON.stringify(err));
});
FB_SESSION.init(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '会话记录预加载失败:%{public}s', JSON.stringify(err));
});
// 🔴 把"令牌读取器"注入给通用模块仓库。
// 为什么在这里做(而不是让仓库自己 import 令牌):
// 仓库与令牌存储之间会形成**循环 import**(两者都依赖 FbDb)。
// 用"启动时注入一个读取函数"把依赖方向固定成单向。
FB_MODULE_TOKEN.set((): string => FB_TOKEN_STORE.get());
// 🔴 日程设置的预加载(用户 2026-10-15 要求日程页照 SyncCalendar 那套,
// 含"默认视图 / 日程显示方式"这类偏好)。
// 同样**必须同步可读**:日程页一渲染就要决定用月/周/列表哪种视图,
// 若只能 await,每个调用点都要 async(同 FbTokenStore 的理由)。
FB_CAL_SETTINGS.init(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '日程设置预加载失败:%{public}s', JSON.stringify(err));
});
// 🔴 启动就**打开本地库**(而不是等某个页面自己打开)。
// 理由:所有模块都要读缓存,谁先进入都得等库就绪;
// 在入口统一打开一次,页面侧只管用(`FbDb.require()`)。
// ⚠️ 不 await:开库是异步的,挡住启动会让首帧变慢;
// 页面里若库还没好,`isReady()` 会返回 false、那次只走网络。
FbDb.inst().open(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '本地库打开失败:%{public}s', JSON.stringify(err));
});
}
onDestroy(): void {
@@ -33,19 +92,55 @@ export default class EntryAbility extends UIAbility {
hilog.error(DOMAIN, 'FamilyBook', 'setColorMode failed: %{public}s', JSON.stringify(err));
}
// ② 沉浸式:系统栏**全透明**,让应用自己的背景透上去。
// ② 沉浸式(**全沉浸**):系统栏全透明 + 窗口布局铺满整屏。
//
// 🔴 用户 2026-10-13 定的形态(照 IT 之家那类资讯应用):
// 「当界面开始从下往上滑的时候,它是连带着连通知栏甚至都好像是自己的
// 这个应用的其中的一部分,就直接滑上去了。然后下面也没有任何的这个东西,
// 也没有任何的条啊什么之类的,就是感觉这个内容是从这个手机的边缘直接滑上去的。」
//
// 要实现这个,**必须三件事同时成立**(缺一个都出不来效果):
// ① `setWindowLayoutFullScreen(true)` —— 窗口**不再为系统栏让位**,
// 内容才可能画到状态栏/手势条区域里去。只用 `expandSafeArea`
// 只能让"背景色"延伸,**内容本身还是被挡在安全区里**,滑不到顶。
// ② 系统栏**全透明** —— 状态栏只是"一层透明玻璃",内容从它下面穿过;
// 它的前景色(时间/信号图标)按背景亮度自动取深色或浅色。
// ③ **隐藏底部导航指示条** —— 用户明确说"下面也没有任何的这个东西,
// 也没有任何的条"。手势条藏掉,内容才真的是"从手机边缘滑上去"。
//
// ⚠️ 之前用的是"系统栏涂成实心白 + 内容留在安全区"那套 —— 那是**另一种**
// 设计(状态栏与顶栏同色、分层不穿透),做不出"跟着内容一起滑走"的效果。
// 两者是互斥的两条路线,这里按用户的最新要求换成全沉浸。
//
// ⚠️ 全屏之后窗口**不再避让状态栏**,所以页面必须自己知道状态栏有多高
// (见下面的 `measureSafeArea`)—— 否则首屏标题会被时间/信号压住。
try {
const win: window.Window = windowStage.getMainWindowSync();
this.mainWindow = win;
// ⚠️ 这里**不调** `setWindowLayoutFullScreen(true)`。
// 本机另一个应用(SyncCalendar)用的是"透明系统栏 + expandSafeArea"那套,
// 实测效果更干净:窗口布局不动(子组件位置不受影响),
// 只把系统栏涂成全透明、让窗口底色透出来 ⇒ 顶部通知栏与页面**同色**,
// 页面侧也不用自己去算状态栏高度、加 padding。
// 之前用 setWindowLayoutFullScreen 时,窗口真的铺到状态栏底下,
// 页面反而要手动让位,容易算错(踩过一次"重复避让"的坑)。
AppStorage.setOrCreate<window.Window>('fbMainWindow', win);
this.applySystemBars(win);
// 🔴 量状态栏高度**必须在开全屏之前**(顺序绝不能反)。
//
// 为什么(这是本轮实测踩出来的真根因):
// `getWindowAvoidArea(TYPE_SYSTEM)` 返回的是"**窗口需要避让的区域**"。
// 开了 `setWindowLayoutFullScreen(true)` 之后窗口**不再避让系统栏**,
// ⇒ 这个"待避让区域"就是空的。
// 所以"先开全屏、再量"拿到的一直是 **0** —— 那不是说"状态栏高度是 0",
// 而是"现在不需要避让"(这正是全屏的意义)。
// ⇒ 之前顶栏 `padding.top = 0 + 4`,图标和"家书"就直接压进状态栏了。
this.measureSafeArea(win);
// ① 布局铺满整屏(API 9 起可用,未废弃)
win.setWindowLayoutFullScreen(true).then((): void => {
// ②③ 必须在全屏**生效之后**再设,否则会被全屏重置掉。
// ⚠️ 这里**不重新量**状态栏高度(全屏后量不到,见上面的说明)。
this.applySystemBars(win);
}).catch((err: BusinessError): void => {
hilog.error(DOMAIN, 'FamilyBook', 'setWindowLayoutFullScreen failed: %{public}s',
JSON.stringify(err));
// 兜底:全屏失败也要把系统栏设成透明,至少不退化成"实心色带"
this.applySystemBars(win);
});
} catch (err) {
hilog.error(DOMAIN, 'FamilyBook', 'immersive setup failed: %{public}s', JSON.stringify(err));
}
@@ -126,34 +221,164 @@ export default class EntryAbility extends UIAbility {
}
/**
* 系统栏配色。
* 系统栏配色 —— **全沉浸版**:系统栏一律**全透明**。
*
* 用户 2026-10-12 定的要求:
* ① 家书标题栏用**浅色(白)**;
* ② 上面通知栏也是**浅色**,两者融为一体。
* 🔴 用户 2026-10-13 要"通知栏像是应用自己的一部分":
* 唯一能做到这一点的就是把状态栏设成**透明**,让底下的内容(`FbTopBar` 的白底)
* 一直透上去。这样内容上滑时,状态栏区域显示的就是"当前滑到那里的内容" ——
* 视觉上它和通知栏是一体的。
*
* ⇒ 做法:把状态栏设成与顶栏**同一个颜色**(`surface` = 白)。
* 这是一个"配置出来的"同色,比"让系统栏透明、再指望紧邻元素恰好同色"稳得多 ——
* 后者一旦顶栏改了底色就会立刻出现色带(已经踩过一次)。
* ⚠️ **不能给状态栏写死颜色**(哪怕取"顶栏底色")——状态栏是独立一层,
* 写死就永远是那条色带,内容永远滑不进它里面。
* (旧版正是这么做的,所以怎么调都不"沉浸"。)
*
* ⚠️ 底部导航条取**页面底色**(宣纸米),与页面下部同色,不突兀。
* ⚠️ 取色走 resourceManager(经 dark/ 限定符解析)—— 深色模式一并生效。
* ⚠️ 前景色(时间/信号、手势条)按**当前背景亮度**自动取深/浅:
* 页面底色是米色(亮)⇒ 取深色图标。
* 页面是浅色的应用,前景色固定深色即可,不必跟随系统深浅色;
* 但深色模式下底色会变深,所以这里仍然按实际底色算一次。
*/
private applySystemBars(win: window.Window): void {
const surfaceBg: string = this.themeColor($r('app.color.surface').id, '#FFFFFF');
const paperBg: string = this.themeColor($r('app.color.page_bg').id, '#F7F3EA');
const fg: string = this.isDarkBg(paperBg) ? '#FFFFFF' : '#2C231D';
try {
win.setWindowSystemBarProperties({
// 顶部:与顶栏同色(白)⇒ 通知栏与标题栏融为一体
statusBarColor: surfaceBg,
statusBarContentColor: this.isDarkBg(surfaceBg) ? '#FFFFFF' : '#2C231D',
// 底部:与页面底色同色
navigationBarColor: paperBg,
navigationBarContentColor: this.isDarkBg(paperBg) ? '#FFFFFF' : '#2C231D'
// ① 系统栏**全透明**('#00000000' 八位色值:前两位 alpha=00)
statusBarColor: '#00000000',
navigationBarColor: '#00000000',
statusBarContentColor: fg,
navigationBarContentColor: fg,
});
} catch (err) {
hilog.error(DOMAIN, 'FamilyBook', 'setSystemBarProperties failed: %{public}s',
JSON.stringify(err));
}
// ③ 隐藏底部**手势导航条**。
// 🔴 用户明确说「下面也没有任何的这个东西,也没有任何的条啊什么之类的」。
// ⚠️ `setSpecificSystemBarEnabled` 是 **API 11+**(本工程 compatibleSdk = 24,够用)。
// ⚠️ 只关 'navigationIndicator'(那根手势小横条),**不关 'navigation'** ——
// 后者是"三键导航栏",把它也关掉会让**用三键导航的用户没有返回键**,
// 属于把用户困在应用里的改动。手势条是视觉装饰,藏掉才是纯粹的观感优化。
// ⚠️ 失败不致命(比如设备本来就没手势条):只记日志,不让它影响启动。
win.setSpecificSystemBarEnabled('navigationIndicator', false)
.catch((err: BusinessError): void => {
hilog.warn(DOMAIN, 'FamilyBook', '隐藏手势条失败(不影响使用):%{public}s',
JSON.stringify(err));
});
}
/**
* 量出状态栏高度(vp)并存进全局状态,供各页"给滚动内容的第一行垫高"。
*
* ⚠️ 为什么必须在**全屏生效之后**量:
* 不开全屏时窗口本身就避让了状态栏、`getWindowAvoidArea(TYPE_SYSTEM).topRect` 会是 0
* ⇒ 那时量到的 0 是"不需要避让",不是"状态栏高度是 0"。
* 开了全屏才会返回真实高度。
* ⚠️ 返回的是 **px**,要 `px2vp()` 换成 vp 再用(ArkUI 的尺寸单位是 vp)。
* ⚠️ 只在启动时量一次:状态栏高度在运行期不会变(横竖屏切换由 ArkUI 自己重排,
* 我们这套布局是纵向滚动,不受影响)。
*/
private measureSafeArea(win: window.Window): void {
const topPx: number = this.avoidTopPx(win);
const topVp: number = this.toVp(win, topPx);
AppStorage.setOrCreate<number>('fbStatusBarH', topVp);
hilog.info(DOMAIN, 'FamilyBook', '状态栏高度:%{public}dpx / %{public}dvp', topPx, topVp);
}
/**
* 取"顶部需要避让的高度"(px)。
*
* ⚠️ **必须在 `setWindowLayoutFullScreen(true)` 之前调用** —— 全屏后
* `getWindowAvoidArea` 返回空(窗口已不需避让),量到恒为 0。
*
* 三种口径依次尝试,取**最大**的一个:
* ① `TYPE_SYSTEM` —— 状态栏(常规来源);
* ② `TYPE_CUTOUT` —— 挖孔/刘海。它返回的是"**整块天线区**"的高度,
* 比状态栏略高,**正是要的**(图标不该顶到挖孔里);
* ③ `getWindowAvoidAreaIgnoringVisibility`(**API 22+**)—— 前两者都拿不到时的兜底。
* ⚠️ 用 `max` 而不是"谁先成功用谁":三者量的可能是同一块区域的不同表述,
* 取最大能保证**绝不偏小** —— 偏小会让图标压进状态栏(就是这个 bug),
* 偏大只是多几 vp 的留白(观感无碍)。
*/
private avoidTopPx(win: window.Window): number {
let best: number = 0;
try {
const sys: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
if (sys.visible && sys.topRect.height > best) {
best = sys.topRect.height;
}
} catch (err) {
hilog.warn(DOMAIN, 'FamilyBook', 'TYPE_SYSTEM 取不到:%{public}s', JSON.stringify(err));
}
try {
const cut: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_CUTOUT);
if (cut.visible && cut.topRect.height > best) {
best = cut.topRect.height;
}
} catch (err) {
hilog.warn(DOMAIN, 'FamilyBook', 'TYPE_CUTOUT 取不到:%{public}s', JSON.stringify(err));
}
if (best <= 0) {
// 兜底:API 22+ 的"无视可见性"版。本工程 compatibleSdk = 24,可用。
// ⚠️ 用 try 包住是必需的:低版本设备上这个方法不存在,会抛 801。
try {
const ign: window.AvoidArea =
win.getWindowAvoidAreaIgnoringVisibility(window.AvoidAreaType.TYPE_SYSTEM);
if (ign.topRect.height > best) {
best = ign.topRect.height;
}
} catch (err) {
hilog.warn(DOMAIN, 'FamilyBook', 'IgnoringVisibility 取不到:%{public}s',
JSON.stringify(err));
}
}
return best;
}
/**
* px → vp。
*
* ⚠️ 拿不到 `UIContext` 时回落到 **1**(不换算)——
* 按本机密度 3 算,换算后是 1/3,直接当作 vp 用会**偏小**(标题贴太紧,但可用);
* 若这里凭空乘个 3 反而可能**偏大**、把标题推到屏幕中间。
* 宁可保守偏小,也不要因为一次量不准而毁掉首屏。
*/
/**
* px → vp。
*
* ⚠️ 两条路径,必须都有:
* ① `UIContext.px2vp()` —— 首选。但它**依赖 UI 内容已经加载**;
* 而本方法是**在 `loadContent` 之前**调用的(量状态栏必须在开全屏之前),
* 此时 `getUIContext()` 很可能还是 undefined。
* ② **按屏幕密度换算**:`px / densityPixels = vp`。
* 这条不依赖 UI,任何时刻都可用 —— 也是实际生效的那条。
*
* ⚠️ **不要再退回"返回 0"**:那会让顶栏完全不让位、
* 图标和标题压进状态栏(这个 bug 就是这么来的)。
* 宁可走密度换算(结果精确),也不要 0。
*/
private toVp(win: window.Window, px: number): number {
if (px <= 0) {
return 0;
}
try {
const ui: UIContext | undefined = win.getUIContext();
if (ui !== undefined) {
return ui.px2vp(px);
}
} catch (err) {
hilog.warn(DOMAIN, 'FamilyBook', 'UIContext.px2vp 失败,改用密度换算:%{public}s',
JSON.stringify(err));
}
// ② 密度换算(vp = px / density)
try {
const density: number = display.getDefaultDisplaySync().densityPixels;
if (density > 0) {
return px / density;
}
} catch (err) {
hilog.warn(DOMAIN, 'FamilyBook', '读屏幕密度失败:%{public}s', JSON.stringify(err));
}
return 0;
}
}
+49 -75
View File
@@ -12,10 +12,10 @@
* ⚠️ 全文件统一 `Fb` 前缀命名,避免与其他文件顶层名冲突(arkts-no-decl-merging)。
*/
import { FbAccount, FbAnniversaryItem, FbBookItem, FbCalBook, FbCalEvent, FbGroup,
FbKinChip, FbKinRelation, FbLedgerBook, FbLedgerEntry, FbModuleCard, FbMovieItem,
import { FbAccount, FbBookItem, FbCalBook, FbCalEvent, FbGroup,
FbKinChip, FbKinRelation, FbLedgerBook, FbLedgerEntry, FbMovieItem,
FbNoteItem, FbNotificationItem, FbResourceItem, FbRosterItem, FbSession,
FbTaskItem, FbTripItem, FbTripNode, FbWeather, FbWeekStat } from './Types';
FbTaskItem, FbTripItem, FbTripNode } from './Types';
/** 假数据里的「我」 */
export const FB_DEMO_ME: FbAccount = {
@@ -42,65 +42,38 @@ function fbChip(id: number, shown: string, kinship: string, color: string): FbKi
return { id: id, shown: shown, kinship: kinship, avatarColor: color, isAdmin: false };
}
/** 首页「我的家人」 */
export const FB_DEMO_KIN: FbKinChip[] = [
fbChip(2, '爱人', '配偶', '#9A5C7A'),
fbChip(3, '儿子', '儿子', '#4A7C82'),
fbChip(4, '女儿', '女儿', '#6B5B95'),
fbChip(5, '爸爸', '父亲', '#8A6B4B'),
fbChip(6, '妈妈', '母亲', '#B0763A'),
fbChip(7, '弟弟', '弟弟', '#3F7D5A')
];
/** 首页天气 */
export const FB_DEMO_WEATHER: FbWeather = {
place: '青岛市 · 崂山区',
temp: 21,
text: '多云',
wind: '东南风 3 级'
};
/** 首页近七天统计 */
export const FB_DEMO_WEEK_STAT: FbWeekStat = {
events: 11,
todos: 4,
overdue: 1
};
/** 首页日子标记 */
export const FB_DEMO_MARKS: string[] = ['寒露', '重阳节', '儿子生日'];
/** 首页模块卡片(顺序与 Web 端 Module::DEFAULT_NAV 一致) */
export const FB_DEMO_MODULE_CARDS: FbModuleCard[] = [
{ key: 'people', name: '名册', big: '12 人', lines: ['1089 人在这个家里'], color: '#4A7C82' },
{ key: 'movies', name: '观影', big: '86 部', lines: ['最近看了《漫长的季节》'], color: '#7A4E8C' },
{ key: 'trips', name: '旅程', big: '9 次', lines: ['最近去了青岛·崂山'], color: '#3F7D5A' },
{ key: 'calendar', name: '日程', big: '11 条', lines: ['未来七天'], color: '#B0763A' },
{ key: 'resources', name: '资源', big: '37 项', lines: ['证件 / 资料 / 附件'], color: '#5566A8' },
{ key: 'tasks', name: '任务', big: '3 件在做', lines: ['4 件待办 · 1 件逾期'], color: '#A8574F' },
{ key: 'days', name: '纪念日', big: '6 天后', lines: ['结婚周年'], color: '#B4633C' },
{ key: 'ledger', name: '财务', big: '¥86.4 万', lines: ['3 本账(净资产)'], color: '#5E7A3C' },
{ key: 'books', name: '读书', big: '2 本在读', lines: ['31 本读完 · 48 条笔记'], color: '#6B5B95' },
{ key: 'notes', name: '笔记', big: '63 条', lines: ['最近:阳台改造清单'], color: '#7A6A4F' }
];
/* ============================================================
* 🔴 v54(用户 2026-10-16)—— 首页的演示常量**全部删掉**:
* `FB_DEMO_KIN`(我的家人)/ `FB_DEMO_MARKS`(寒露·重阳节·儿子生日)/
* `FB_DEMO_WEATHER` / `FB_DEMO_WEEK_STAT` / `FB_DEMO_MODULE_CARDS`。
*
* 用户原话:「里面的数据好像有些不符,比如**寒露、重阳节、儿子的生日**,
* 这些都是**乱写的**,要用**真实数据**填写一下。」
* —— 那三个字面值就是 `FB_DEMO_MARKS`,它一直显示给真实用户看,
* 因为 `/api/v1/home` 当时**根本没有**农历/标记/天气/统计这几样字段。
*
* ⇒ 服务端已补齐(见 `ApiHandler::home()`),端上全部改接真实数据。
* ⚠️ **不要再往这个文件加首页的演示常量** —— 首页现在没有任何"占位",
* 接口没给就不画那一块(这比画一个假的好)。
* ============================================================ */
// ============================================================
// 名册
// ============================================================
export const FB_DEMO_ROSTER: FbRosterItem[] = [
{ id: 1, name: '杨永全', kinship: '我', group: '本家', tags: ['管理员'], avatarColor: '#A84A28', roster: 'member', birthday: '03-12', isAdmin: true },
{ id: 2, name: '李慧', kinship: '配偶', group: '本家', tags: [], avatarColor: '#9A5C7A', roster: 'member', birthday: '07-08', isAdmin: false },
{ id: 3, name: '杨小满', kinship: '儿子', group: '本家', tags: ['学生'], avatarColor: '#4A7C82', roster: 'member', birthday: '10-18', isAdmin: false },
{ id: 4, name: '杨小禾', kinship: '女儿', group: '本家', tags: ['学生'], avatarColor: '#6B5B95', roster: 'member', birthday: '01-04', isAdmin: false },
{ id: 5, name: '杨建国', kinship: '父亲', group: '长辈', tags: ['退休'], avatarColor: '#8A6B4B', roster: 'member', birthday: '05-21', isAdmin: false },
{ id: 6, name: '王秀兰', kinship: '母亲', group: '长辈', tags: [], avatarColor: '#B0763A', roster: 'member', birthday: '11-30', isAdmin: false },
{ id: 7, name: '杨永强', kinship: '弟弟', group: '本家', tags: [], avatarColor: '#3F7D5A', roster: 'member', birthday: '08-15', isAdmin: false },
{ id: 8, name: '李国栋', kinship: '岳父', group: '姻亲', tags: [], avatarColor: '#5566A8', roster: 'member', birthday: '02-26', isAdmin: false },
{ id: 9, name: '陈静', kinship: '岳母', group: '姻亲', tags: [], avatarColor: '#A8574F', roster: 'member', birthday: '09-09', isAdmin: false },
{ id: 10, name: '张伟', kinship: '朋友', group: '亲友', tags: ['同事'], avatarColor: '#5E7A3C', roster: 'friend', birthday: '06-14', isAdmin: false },
{ id: 11, name: '刘敏', kinship: '朋友', group: '亲友', tags: ['同学'], avatarColor: '#7A6A4F', roster: 'friend', birthday: '12-02', isAdmin: false },
{ id: 12, name: '赵鹏', kinship: '朋友', group: '亲友', tags: ['邻居'], avatarColor: '#3D6B8E', roster: 'friend', birthday: '04-17', isAdmin: false }
{ id: 1, name: '杨永全', kinship: '我', group: '本家', tags: ['管理员'], avatarColor: '#A84A28', roster: 'member', birthday: '03-12', avatar: '', phone: '', initials: 'x', isAdmin: true, phones: [] },
{ id: 2, name: '李慧', kinship: '配偶', group: '本家', tags: [], avatarColor: '#9A5C7A', roster: 'member', birthday: '07-08', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 3, name: '杨小满', kinship: '儿子', group: '本家', tags: ['学生'], avatarColor: '#4A7C82', roster: 'member', birthday: '10-18', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 4, name: '杨小禾', kinship: '女儿', group: '本家', tags: ['学生'], avatarColor: '#6B5B95', roster: 'member', birthday: '01-04', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 5, name: '杨建国', kinship: '父亲', group: '长辈', tags: ['退休'], avatarColor: '#8A6B4B', roster: 'member', birthday: '05-21', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 6, name: '王秀兰', kinship: '母亲', group: '长辈', tags: [], avatarColor: '#B0763A', roster: 'member', birthday: '11-30', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 7, name: '杨永强', kinship: '弟弟', group: '本家', tags: [], avatarColor: '#3F7D5A', roster: 'member', birthday: '08-15', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 8, name: '李国栋', kinship: '岳父', group: '姻亲', tags: [], avatarColor: '#5566A8', roster: 'member', birthday: '02-26', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 9, name: '陈静', kinship: '岳母', group: '姻亲', tags: [], avatarColor: '#A8574F', roster: 'member', birthday: '09-09', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 10, name: '张伟', kinship: '朋友', group: '亲友', tags: ['同事'], avatarColor: '#5E7A3C', roster: 'friend', birthday: '06-14', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 11, name: '刘敏', kinship: '朋友', group: '亲友', tags: ['同学'], avatarColor: '#7A6A4F', roster: 'friend', birthday: '12-02', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
{ id: 12, name: '赵鹏', kinship: '朋友', group: '亲友', tags: ['邻居'], avatarColor: '#3D6B8E', roster: 'friend', birthday: '04-17', avatar: '', phone: '', initials: 'x', isAdmin: false, phones: [] },
];
/** 家谱连线(父母 / 配偶 / 子女) */
@@ -166,17 +139,17 @@ export const FB_DEMO_CAL_BOOKS: FbCalBook[] = [
];
export const FB_DEMO_EVENTS_TODAY: FbCalEvent[] = [
{ id: 1, title: '儿子数学兴趣班', date: '2026-10-11', time: '09:00', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '小区活动中心' },
{ id: 2, title: '家庭大扫除', date: '2026-10-11', time: '10:30', calName: '家庭日程', color: '#B0763A', allDay: false, place: '家' },
{ id: 3, title: '晚上看电影', date: '2026-10-11', time: '19:30', calName: '观影', color: '#7A4E8C', allDay: false, place: '家里' },
{ id: 4, title: '妈妈体检预约', date: '2026-10-11', time: '', calName: '家庭日程', color: '#B0763A', allDay: true, place: '市立医院' }
{ id: 1, title: '儿子数学兴趣班', date: '2026-10-11', time: '09:00', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '小区活动中心', calId: 0, endTime: '', note: '', kind: 'custom' },
{ id: 2, title: '家庭大扫除', date: '2026-10-11', time: '10:30', calName: '家庭日程', color: '#B0763A', allDay: false, place: '家', calId: 0, endTime: '', note: '', kind: 'custom' },
{ id: 3, title: '晚上看电影', date: '2026-10-11', time: '19:30', calName: '观影', color: '#7A4E8C', allDay: false, place: '家里', calId: 0, endTime: '', note: '', kind: 'custom' },
{ id: 4, title: '妈妈体检预约', date: '2026-10-11', time: '', calName: '家庭日程', color: '#B0763A', allDay: true, place: '市立医院', calId: 0, endTime: '', note: '', kind: 'custom' },
];
export const FB_DEMO_EVENTS_SOON: FbCalEvent[] = [
{ id: 11, title: '女儿钢琴课', date: '10-12', time: '16:00', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '琴行' },
{ id: 12, title: '结婚周年', date: '10-17', time: '', calName: '家庭日程', color: '#B0763A', allDay: true, place: '' },
{ id: 13, title: '周末露营', date: '10-18', time: '08:00', calName: '家庭日程', color: '#B0763A', allDay: false, place: '仰口' },
{ id: 14, title: '家长会', date: '10-20', time: '18:30', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '学校' }
{ id: 11, title: '女儿钢琴课', date: '10-12', time: '16:00', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '琴行', calId: 0, endTime: '', note: '', kind: 'custom' },
{ id: 12, title: '结婚周年', date: '10-17', time: '', calName: '家庭日程', color: '#B0763A', allDay: true, place: '', calId: 0, endTime: '', note: '', kind: 'custom' },
{ id: 13, title: '周末露营', date: '10-18', time: '08:00', calName: '家庭日程', color: '#B0763A', allDay: false, place: '仰口', calId: 0, endTime: '', note: '', kind: 'custom' },
{ id: 14, title: '家长会', date: '10-20', time: '18:30', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '学校', calId: 0, endTime: '', note: '', kind: 'custom' },
];
// ============================================================
@@ -196,17 +169,18 @@ export const FB_DEMO_TASKS: FbTaskItem[] = [
/** 任务看板(tasks/settings) */
export const FB_DEMO_TASK_BOARDS: string[] = ['家务', '家人健康', '孩子', '车与房'];
// ============================================================
// 纪念日
// ============================================================
export const FB_DEMO_ANNIVERSARIES: FbAnniversaryItem[] = [
{ id: 1, title: '结婚周年', date: '10-17', daysLeft: 6, kind: 'anniversary', people: [fbChip(1, '我', '我', '#A84A28'), fbChip(2, '爱人', '配偶', '#9A5C7A')], yearly: true },
{ id: 2, title: '儿子生日', date: '10-18', daysLeft: 7, kind: 'birthday', people: [fbChip(3, '儿子', '儿子', '#4A7C82')], yearly: true },
{ id: 3, title: '重阳节', date: '10-29', daysLeft: 18, kind: 'holiday', people: [], yearly: true },
{ id: 4, title: '爸爸生日', date: '05-21', daysLeft: 222, kind: 'birthday', people: [fbChip(5, '爸爸', '父亲', '#8A6B4B')], yearly: true },
{ id: 5, title: '搬家纪念日', date: '2019-11-08', daysLeft: 28, kind: 'anniversary', people: [], yearly: true }
];
/* ============================================================
* 🔴 v54(用户 2026-10-16)—— `FB_DEMO_ANNIVERSARIES` 已删除。
*
* 用户原话:「里面的数据好像有些不符,比如**寒露、重阳节、儿子的生日**,
* 这些都是**乱写的**,要用**真实数据**填写一下。」
* ("寒露"来自已删的 `FB_DEMO_MARKS`,"重阳节 / 儿子生日"来自这里。)
*
* 它**早已无人引用**(纪念日页接的是 `/api/v1/days`),
* 只是文件里还留着 —— 留着就有被"顺手拿来当占位"的风险
* (这次首页的假数据就是这么来的)。**没有引用就删掉**,
* 比"留着备用"安全。
* ============================================================ */
// ============================================================
// 财务
@@ -0,0 +1,61 @@
/**
* 家书 FamilyBook · 各模块的「设置菜单」条目(鸿蒙端)
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
*
* 这份表**逐条对齐网页版** `templates/<模块>/_settings_menu.php` 里的菜单项
* (那是权威来源;别自己发明条目)。
*
* ⚠️ `action` 为**空串** = 该功能在鸿蒙端**还没做**:
* 界面会画成灰色 + 「暂未开放」,点它**不响应**
* (比"点了没反应"好 —— 后者用户以为是卡了)。
* ⚠️ 补上某个功能时,只要把对应 `action` 填上、并在页面里处理它即可;
* 表本身不用改结构。
*/
import { FbSettingItem } from '../components/FbModuleSettingsSheet';
/** 观影(对应网页版 templates/movies/_settings_menu.php) */
export const FB_SET_MOVIES: FbSettingItem[] = [
{ icon: 'movies', label: '影院管理', hint: '看过的影院,按名字排', action: 'cinema' },
{ icon: 'search', label: '高级搜索', hint: '按年份 / 类型 / 评分筛', action: '' },
{ icon: 'chart', label: '统计', hint: '每年看了多少、类型分布', action: '' },
{ icon: 'notes', label: '年度报告', hint: '一句话总结这一年', action: '' }
];
/** 读书(templates/books/_settings_menu.php) */
export const FB_SET_BOOKS: FbSettingItem[] = [
{ icon: 'chart', label: '读书统计', hint: '每年读了多少、多少页', action: '' },
{ icon: 'plus', label: '导入读书笔记', hint: '从笔记文件批量导入', action: '' },
{ icon: 'share', label: '微信读书导入', hint: '同步微信读书的书单', action: '' }
];
/** 纪念日(templates/days/_settings_menu.php) */
export const FB_SET_DAYS: FbSettingItem[] = [
{ icon: 'filter', label: '纪念日分类', hint: '全家共用一套分类', action: '' }
];
/** 账本(templates/ledger/_settings_menu.php) */
export const FB_SET_LEDGER: FbSettingItem[] = [
{ icon: 'share', label: '共享给谁', hint: '对方就能记能看', action: '' },
{ icon: 'edit', label: '账本名字', hint: '改这本账本的名字', action: '' }
];
/** 任务(templates/tasks/_settings_menu.php) */
export const FB_SET_TASKS: FbSettingItem[] = [
{ icon: 'tasks', label: '分类与看板', hint: '管分类、管看板', action: '' },
{ icon: 'check', label: '已完成', hint: '看已经完成的任务', action: '' }
];
/** 旅程(templates/trips/_settings_menu.php) */
export const FB_SET_TRIPS: FbSettingItem[] = [
{ icon: 'nav', label: '车辆管理', hint: '自驾时的车与油耗', action: '' },
{ icon: 'trips', label: '出行方式', hint: '飞机 / 高铁 / 自驾…', action: '' },
{ icon: 'dot', label: '节点类型', hint: '行程节点的类型', action: '' }
];
/** 资源(网页版没有独立菜单;这里只放已有的入口) */
export const FB_SET_RESOURCES: FbSettingItem[] = [
{ icon: 'filter', label: '格式筛选', hint: '只看某类资源', action: '' }
];
+224 -12
View File
@@ -63,24 +63,75 @@ export interface FbTodayMark {
export interface FbWeather {
/** 地点名 */
place: string;
/** 温度(摄氏度,整数) */
temp: number;
/**
* 温度(**带单位的字符串**,如 `21°C`)。
*
* 🔴 v54:原来是 `number`,但服务端 `weatherOut()` 给的是 `'21°C'`(带单位)——
* 端上按数字解析会得到 NaN。改成字符串**原样承接**,
* 而不是"端上再拼一次单位"(那样单位口径会在两处各写一遍)。
*/
temp: string;
/** 天气描述,如「晴」「多云」 */
text: string;
/** 风向风力,如「东南风 3 级」 */
/**
* 天气**符号**(服务端给的彩色图标字符:☀ / ⛅ / ☂ / ❄ / ⚡ / ≋)。
* ⚠️ 用字符而不是端上图标资源:服务端已经按 WMO 码分好档了
* (晴=暖黄、雨=蓝…),端上再映射一遍必然与网页版飘。
*/
icon: string;
/** 色调档位:sunny|cloud|rain|snow|storm|fog(决定图标用什么颜色) */
tone: string;
/** 风:`东南风 12 km/h`(可为空串) */
wind: string;
/** 风向(`东南风`,可为空串) */
wdir: string;
/** 今日最高 / 最低(`28°`,可为空串) */
tmax: string;
tmin: string;
}
/** 首页统计块(近七天) */
export interface FbWeekStat {
/** 未来七天日程条数 */
events: number;
/** 待办件数 */
todos: number;
/** 逾期件数 */
/**
* 首页概述卡底部的四个数字(与网页版 `hb-stats` 同源)。
*
* 🔴 v54:原来是「未来七天 / 待办 / 逾期」三格,其中第一格是
* "今天起往后 7 天的日程条数"。用户 2026-10-16 说
* 「下面……**未来7天**……这几个就是去掉了,不要了。」
* ⇒ 换成与**网页版概述卡一致**的四格(网页版本来也是这四个):
* 条日程(今天)/ 件在做 / 件待办 / 件逾期。
* ⚠️ 四个数字必须与**下面那块任务列表同源** —— v24 第 25 批用户报过
* 「为什么显示 0 条待办呢?任务看板明明有一个进行中的任务啊」,
* 根因就是"数字与列表两个口径"。服务端为此直接数 `$tasks` 那个数组。
*/
export interface FbStat {
/** 今天的日程条数 */
todayEvents: number;
/** 进行中的任务数 */
doing: number;
/** 待办的任务数 */
todo: number;
/** 已逾期的任务数 */
overdue: number;
}
/**
* 首页「日子标记」:今天是什么节气 / 节日 / 纪念日 / 谁的生日。
*
* 🔴 v54(用户 2026-10-16):「寒露、重阳节、儿子的生日,这些都是**乱写的**,
* 要用**真实数据**填写一下。」
* ⇒ 标记改由服务端 `Home::todayMarks()` 给(与网页版首页**同一份实现**:
* 法定节假日 / 二十四节气 / 传统节日 / 纪念日 / 生日)。
*/
export interface FbMark {
/** 显示词:`寒露` / `国庆节` / `妈妈生日` */
word: string;
/**
* 类别(决定配色,与网页版 `.hb-mark.is-*` 一一对应):
* `holiday` 法定节假日 / `term` 节气 / `festival` 传统节日 /
* `anniv` 纪念日 / `birthday` 生日
*/
kind: string;
}
/** 首页「我的家人」里的一个人(带称谓与称呼) */
export interface FbKinChip {
id: number;
@@ -94,6 +145,32 @@ export interface FbKinChip {
isAdmin: boolean;
}
/**
* 首页「往年的今天」里的一条。
*
* 🔴 v54b(用户 2026-10-17):「近期任务的下面,应该加上**往年的今天**,
* 跟电脑端一样,你看看那个是什么逻辑。」
* 电脑端的逻辑在 `App\Home::memories()`(原来的 `index.php::home_memories()`):
* · **只认同一天**(月与日都相同)—— 用户 2026-10-02 明确过
* 「"10 月 2 日"给我看"去年 10 月 8 日去看了场电影"……不是同一天的事」,
* 所以**不退回同月**;
* · 两类来源:看过的电影(`events.kind='movie'`)、
* 走过的**实际行程**节点(`trip_nodes.stage='actual'`,计划中的不算);
* · 合并按时间倒序、最多 6 条。
*/
export interface FbMemoryItem {
/** 源记录 id(movie 是 events.id,trip_node 是 trip_nodes.id) */
id: number;
/** 标题(旅程节点已在服务端拼成「旅程名 · 节点名」) */
title: string;
/** `YYYY-MM-DD HH:MM:SS` */
startsAt: string;
/** `movie` | `trip_node`(决定图标与圆点色) */
kind: string;
/** 距今几年(服务端算好,`2026` 年的记录 `years_ago=2`) */
yearsAgo: number;
}
/** 首页「模块卡片」—— 一块一块的入口,带一句关键数字 */
export interface FbModuleCard {
/** 模块 key(点击后切到哪个模块) */
@@ -108,6 +185,45 @@ export interface FbModuleCard {
color: string;
}
/**
* 首页数据(`GET /api/v1/home`)。
*
* 🔴 v54(用户 2026-10-16)—— 服务端**补齐**了网页版首页有的那几样。
* 在此之前这里写着"天气、纪念日、周统计是本地算或另拉",而端上其实是拿
* `DemoData` 的演示常量顶着 ⇒ 用户看到「寒露 / 重阳节 / 儿子生日」这种假数据。
* ⇒ 现在起这些全部**由接口给**,端上不再有演示常量。
* ⚠️ 不要改回"端上自己算农历/节气":那些表在服务端是一份权威实现,
* 两端各算一遍早晚会出现"网页说今天小寒、手机说昨天"。
*/
export interface FbHomeData {
/** 「晚上好,今天有 49 件事,慢慢来。」 */
greeting: string;
/** 'YYYY-MM-DD' */
date: string;
/** 「星期一」 */
weekday: string;
/** 抬头日期 `10 月 16 日`(服务端 `today_label`,与网页版同格式) */
todayLabel: string;
/** 农历短写 `八月廿二`(空 = 算不出) */
lunarShort: string;
/** 农历全称(含年份,详情/悬停用) */
lunarLine: string;
/** 日子标记:节气 / 节日 / 纪念日 / 生日(空数组 = 今天没有特别的) */
marks: FbMark[];
/** 天气(`null` = 没配地点或取不到 ⇒ **整块不画**,与网页版一致) */
weather: FbWeather | null;
/** 概述卡底部的四个数字(与网页版 `hb-stats` 同源) */
stat: FbStat;
/** 往年的今天(与电脑端 `Home::memories()` 同一个逻辑) */
memories: FbMemoryItem[];
/** 今天的日程(服务端已排好:全天在前、其余按时间) */
todayEvents: FbCalEvent[];
/** 近期任务(**进行中在前、待办在后**,用户 2026-10-16 要求含待办) */
tasks: FbTaskItem[];
/** 未读通知数 */
unread: number;
}
// ============================================================
// 名册 / 家谱
// ============================================================
@@ -124,6 +240,25 @@ export interface FbRosterItem {
tags: string[];
/** 头像色 */
avatarColor: string;
/**
* 头像图(服务端的 OSS 相对路径,如 `avatars/xxx.jpg`;空 = 这个人没传过头像)。
*
* ⚠️ 实测 800 人里只有 **19 人有图** ⇒ 绝大多数要**回落到色块字**
* (`FbAvatar` 的默认行为)。只有当它非空时才用 `fbImageUrl()` 显示真图。
*/
avatar: string;
/** 手机号明文(空 = 没录)。= `phones[0]` */
phone: string;
/**
* **全部电话**(主号在前)。
*
* 🔴 用户 2026-10-15:「一个人可能有多个电话号码……应该拨打最近的那个电话。」
* ⇒ 端上按"**有主号打主号**"取 `phones[0]`;
* 一个都没有时点行改为"打开详情"(点了没反应比什么都让人困惑)。
*/
phones: string[];
/** 姓名首字母(拼音,如「杨永全」→ yyq);搜索用 */
initials: string;
/** 归属:member(家里的人)/ friend(亲友通讯录) */
roster: string;
/** 生日('MM-DD' 或 '') */
@@ -132,6 +267,38 @@ export interface FbRosterItem {
isAdmin: boolean;
}
/**
* 家谱里的一个人(`GET /api/v1/kin`)。
*
* ⚠️ 与 `FbRosterItem`(名册平铺名单)**不是一个东西**:
* 名册是"我名册里的人"(可能上千),家谱是"**以我为中心的关系网**"
* (只含上下两代,十来人),关键是带**代数**与**亲属边**。
*/
export interface FbKinPerson {
id: number;
name: string;
/** 「我怎么叫他」(昵称优先) */
shown: string;
/** 「我该怎么称呼他」(服务端按图谱推导,如"岳父""外甥女") */
label: string;
/** 代数:0 = 我,负 = 长辈,正 = 晚辈 */
gen: number;
avatarColor: string;
/** 户(夫妻单元)号 */
unit: number;
parents: number[];
children: number[];
spouses: number[];
siblings: number[];
}
/** 家谱整体 */
export interface FbKinGraph {
meId: number;
people: FbKinPerson[];
total: number;
}
/** 家谱连线(kin/relate:父母 / 配偶 / 子女) */
export interface FbKinRelation {
id: number;
@@ -228,15 +395,34 @@ export interface FbTripNode {
// 日程(calendar)
// ============================================================
/** 一条日程(calendar/index.php 的格子/时间轴条目) */
/**
* 一条日程(`/api/v1/calendar` 的条目;含节日/农历/纪念日等**所有**日历条目)。
*
* 🔴 v52(用户 2026-10-15 要日程照 SyncCalendar 做)补齐了几个原本漏掉的字段 ——
* 实测接口**给了**、端上却没解析,于是"新建/编辑日程"没有 id 可用:
* · `calId` —— **所在日历本的真实 id**。缺了它,新建时就没法指定
* 往哪本写(旧代码甚至从条目里**臆造** id,见 `FbCalRepo.fetchBooks`)。
* · `endTime` —— 结束时间(时间轴上要画时长、详情要显示区间)。
* · `kind` —— 条目类型(`custom`/`anniv`/`lunar`/`holiday`…):
* 只有 `custom` 那种才是**用户自己的日程**、可点开可编辑;
* 农历/节气/节假日是**系统衍生条目**,点开也不该给"编辑"。
* · `note` —— 备注。
*/
export interface FbCalEvent {
id: number;
/**
* 所在日历本的**真实 id**(服务端条目里的 `cal`)。
* ⚠️ 0 表示"没有对应的可写日历本"(如纪念日、农历这类衍生条目)。
*/
calId: number;
/** 标题 */
title: string;
/** 日期 'YYYY-MM-DD' */
date: string;
/** 时间 'HH:mm'(全天事件为空串) */
/** 开始时间 'HH:mm'(全天事件为空串) */
time: string;
/** 结束时间 'HH:mm'(空 = 没设) */
endTime: string;
/** 所属日历本名 */
calName: string;
/** 日历本色标 */
@@ -245,6 +431,15 @@ export interface FbCalEvent {
allDay: boolean;
/** 地点(可为空) */
place: string;
/** 备注(可为空) */
note: string;
/** 条目类型:`custom` = 用户自己的日程;其余是衍生条目(农历/节气/节假日/纪念日…) */
kind: string;
}
/** 这个条目能不能当"用户日程"来编辑(只有 custom 才算) */
export function isEditableEvent(e: FbCalEvent): boolean {
return e.kind === 'custom' && e.id > 0 && e.calId > 0;
}
/** 日历本(calendar/calendars) */
@@ -338,6 +533,23 @@ export interface FbLedgerEntry {
updatedAt: string;
}
/**
* 一本账的明细(`GET /api/v1/ledger/<id>/entries`)。
*
* ⚠️ **净资产只在明细接口里**(概览 `/ledger` 不返回)
* ⇒ 账本页面必须先拉概览拿到 id、再拉明细才有汇总。
* 实测:`summary: {asset, debt, net, by_kind, count}`。
*/
export interface FbLedgerDetail {
entries: FbLedgerEntry[];
/** 净资产(分)= 资产 - 负债 */
netCent: number;
/** 总资产(分) */
assetCent: number;
/** 总负债(分) */
debtCent: number;
}
// ============================================================
// 资源(resources)
// ============================================================
+358 -15
View File
@@ -18,7 +18,8 @@ import { FbColors, FbMetric } from '../common/Theme';
import { FbAppState, FB_WIDE_BREAKPOINT, fbAppState } from '../common/Store';
import { FbDrawerShell } from '../components/Drawer';
import { FbTopBar } from '../components/TopBar';
import { FbFloatDock } from '../components/FloatDock';
import { FB_GRIP, fbStartGripWatch } from '../common/FbGrip';
import { FbFloatDock, FB_DOCK_HIDE_Y } from '../components/FloatDock';
import { FbHomePage } from './home/FbHomePage';
import { FbMoviesPage } from './movies/FbMoviesPage';
import { FbTripsPage } from './trips/FbTripsPage';
@@ -31,6 +32,25 @@ import { FbLedgerPage } from './ledger/FbLedgerPage';
import { FbBooksPage } from './books/FbBooksPage';
import { FbNotesPage, FbNotificationsPage } from './misc/FbMiscPages';
import { FbSettingsPage } from './settings/FbSettingsPage';
import { FbLoginPage } from './login/FbLoginPage';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_SERVER_STORE } from '../data/FbServerStore';
import { FB_DEVICE_STORE } from '../data/FbDeviceStore';
import { FB_SESSION, SESSION_NEED_LOGIN, SESSION_OFFLINE } from '../data/FbSession';
import { fbOnUnauthorized } from '../data/FbHttp';
import { common } from '@kit.AbilityKit';
/**
* 小睡一会儿(毫秒)。
*
* ⚠️ ArkTS 里没有 `setTimeout` 的 Promise 版,启动检查要"轮询等就绪"
* 就需要这样一个工具。用它而不是忙等(忙等会占满 CPU)。
*/
function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
}
@Entry
@ComponentV2
@@ -38,8 +58,255 @@ struct Index {
/** 全局状态(抽屉 / 当前模块 / 宽窄屏) */
@Local app: FbAppState = fbAppState();
/**
* 是否已经"进到应用里"。
*
* 🔴 三种状态,必须分开(别用两个布尔硬凑):
* · `checking` —— 启动时正在看"有没有令牌"(很短,但**必须有**,
* 否则会先闪一下登录页再跳进主界面);
* · 有令牌 / 用户选了"稍后再说" ⇒ `entered = true` ⇒ 显示主界面;
* · 没令牌且用户没跳过 ⇒ 显示登录页。
*/
@Local entered: boolean = false;
/** 启动检查是否已完成(false = 还在看令牌,先显示空白/logo) */
@Local bootChecked: boolean = false;
aboutToAppear(): void {
this.measureScreen();
this.readStatusBar();
// 🔴 「质感握姿」(用户 2026-10-16 第 4 条):左手握→抽屉从左出、右手握→从右出。
// ⇒ 它决定**顶栏家书图标贴哪边**(见 TopBar)与抽屉从哪侧滑出(见 Drawer)。
// ⚠️ 能力不支持(API<20)时会自己 catch 住,不影响启动。
fbStartGripWatch();
/* 🔴 v57:注册「令牌失效」回调。
任何业务请求收到 401 → 网络层直接调这里 → 清本地令牌 + 回登录页。
⚠️ 只注册一次(重复注册会覆盖成新的 this,无害但没必要)。 */
fbOnUnauthorized((): void => {
this.lockOut();
});
this.bootCheck();
}
/** 离开页面时注销握姿监听(⚠️ 不注销会在重建后叠加回调) */
aboutToDisappear(): void {
FB_GRIP.stop();
}
/**
* 读状态栏高度(由 `EntryAbility` 在全屏生效后量好、放进 AppStorage)。
*
* 🔴 为什么要读它(用户 2026-10-13 要"全沉浸"):
* 打开 `setWindowLayoutFullScreen(true)` 之后窗口**不再为状态栏让位**,
* 内容会真的画到状态栏底下。各页顶栏必须自己往下让出这一段,
* 否则首屏"家书"标题会被时间/信号图标压住。
* ⚠️ 存进 `app.statusBarH` 而不是各页自己读 AppStorage:
* 它是 `@Trace` 的,量出来(异步)之后各页会**自动重排**;
* 各页自己读一次的话,量完不会通知它们刷新。
* ⚠️ 量不到(=0)也能用:内容仍从最顶上开始画,只是标题贴上去了 ——
* 比猜一个数字把标题推到屏幕中间好。所以要**轮询等**一小会儿。
*/
private async readStatusBar(): Promise<void> {
for (let i = 0; i < 20; i++) {
const h: number = AppStorage.get<number>('fbStatusBarH') ?? -1;
if (h >= 0) {
this.app.statusBarH = h;
return;
}
await sleep(50);
}
this.app.statusBarH = 0;
}
/**
* 启动检查:有令牌就直接进主界面,否则显示登录页。
*
* ⚠️ 令牌与**服务器地址**的读取都是**同步**的(见两个 store 的说明),
* 所以这里其实很快;但要 `await` 一下它们的**预加载**,
* 否则会误判成"没登录 / 没配地址"(EntryAbility 里是异步预加载的)。
* ⇒ 用一个小轮询等它们就绪(最多约 1 秒),避免"有令牌却显示登录页"
* 以及"明明配过地址却让人重填"。
*/
private async bootCheck(): Promise<void> {
for (let i = 0; i < 40; i++) {
/* 🔴 四个都要就绪:
· 令牌 —— 有没有登录过(决定要不要验);
· 地址 —— 打哪个服务器(v55 起地址是持久化配置);
· 设备身份 —— 登录/校验请求要带 device_id(v57);
· 会话时间 —— 离线多久了(v57,离线超 24h 判锁)。
只等其中一部分,会出现"有令牌却显示登录页"或"没 device_id 就去登录"
这种看起来莫名其妙的状态。 */
if (FB_TOKEN_STORE.isReady() && FB_SERVER_STORE.isReady()
&& FB_DEVICE_STORE.isReady() && FB_SESSION.isReady()) {
break;
}
await sleep(50);
}
if (!FB_TOKEN_STORE.has()) {
// 从没登录过(或已退出登录):停在登录页。
// ⚠️ 首次使用允许"先不登录"(本地优先),但**被踢/退出后不允许** ——
// 那会把"v57 的安全策略"变成一个可绕过的按钮(见 build 里的 allowSkip)。
this.entered = false;
this.bootChecked = true;
return;
}
/* 🔴 v57(用户 2026-10-17):「每过 24 小时,我们就要请求一下服务器,
去验证一下这个 Token 是不是还是有效的」+「应用一打开就发现 Token 失效」。
⇒ 有令牌就**每次启动都验一次**(不是每 24h 才验一次)。
只有"打开时联网"才可能发现被踢,这一步就是那个时机。 */
const ctx: common.Context | undefined = this.abilityCtx();
if (ctx === undefined) {
// 拿不到上下文(极罕见):不要把用户卡死,按本地数据先用着
this.entered = true;
this.bootChecked = true;
return;
}
const r = await FB_SESSION.check(ctx);
if (r.state === SESSION_NEED_LOGIN) {
this.app.lockReason = r.reason;
this.app.loginStartStep = 2;
this.app.needLogin = true;
this.app.offlineLocked = false;
this.entered = false;
} else if (r.state === SESSION_OFFLINE) {
/* 离线超过 24 小时:**锁界面但不删令牌**。
联网校验通过后自动恢复 —— 用户不用重新输密码(这正是他要的
"一直联网就不用每天登录")。 */
this.app.lockReason = r.reason;
this.app.offlineLocked = true;
this.app.needLogin = false;
this.entered = false;
} else {
this.app.lockReason = '';
this.app.needLogin = false;
this.app.offlineLocked = false;
this.entered = true;
}
this.bootChecked = true;
}
/** 取 Ability 上下文(失败返回 undefined,不抛) */
private abilityCtx(): common.Context | undefined {
try {
return this.getUIContext().getHostContext();
} catch (err) {
hilog.warn(0x0000, 'FamilyBook', '取 Ability Context 失败:%{public}s', JSON.stringify(err));
return undefined;
}
}
/**
* v57:离线超过 24 小时的锁屏。
*
* 用户原话:「离线超过 24 个小时就硬退出。」
*
* ⚠️ 这里**不做成登录页**:用户只是没联网,不该被要求重输密码。
* 联网后校验通过会自动回主界面(见 `retrySession`);
* 只有当服务端明确回 401(= 真被踢了)才转去登录页。
*/
@Builder
offlineLockView() {
Column() {
Blank()
Column() {
Text('需要联网确认')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.app.lockReason)
.fontSize(14)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.lineHeight(21)
.margin({ top: 12 })
Button('重新连接')
.fontSize(15)
.height(44)
.width(170)
.margin({ top: 26 })
.onClick((): void => {
this.retrySession();
})
Text('数据还在。联网确认后自动恢复,不用重新登录。')
.fontSize(12)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.lineHeight(18)
.margin({ top: 14 })
}
.padding({ left: 32, right: 32 })
Blank()
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
}
/** 离线锁屏上的「重新连接」:再验一次,通过就自动放行 */
private async retrySession(): Promise<void> {
const ctx: common.Context | undefined = this.abilityCtx();
if (ctx === undefined) {
return;
}
const r = await FB_SESSION.check(ctx);
if (r.state === SESSION_NEED_LOGIN) {
// 联网之后服务端说"这枚令牌没了" → 这才是真的被踢,转登录页
this.app.offlineLocked = false;
this.app.needLogin = true;
this.app.loginStartStep = 2;
this.app.lockReason = r.reason;
this.entered = false;
} else if (r.state === SESSION_OFFLINE) {
this.app.lockReason = r.reason; // 还是连不上,留在锁屏
} else {
this.app.offlineLocked = false;
this.app.needLogin = false;
this.app.lockReason = '';
this.entered = true;
}
}
/**
* 🔴 v57:令牌失效(任何请求 401)→ 立刻锁定回登录页。
*
* 用户原话:「运行期间任何一个请求返回 401,立刻锁定跳登录页 ——
* 这是真正的兜底,因为同步/写入本来就在不断发请求。」
*
* ⚠️ 由 `FbHttp` 的网络层统一回调(不是每个页面自己判),
* 所以**漏不掉**;登录接口自己传了 `authProbe` 不会触发。
*/
private lockOut(): void {
if (this.app.needLogin) {
return; // 已经在登录页了,别反复设状态(会让界面闪)
}
const ctx: common.Context | undefined = this.abilityCtx();
if (ctx !== undefined) {
// ⚠️ 先清本地令牌(主防线:捡到手机的人拿不到它),再通知界面
FB_TOKEN_STORE.clear(ctx).catch((err: BusinessError): void => {
hilog.warn(0x0000, 'FamilyBook', '清令牌失败:%{public}s', JSON.stringify(err));
});
FB_SESSION.clear(ctx).catch((err: BusinessError): void => {
hilog.warn(0x0000, 'FamilyBook', '清会话记录失败:%{public}s', JSON.stringify(err));
});
}
this.app.lockReason = '这台设备已经被退出登录,请重新登录';
this.app.loginStartStep = 2;
this.app.needLogin = true;
this.app.offlineLocked = false;
this.entered = false;
}
/** 登录成功:进主界面(会话信息已由登录页写进 `app.session`) */
private onLoggedIn(): void {
this.app.session.loggedIn = true;
// 🔴 复位"要登录"的请求 —— 不复位的话,下一次渲染又会判回登录页(死循环)
this.app.needLogin = false;
this.app.loginStartStep = 0;
// v57:登录成功 = 会话刚校验过,两种锁都要撤掉
this.app.lockReason = '';
this.app.offlineLocked = false;
this.entered = true;
}
/**
@@ -68,27 +335,97 @@ struct Index {
// 🔴 用 Stack 而不是 Column:底部快捷栏要**浮在内容之上**(用户要求"不要固定在下边,
// 要浮着")。写在 Column 里它就是"占一行的固定底栏"(老样式)。
Stack({ alignContent: Alignment.Bottom }) {
Column() {
FbDrawerShell({ app: this.app }) {
this.mainArea()
// ---- 启动检查还没完:先给一块底色,别闪 ----
if (!this.bootChecked) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
} else if (this.app.offlineLocked) {
// ---- v57:离线超过 24 小时 —— 锁界面(**不清令牌**,联网即恢复)----
this.offlineLockView()
} else if (!this.entered || this.app.needLogin) {
// ---- 未登录 / 被要求重新登录:登录页(**不显示抽屉/悬浮栏**)----
// 🔴 v55:`needLogin` 是设置页「退出登录 / 更换服务器」置的位 ——
// 本应用没有路由栈,登录页只能靠这个状态位回到前台。
FbLoginPage({
// 传 app 只为让它拿到**状态栏高度**(全屏布局下登录页也要自己让位)
app: this.app,
// 从第几步开始(1=连地址一起换,2=只重登,0=自动)
startStep: this.app.loginStartStep,
/* 🔴 v57:只有"从没登录过"才允许「先不登录」。
被踢 / 主动退出后必须重新登录 —— 用户原话:
「退到登录页面之后,里面所有的资源、所有的内容都是不允许看的。」
留着那个按钮就等于留了一扇后门(v58 上了加密才真正堵死)。 */
allowSkip: this.app.lockReason === '',
onLoggedIn: (): void => {
this.onLoggedIn();
},
onSkip: (): void => {
// 「先不登录」:不登录也进主界面 —— 本地优先架构下,
// 没令牌照样能看已同步的数据。把用户堵在登录页是错的。
// ⚠️ 必须同时复位 needLogin,否则又会被判回登录页。
this.app.needLogin = false;
this.app.loginStartStep = 0;
this.entered = true;
}
})
} else {
// ---- 主界面 ----
Column() {
FbDrawerShell({ app: this.app }) {
this.mainArea()
}
}
}
.width('100%')
.height('100%')
.width('100%')
.height('100%')
// 底部悬浮快捷栏(日程 / 名册 / 任务 / 笔记)
// ⚠️ 只在窄屏显示:宽屏时抽屉常驻,且屏幕宽,用不上这个快捷入口。
// ⚠️ 抽屉展开时不显示 —— 那时用户正在抽屉里选模块,浮层再叠一个反而乱。
if (!this.app.wideLayout && !this.app.drawerOpen) {
FbFloatDock({ app: this.app })
// 底部悬浮快捷栏(日程 / 名册 / 任务 / 笔记)
// ⚠️ 只在窄屏显示:宽屏时抽屉常驻,且屏幕宽,用不上这个快捷入口。
// ⚠️ 抽屉展开时不显示 —— 那时用户正在抽屉里选模块,浮层再叠一个反而乱。
// 🔴 **浮层开着时也不显示**(用户 2026-10-15 实测的真 bug):
// 悬浮栏在**比页面浮层更外层**的 Stack 里 ⇒ 层级更高。
// 详情卡片底部的「编辑 / 关闭」(y≈2207-2331)与悬浮栏(y≈2240-2350)
// 在屏幕上重叠,点按钮被悬浮栏抢走 ⇒ **直接跳到日历页**。
// ⇒ 用 `sheetOpen()` 让位(收起 + 不接收点击)。
if (!this.app.wideLayout && !this.app.drawerOpen && !this.app.sheetOpen()) {
// ⚠️ 外面这层 Column 是**必需的**:Stack 里的子组件会按内容宽度收缩,
// 不给它一个 width('100%') 的宿主,`FbFloatDock` 内部的左右内缩就无处生效
// (悬浮栏会缩成一小团靠在某一边)。
Column() {
FbFloatDock({ app: this.app })
}
.width('100%')
// 🔴 **随滚动收起 / 唤回**(用户 2026-10-13 要的 IT 之家那套):
// 向上滚内容 ⇒ 向下平移出屏幕(连同透明度一起淡出);
// 向下滚回看 ⇒ 平移回原位。
//
// ⚠️ 用 `translate` 平移而不是 `visibility` 开关:
// 平移是**连续的**(有动画过程),看起来是"滑走";
// 显隐是突变的,看着像"啪一下没了"。
// ⚠️ 平移量取 `FB_DOCK_HIDE_Y`(= 栏高 + 下边距 + 余量)——
// 必须**完全移出可视区**,否则底下会露出半截胶囊边。
// 用导出的常量而不是写死数字:它和栏高/边距是同一个几何量,
// 写死两处必然漂移。
.translate({ y: this.app.dockHidden ? FB_DOCK_HIDE_Y + 'vp' : '0vp' })
.opacity(this.app.dockHidden ? 0 : 1)
// 位移动画:出入对称、约 220ms 是"跟手但不拖沓"的区间
.animation({ duration: 220, curve: Curve.Friction })
}
}
}
.width('100%')
.height('100%')
// 沉浸式:让**根容器背景**延伸到状态栏/手势条区域,但不移动子组件。
// 配合 EntryAbility 里"系统栏与顶栏同色",通知栏与标题栏融为一体。
.backgroundColor(FbColors.paper)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// 🔴 这里**不再用 `expandSafeArea`**(用户 2026-10-13 改"全沉浸"后)。
//
// 原因:安全区扩展只在**窗口仍然避让系统栏**的前提下才有意义
// (它做的事是"让背景多画进系统栏那条,但子组件不动")。
// 现在窗口已经开了 `setWindowLayoutFullScreen(true)`——
// 内容本来就铺满整屏、系统栏也全透明,再叠加 `expandSafeArea` 是多余的;
// 而且两者叠加会出现"背景扩一次、内容又扩一次"的重复计算风险。
// ⇒ 全屏这条路线下,**让位交给顶栏自己的 padding.top**(见 FbTopBar),
// 边界一清二楚。
}
/** 内容区:当前模块页(含顶栏 —— 顶栏由页面放进自己的滚动流,见 topBarSlot) */
@@ -112,6 +449,12 @@ struct Index {
* ⚠️ 状态栏由系统直接涂成白色(EntryAbility 里 `statusBarColor = surface`,
* 与顶栏同色)。所以顶栏滚走之后,通知栏区域仍由系统保持白色,
* 不会露出页面底色 —— 不需要在页面上再铺"白色压条"。
*
* ⚠️ v55 顺手清掉了一段残留:这上面原本还有一个**孤立的 `@Builder`**
* (是早期"临时诊断条"删剩的壳)。它让编译器报
* "Duplicate '@Builder' decorators for method are not allowed" ——
* 行为上没坏(生效的是下面这个),但每轮编译都刷一条假错误,
* 真出问题时很容易被它盖住。
*/
@Builder
topBarSlot() {
+143 -52
View File
@@ -11,9 +11,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbBookItem, FbGroup } from '../../model/Types';
import { FB_DEMO_BOOKS, fbGroupBook } from '../../model/DemoData';
import { fbGroupBook } from '../../model/DemoData';
import { parseBooks } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbEmptyBox, FbIconButton, FbProgressLine, FbRating, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_BOOKS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbBooksPage {
@@ -27,71 +32,157 @@ export struct FbBooksPage {
/** 模块主题色(读书 = 藕紫) */
private accent: string = '#6B5B95';
private all: FbBookItem[] = FB_DEMO_BOOKS;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local all: FbBookItem[] = [];
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbBookItem[]>('books', parseBooks,
(rows: FbBookItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('书架')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.all.length.toString() + ' 本 · '
+ this.readingCount().toString() + ' 本在读 · '
+ this.noteCount().toString() + ' 条笔记')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Scroll() {
Column() {
this.topBar()
// 上传 / 统计(Web 端在设置菜单里)
FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbColors.muted })
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('书架')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.all.length.toString() + ' 本 · '
+ this.readingCount().toString() + ' 本在读 · '
+ this.noteCount().toString() + ' 条笔记')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// ---- 分组列表 ----
if (this.all.length === 0) {
FbEmptyBox({
title: '书架还是空的',
hint: '传一本进来,或先记下想读的书',
icon: $r('app.media.fb_ic_books')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbBookItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count) }) {
ForEach(g.items, (b: FbBookItem) => {
ListItem() {
this.bookRow(b)
}
}, (b: FbBookItem): string => 'bk_' + b.id.toString())
// 上传 / 统计(Web 端在设置菜单里)
FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbColors.muted })
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
}, (g: FbGroup<FbBookItem>): string => 'bg_' + g.key)
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 分组列表 ----
if (this.all.length === 0) {
FbEmptyBox({
title: '书架还是空的',
hint: '传一本进来,或先记下想读的书',
icon: $r('app.media.fb_ic_books')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbBookItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count) }) {
ForEach(g.items, (b: FbBookItem) => {
ListItem() {
this.bookRow(b)
}
}, (b: FbBookItem): string => 'bk_' + b.id.toString())
}
}, (g: FbGroup<FbBookItem>): string => 'bg_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '读书设置',
items: FB_SET_BOOKS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一本书:书脊色块(代替封面)+ 书名/作者 + 评分/笔记/进度 */
File diff suppressed because it is too large Load Diff
+121 -35
View File
@@ -13,9 +13,13 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbAnniversaryItem, FbKinChip } from '../../model/Types';
import { FB_DEMO_ANNIVERSARIES } from '../../model/DemoData';
import { parseDays } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_DAYS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbDaysPage {
@@ -29,41 +33,95 @@ export struct FbDaysPage {
/** 模块主题色(纪念日 = 陶土橙) */
private accent: string = '#B4633C';
private all: FbAnniversaryItem[] = FB_DEMO_ANNIVERSARIES;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local all: FbAnniversaryItem[] = [];
/** 类别筛选('' = 全部) */
@Local kindFilter: string = '';
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbAnniversaryItem[]>('days', parseDays,
(rows: FbAnniversaryItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('纪念日')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('节日 / 纪念日 / 生日,都在这一条时间线上')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Scroll() {
Column() {
this.topBar()
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('纪念日')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('节日 / 纪念日 / 生日,都在这一条时间线上')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
// ---- 即将到来(30 天内)----
@@ -95,6 +153,8 @@ export struct FbDaysPage {
}
}
.scrollable(ScrollDirection.Horizontal)
// 🔴 横向滚动上报(让抽屉给横滑组件让位)—— 见 common/FbScroll.ets
.onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off)
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
@@ -138,17 +198,43 @@ export struct FbDaysPage {
.width('100%')
}
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '纪念日设置',
items: FB_SET_DAYS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 即将到来的大数字卡 */
+481 -262
View File
@@ -1,25 +1,53 @@
/**
* 家书 FamilyBook · 首页(鸿蒙端)
*
* 对应 Web 端 `templates/home.php`。版面照搬它的三段:
* ① hero(家庭名 + 格言 + 我的资料/家谱两个快捷入口)
* ② 信息面板(问候 + 日期/星期/农历 + 日子标记 | 右侧天气)
* ② 今天的日程 + 近期任务(两块并排;窄屏上下堆叠)
* ④ 「往年的今天」回忆条
* ⑤ 模块入口卡片(12 个模块的关键数字)
* 对应 Web 端 `templates/home.php`。版面:
* ① 个人信息卡(问候 + 姓名 + 日期/星期/农历 + 日子标记 + 天气 + 近七天数字)
* ② 今天的日程(🔴 **直接用日程页那块面板组件** —— 用户 2026-10-16)
* ③ 近期任务(进行中 + 待办,进行中在前)
* ④ 往年的今天(用户 2026-10-17 要求加,与电脑端同一个逻辑)
*
* ⚠️ 只做**界面**,不做交互实现:卡片点了切模块(前端状态),不发请求。
* 🔴🔴 v54(用户 2026-10-16)—— 第一轮修的四件事:
* 1. 「概述块**太高了**……其实是因为我们调整之后,把家书那条调到上面去了,
* 所以概述块变得特别高。」
* 根因:`.padding({ bottom: FB_DOCK_RESERVE })`(**96vp** 的底部悬浮栏预留)
* 被接在了「天气地点 + 日子标记」那个小 Flex 上(缩进看着像它的修饰)——
* 它本该在**页面根部**(给最后一屏留白)。
* ⇒ 概述卡中间凭空多出 96vp 的空白,卡片被撑得老高。
* **修法:挪回页面根部的 Column。**
* 2. 「里面的数据好像有些不符,比如**寒露、重阳节、儿子的生日**,
* 这些都是**乱写的**,要用**真实数据**填写一下。」
* 根因:农历 / 日子标记 / 天气 / 周统计这四样接口**从来没给过**
* (网页版是在 index.php 里算好直接渲染的)⇒ 端上拿 `DemoData` 演示常量顶着。
* ⇒ 服务端 `/home` 已补齐(见 `ApiHandler::home()`),这里全部改接真实数据。
* 3. 「**今天的日程**……把今天那个块拿到首页上来就行了。」
* ⇒ 复用 `FbDayEventPanel`(日程页月/周/列表三处用的同一个组件),
* 于是时间轴/列表显示方式、冲突并排、当前时间红线在首页**与日程页完全一致**。
* 4. 「**我的家人、未来7天,还有全部模块**,这几个就是去掉了,不要了。」
* ⇒ 三个区块连同它们的演示常量一起删掉。
*
* 🔴🔴 v54b(用户 2026-10-17)—— 第二轮:
* 5. 「概述卡……**日程、在做、待办的数量是错误的**。」
* 真机实测:接口与端上缓存都是 `{1,3,2,0}`(正确),界面却是 `0/0/0`。
* 根因:`FbStatTriple` 内部 `@Builder cell(label, value, alarm)` 是**多值参数**
* ⇒ SDK 规则「两个及以上参数不触发刷新」⇒ 永远停在初始的 0。
* ⇒ 抽 `FbStatCell` **组件**(@Param 参与观察)—— 本项目第 6 次同一类修法。
* 6. 「近期任务的下面,应该加上**往年的今天**,跟电脑端一样。」
* ⇒ 服务端把 `home_memories()` 搬成 `Home::memories()`(两端同一份实现),
* `/home` 给 `memories` 字段,这里加第 ④ 块。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbModuleCard, FbCalEvent, FbTaskItem, FbKinChip } from '../../model/Types';
import {
FB_DEMO_EVENTS_TODAY, FB_DEMO_EVENTS_SOON, FB_DEMO_KIN, FB_DEMO_MARKS,
FB_DEMO_MODULE_CARDS, FB_DEMO_TASKS, FB_DEMO_WEATHER, FB_DEMO_WEEK_STAT
} from '../../model/DemoData';
import { FbAvatar, FbDot, FbDotLine, FbSectionTitle, FbStatTriple, FbTag } from '../../components/Widgets';
import { FbCalEvent, FbHomeData, FbMark, FbMemoryItem, FbTaskItem, FbWeather } from '../../model/Types';
import { parseHome } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FB_CAL_SETTINGS, FbCalStyle } from '../../data/FbCalSettings';
import { FbSectionTitle } from '../../components/Widgets';
import { FbStatCell } from '../../components/FbStatCell';
import { FbDayEventPanel } from '../../components/FbDayEventPanel';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
@ComponentV2
export struct FbHomePage {
@@ -31,10 +59,81 @@ export struct FbHomePage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private cards: FbModuleCard[] = FB_DEMO_MODULE_CARDS;
private todayEvents: FbCalEvent[] = FB_DEMO_EVENTS_TODAY;
private soonEvents: FbCalEvent[] = FB_DEMO_EVENTS_SOON;
private tasks: FbTaskItem[] = FB_DEMO_TASKS;
// ---------------- 数据(全部来自 `/api/v1/home`)----------------
/** 今天的日程(服务端已排好:全天在前、其余按时间) */
@Local todayEvents: FbCalEvent[] = [];
/** 近期任务(进行中在前、待办在后) */
@Local tasks: FbTaskItem[] = [];
/** 首页问候语 */
@Local greeting: string = '';
/** 抬头日期(`10 月 16 日`) */
@Local todayLabel: string = '';
/** 星期几(`星期五`) */
@Local weekday: string = '';
/** 农历短写(`八月廿二`) */
@Local lunarShort: string = '';
/** 日子标记(节气/节日/纪念日/生日) */
@Local marks: FbMark[] = [];
/** 天气(null = 没配地点 ⇒ 整块不画) */
@Local weather: FbWeather | null = null;
/**
* 概述卡的四个数字。
*
* 🔴 v54b:**拆成四个独立的 `@Local number`**,而不是一个 `@Local stat: FbStat`
* 对象。理由(本次真机 bug 的教训):数字是最可靠被观察的类型;
* 用对象时,刷新链路要经过"对象整体替换 → 子组件 @Param 收到新值"两道,
* 任何一道出问题都表现为"界面永远停在初始的 0"。
* ⚠️ 拆开之后,四个格子各自只依赖一个 number,链路最短。
*/
@Local statToday: number = 0;
@Local statDoing: number = 0;
@Local statTodo: number = 0;
@Local statOverdue: number = 0;
/** 往年的今天(`/home` 的 memories) */
@Local memories: FbMemoryItem[] = [];
/** 顶部提示(同步失败等) */
@Local syncNote: string = '';
/**
* 日程显示方式(时间轴 / 列表)—— 与日程页**读同一个设置**。
* ⚠️ 首页显示"今天的日程",用户在哪一处把显示方式调了,另一处也该跟着变
* (它们本来就是同一份数据的不同入口)。
*/
@Local calStyle: FbCalStyle = 'timeline';
aboutToAppear(): void {
// 显示方式来自日程设置(启动时已预载进内存,可同步读)
this.calStyle = FB_CAL_SETTINGS.getStyle();
this.load();
}
/**
* 拉首页数据。
*
* ⚠️ 首页**只要一个接口**了(v54)—— 原来还要并发拉 `/calendar`(未来七天)
* 和 `/tasks`(任务),因为那两块数据接口没给。
* 现在服务端 `/home` 把它们都备齐了,少两个请求就是首页更快一截。
*/
private async load(): Promise<void> {
await loadModule<FbHomeData>('home', parseHome,
(d: FbHomeData): void => {
this.todayEvents = d.todayEvents;
this.tasks = d.tasks;
this.greeting = d.greeting;
this.todayLabel = d.todayLabel;
this.weekday = d.weekday;
this.lunarShort = d.lunarShort;
this.marks = d.marks;
this.weather = d.weather;
this.statToday = d.stat.todayEvents;
this.statDoing = d.stat.doing;
this.statTodo = d.stat.todo;
this.statOverdue = d.stat.overdue;
this.memories = d.memories;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 问候语时段(与 Web 端 home.php 里那段 PHP 同口径) */
private hello(): string {
@@ -54,24 +153,81 @@ export struct FbHomePage {
return '晚上好';
}
/** 今天的日期与星期 */
/** 日期行:`10 月 16 日 · 星期五`(服务端给的字段;没有就本地算兜底) */
private dateLine(): string {
if (this.todayLabel !== '') {
return this.weekday !== '' ? (this.todayLabel + ' · ' + this.weekday) : this.todayLabel;
}
const d: Date = new Date();
const week: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return (d.getMonth() + 1).toString() + ' 月 ' + d.getDate().toString() + ' 日 · '
+ week[d.getDay()];
const w: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return (d.getMonth() + 1).toString() + ' 月 ' + d.getDate().toString() + ' 日 · ' + w[d.getDay()];
}
/** 天气图标(按文案粗略映射到已有的几个图标上,不引外部图) */
private weatherIcon(): Resource {
// TODO(接口):真实天气码 → 图标映射。现在只区分「有太阳/没太阳」两种。
return this.weather().text.indexOf('晴') >= 0
? $r('app.media.fb_ic_days')
: $r('app.media.fb_ic_calendar');
/** 天气图标颜色(按服务端给的 tone 分档,与网页版 `.hb-weather.is-*` 一一对应) */
private weatherTint(): string {
const w: FbWeather | null = this.weather;
const tone: string = w !== null ? w.tone : 'cloud';
if (tone === 'sunny') {
return '#E8A33D';
}
if (tone === 'rain') {
return '#3B7FBF';
}
if (tone === 'snow') {
return '#5AA6B8';
}
if (tone === 'storm') {
return '#7A5BB5';
}
if (tone === 'fog') {
return '#8C8C8C';
}
return '#7C8AA0';
}
private weather() {
return FB_DEMO_WEATHER;
/**
* 日子标记的配色(与网页版 `.hb-mark.is-*` 同一套,抄自 `assets/css/app.css`)。
*
* ⚠️ 用**具体的背景/前景两色**而不是 `FbTag` 的 tone —— 网页版那五档
* (holiday 红 / term 绿 / festival 橙 / anniv 紫 / birthday 粉)
* 比 FbTag 现有的四档更细,硬套会丢掉"生日和纪念日不是一回事"这个区分。
*/
private markBg(kind: string): string {
if (kind === 'holiday') {
return '#FDECEC';
}
if (kind === 'term') {
return '#EDF4E9';
}
if (kind === 'festival') {
return '#FDF2E3';
}
if (kind === 'birthday') {
return '#FDECF3';
}
return '#F2ECF7'; // anniv 及其它
}
private markFg(kind: string): string {
if (kind === 'holiday') {
return '#A8232B';
}
if (kind === 'term') {
return '#3F6B34';
}
if (kind === 'festival') {
return '#96551A';
}
if (kind === 'birthday') {
return '#A03060';
}
return '#6B4A86'; // anniv 及其它
}
/** 当前时刻(当天第几分钟)—— 时间轴画"当前时间红线"用 */
private nowMinutes(): number {
const d: Date = new Date();
return d.getHours() * 60 + d.getMinutes();
}
build() {
@@ -79,7 +235,6 @@ export struct FbHomePage {
Column({ space: 0 }) {
this.topBar()
// ==================== ① 个人信息卡(问候 + 今天 + 天气)====================
// 🔴 用户 2026-10-12 定的:这一块**要白色卡片**("这样显得好看")。
// ⚠️ 曾一度做成"不套卡片的页面头部",理由是省首屏高度;
@@ -107,72 +262,100 @@ export struct FbHomePage {
Text(this.dateLine())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
Text('八月廿二')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
// 🔴 农历:真实值(接口给)。算不出时**不显示**,不编一个
if (this.lunarShort !== '') {
Text(this.lunarShort)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 天气:靠右,温度大字 + 文案小字
Row({ space: 6 }) {
Image(this.weatherIcon())
.width(26)
.height(26)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.brand2)
Column({ space: 0 }) {
Text(this.weather().temp.toString() + '°')
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.weather().text)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
// ⚠️ 没配地点 / 取不到天气时**整块不画**(与网页版一致)——
// 画一个"——"占位反而让人以为坏了。
if (this.weather !== null) {
Row({ space: 6 }) {
Text(this.weather!.icon)
.fontSize(22)
.fontColor(this.weatherTint())
Column({ space: 0 }) {
Text(this.weather!.temp)
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.weather!.text)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.alignItems(HorizontalAlign.End)
}
.alignItems(HorizontalAlign.End)
.alignItems(VerticalAlign.Center)
}
.alignItems(VerticalAlign.Center)
}
.width('100%')
.alignItems(VerticalAlign.Top)
// 第二行:天气地点 + 日子标记(节气 / 节日 / 生日)
// 第二行:天气地点 + 日子标记(节气 / 节日 / 纪念日 / 生日)
// 🔴🔴 这里**绝不能**再出现 `FB_DOCK_RESERVE` 的 padding ——
// 它 96vp,接在这会把这个小方块撑成一大片空白(v54 修的那个"概述块太高")。
Column({ space: 6 }) {
Text(this.weather().place + ' · ' + this.weather().wind)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
if (this.weather !== null && (this.weather!.place !== '' || this.weather!.wind !== '')) {
Text(this.weatherLine())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.width('100%')
}
// 日子标记:今天是什么节气/节日/纪念日/谁的生日(真实数据)
if (this.marks.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.marks, (mk: FbMark, idx: number) => {
Text(mk.word)
.fontSize(FbMetric.fontTiny)
.fontColor(this.markFg(mk.kind))
.backgroundColor(this.markBg(mk.kind))
.borderRadius(FbMetric.radiusPill)
.padding({ left: 9, right: 9, top: 2, bottom: 2 })
.margin({ right: 6, top: 2 })
}, (mk: FbMark, idx: number): string => 'mk_' + idx.toString() + '_' + mk.word)
}
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_DEMO_MARKS, (mk: string, idx: number) => {
FbTag({ text: mk, tone: idx === 0 ? 'green' : 'brand' })
.margin({ right: 6, top: 2 })
}, (mk: string, idx: number): string => 'mk_' + idx.toString() + '_' + mk)
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 第三行:三个数字(未来七天日程 / 待办 / 逾期)——
// 贴在卡片底部,作为"今天要不要操心的快速判断"
// (卡片内用浅分隔线分开,视觉上比留白更"有层次")
// 第三行:四个数字(**与网页版 `hb-stats` 同源**)
// `N 条日程(今天)· N 件在做 · N 件待办 · N 件逾期`
// 🔴 用户 2026-10-16:「下面……**未来7天**……这几个就是去掉了,不要了。」
// ⇒ 原来这里有"未来七天"那一格(today..+7 天的条数),已去掉;
// 换成与网页版一样的口径(网页版概述卡本来也是这四个)。
//
// 🔴🔴 v54b(用户 2026-10-17):「里面的**日程、在做、待办的数量是错误的**」
// 真机实测:接口与端上缓存里都是 `{today_events:1, doing:3, todo:2}`(正确),
// 而界面显示 `0 / 0 / 0` —— 同一份数据里「今天的日程 1 条」却是对的。
// 根因:原来是 `FbStatTriple`,它内部用
// `@Builder cell(label, value, alarm)` —— **多值参数**,
// SDK 规则明写「**两个及以上参数不触发刷新**」
// ⇒ 初始渲染的 0 永远不会被后来的真实值替换。
// (本项目第 6 次踩同一个坑:@Builder 值参数不参与状态观察。)
// ⇒ 抽成 **`FbStatCell` 组件**(@ComponentV2 + @Param,@Param 参与观察)
// —— 与 `FbEventChip` / `FbCalOptionRow` / `FbTagPickChip` 同一套修法。
// ⚠️ 不要改回 `@Builder cell(...)`;也不要用**本页的 @Builder**(同样是按值传递)。
Divider().color(FbColors.line2)
FbStatTriple({
labelA: '未来七天',
valueA: FB_DEMO_WEEK_STAT.events.toString(),
labelB: '待办',
valueB: FB_DEMO_WEEK_STAT.todos.toString(),
labelC: '逾期',
valueC: FB_DEMO_WEEK_STAT.overdue.toString(),
cAlarm: FB_DEMO_WEEK_STAT.overdue > 0
})
Row() {
FbStatCell({ value: this.statToday, label: '今天日程' })
FbStatCell({ value: this.statDoing, label: '在做' })
FbStatCell({ value: this.statTodo, label: '待办' })
FbStatCell({ value: this.statOverdue, label: '逾期', alarm: true })
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
// 🔴 不要 `.width('100%')` —— 它会先把宽度占满,导致后面的 margin 失效,
// 卡片于是**顶到屏幕两侧边缘**(实测 x=0..1080),而别的区块是留边 16vp 的,
@@ -189,166 +372,106 @@ export struct FbHomePage {
})
.alignItems(HorizontalAlign.Start)
// ==================== ② 今天的日程 + 近期任务 ====================
// ==================== ② 今天的日程 ====================
// 🔴 用户 2026-10-16:「今天的日程,现在显示的是日程里面的展示内容。
// 把**今天那个块**拿到首页上来就行了。」
// ⇒ 直接用日程页那块面板组件(月/周/列表三处用的同一个)——
// 于是显示方式(时间轴/列表)、冲突横向并排、当前时间红线
// 在首页与日程页**完全一致**,不会再出现"两个地方长得不一样"。
// ⚠️ 面板自带卡片样式与外边距,这里**不要再套一层卡片**。
// ⚠️ `showHead: false` —— 本页已有 `FbSectionTitle`,两个头会重复。
// ⚠️ 空态文案也换掉:首页没有"右下角 +"那个按钮。
FbSectionTitle({ title: '今天的日程', extra: this.todayEvents.length.toString() + ' 条' })
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
FbDayEventPanel({
label: this.todayEvents.length > 0 ? '今天' : '',
events: this.todayEvents,
style: this.calStyle,
isToday: true,
nowMin: this.nowMinutes(),
showHead: false,
emptyTitle: '今天没有安排',
emptyHint: '过得轻松一点',
onPick: (e: FbCalEvent): void => {
// 点条目 ⇒ 切到日程模块(首页不做就地编辑:那是日程页的职责)
this.app.switchModule('calendar');
}
})
// ==================== ③ 近期任务 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '今天的日程', extra: this.todayEvents.length.toString() + ' 条' })
Column({ space: 10 }) {
if (this.todayEvents.length === 0) {
FbDotLine({ label: '今天没有安排,过得轻松一点', muted: true })
FbSectionTitle({ title: '近期任务', extra: this.taskExtra() })
Column({ space: 0 }) {
if (this.tasks.length === 0) {
Row() {
Text('没有进行中的任务,也没有待办。')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.todayEvents, (ev: FbCalEvent) => {
Row({ space: 10 }) {
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(46)
Column() {
FbDot({ dia: 8, fillC: ev.color })
}
.width(8)
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(ev.calName)
.fontSize(10)
.fontColor(Color.White)
.backgroundColor(ev.color)
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (ev: FbCalEvent): string => 'tev_' + ev.id.toString())
ForEach(this.tasks, (t: FbTaskItem, idx: number) => {
this.taskRow(t, idx === this.tasks.length - 1)
}, (t: FbTaskItem, idx: number): string => 'htk_' + t.id.toString())
}
}
.width('100%')
.padding(FbMetric.cardPad)
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 6 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 8 })
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ④ 往年的今天 ====================
// 🔴 用户 2026-10-17:「近期任务的下面,应该加上**往年的今天**,
// 跟电脑端一样,你看看那个是什么逻辑。」
// ⇒ 位置**就在近期任务下面**(用户明确指定,不是"放在首页某处")。
// 电脑端的逻辑见 `App\Home::memories()`:只认**往年的同一天**
// (不退回同月),来源是「看过的电影」+「走过的**实际行程**」,
// 合并按时间倒序取 6 条。
// ⚠️ 数据来自 `/home` 的 `memories` —— 与网页版**同一份实现**,
// 不要端上自己再查一遍(会出现"网页 2 年前、手机 3 年前")。
Column({ space: 0 }) {
FbSectionTitle({
title: '近期任务',
extra: '进行中与已逾期'
title: '往年的今天',
extra: this.todayLabel !== '' ? this.todayLabel : ''
})
Column({ space: 10 }) {
ForEach(this.recentTasks(), (t: FbTaskItem) => {
Row({ space: 10 }) {
FbDot({ dia: 8, fillC: t.overdue ? '#C0533F' : t.boardColor })
Text(t.title)
Column({ space: 0 }) {
if (this.memories.length === 0) {
// 空态与网页版一字不差(两句:一句说没有,一句给个盼头)
Column({ space: 6 }) {
Text('往年的这一天还没有记录')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (t.childTotal > 0) {
Text(t.childDone.toString() + '/' + t.childTotal.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.dueText.length > 0) {
Text(t.dueText)
.fontSize(FbMetric.fontTiny)
.fontColor(t.overdue ? FbColors.red : FbColors.faint)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (t: FbTaskItem): string => 'htk_' + t.id.toString())
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ③ 我的家人 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '我的家人', extra: '以我为中心 · 上下三代' })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_DEMO_KIN, (k: FbKinChip) => {
Column({ space: 4 }) {
FbAvatar({ name: k.shown, color: k.avatarColor, dia: 42, fontSizeVp: 17 })
Text(k.shown)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.maxLines(1)
Text(k.kinship)
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
}
.width(64)
.padding({ top: 6, bottom: 6 })
}, (k: FbKinChip): string => 'kin_' + k.id.toString())
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ④ 未来七天 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '未来七天', extra: this.soonEvents.length.toString() + ' 条' })
Column({ space: 10 }) {
ForEach(this.soonEvents, (ev: FbCalEvent) => {
Row({ space: 10 }) {
Text(ev.date)
Text('日子还长,等有了第一次,明年这时候就会自己冒出来。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(44)
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(40)
FbDot({ dia: 7, fillC: ev.color })
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (ev: FbCalEvent): string => 'sev_' + ev.id.toString())
.alignItems(HorizontalAlign.Start)
.padding({ top: 12, bottom: 12 })
} else {
ForEach(this.memories, (m: FbMemoryItem, idx: number) => {
this.memoryRow(m, idx === this.memories.length - 1)
}, (m: FbMemoryItem, idx: number): string => 'mem_' + m.kind + '_' + m.id.toString())
}
}
.width('100%')
.padding(FbMetric.cardPad)
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 6 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ⑤ 模块入口 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '全部模块', extra: this.cards.length.toString() + ' 个' })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.cards, (c: FbModuleCard) => {
this.moduleCard(c)
}, (c: FbModuleCard): string => 'mc_' + c.key)
}
.width('100%')
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 })
// 🔴🔴 **底部悬浮快捷栏的预留** —— 它必须留在**页面最根部**。
// v54 之前它被接在了概述卡中间那个小 Flex 上(96vp 凭空撑高卡片),
// 真正的页面底部反而没有留白、被悬浮栏压住最后一屏。
// ⚠️ 别再把它挪到任何中间区块上。
Blank().height(FB_DOCK_RESERVE)
}
.width('100%')
}
@@ -356,72 +479,168 @@ export struct FbHomePage {
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
/** 一块模块入口卡(图标 + 模块名 + 一句关键数字) */
/** 天气地点行:`青岛市 · 崂山区 · 东南风 12 km/h` */
private weatherLine(): string {
const w: FbWeather | null = this.weather;
if (w === null) {
return '';
}
const parts: string[] = [];
if (w.place !== '') {
parts.push(w.place);
}
if (w.tmax !== '' || w.tmin !== '') {
parts.push(w.tmax + ' / ' + w.tmin);
}
if (w.wind !== '') {
parts.push(w.wdir !== '' ? (w.wdir + ' ' + w.wind) : w.wind);
}
return parts.join(' · ');
}
/** 「近期任务」右上角小字:`3 件在做 · 5 件待办`(进行中与待办**分开数**) */
private taskExtra(): string {
let doing: number = 0;
let todo: number = 0;
for (const t of this.tasks) {
if (t.status === 'doing') {
doing++;
} else {
todo++;
}
}
if (todo > 0) {
return doing.toString() + ' 件在做 · ' + todo.toString() + ' 件待办';
}
return doing.toString() + ' 件在做';
}
/**
* 一条任务(首页版)。
*
* 🔴 与网页版首页同一套信息:圆点(看板色,逾期变红)+ 标题 + **状态词**
* (用户第 3 条:「除了显示任务的标题之外,还要显示他的状态」)
* + 子任务进度 + 截止时间。
* ⚠️ 看板色**必须**用真实派生色(`boardColor`)—— 用户 2026-10-08 报过
* 「首页近期任务……颜色并没有跟看板颜色一致」。
*/
@Builder
moduleCard(c: FbModuleCard) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Image(this.cardIcon(c.key))
.width(18)
.height(18)
.renderMode(ImageRenderMode.Template)
.fillColor(fbPalette(c.color).brand)
Text(c.name)
taskRow(t: FbTaskItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
// 圆点:逾期用红(状态语义优先),否则跟看板色
Row()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(t.overdue ? '#C0533F' : t.boardColor)
Text(t.title)
.fontSize(FbMetric.fontSmall)
.fontColor(t.overdue ? '#C0533F' : FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 状态词(待办 / 进行中)—— 用户第 3 条要求
Text(t.status === 'doing' ? '进行中' : '待办')
.fontSize(10)
.fontColor(t.status === 'doing' ? '#A8574F' : FbColors.faint)
.backgroundColor(t.status === 'doing' ? '#14A8574F' : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
if (t.childTotal > 0) {
Text(t.childDone.toString() + '/' + t.childTotal.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.dueText.length > 0) {
Text(t.dueText)
.fontSize(FbMetric.fontTiny)
.fontColor(t.overdue ? '#C0533F' : FbColors.faint)
}
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 18 })
}
}
.width('100%')
}
/**
* 一条「往年的今天」。
*
* 形态照网页版 `templates/home.php` 的 `.dash-row`:
* 圆点(旅程=绿 #3F7D5A / 观影=紫 #7A4E8C)→「N 年前」→ 🧳/🎬 + 标题 → 日期。
*
* ⚠️ 这里是 `@Builder` 带 **2 个参数** —— 与本次概述卡数字那个 bug 同一个反模式。
* 但两者的**后果不同**:本区块的数据(`this.memories`)是在页面首次加载后
* 随其它字段一起到的,且整块由 `ForEach` 按 `m.id` 重建
* (数据变化 ⇒ 列表项增删 ⇒ 走 ForEach 的重建路径,不依赖 Builder 参数刷新)。
* ⚠️ 若将来出现"memories 变了、这一行文字没变"的现象,**先改成组件**
* (参照 `FbStatCell`),不要在这个 Builder 上打补丁。
*/
@Builder
memoryRow(m: FbMemoryItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
// 圆点:两类的颜色与网页版一致(旅程绿 / 观影紫)
Row()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(m.kind === 'trip_node' ? '#3F7D5A' : '#7A4E8C')
// 「N 年前」
Text(m.yearsAgo.toString() + ' 年前')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(52)
// 🧳 / 🎬 + 标题
Text((m.kind === 'trip_node' ? '🧳 ' : '🎬 ') + m.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 那天的日期(`YYYY-MM-DD`)
Text(this.memoryDate(m.startsAt))
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
Text(c.big)
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(fbPalette(c.color).brand)
.maxLines(1)
Text(c.lines.length > 0 ? c.lines[0] : '')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 18 })
}
}
.width('48.5%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 10 })
.alignItems(HorizontalAlign.Start)
.onClick(() => {
this.app.switchModule(c.key);
})
.width('100%')
}
/** 卡片图标(按模块 key 取) */
private cardIcon(key: string): Resource {
switch (key) {
case 'people': return $r('app.media.fb_ic_people');
case 'kin': return $r('app.media.fb_ic_kin');
case 'movies': return $r('app.media.fb_ic_movies');
case 'trips': return $r('app.media.fb_ic_trips');
case 'calendar': return $r('app.media.fb_ic_calendar');
case 'resources': return $r('app.media.fb_ic_resources');
case 'tasks': return $r('app.media.fb_ic_tasks');
case 'days': return $r('app.media.fb_ic_days');
case 'ledger': return $r('app.media.fb_ic_ledger');
case 'books': return $r('app.media.fb_ic_books');
case 'notes': return $r('app.media.fb_ic_notes');
default: return $r('app.media.fb_ic_home');
}
}
/** 首页只放「进行中 + 已逾期」两类任务(与 Web 端同一取舍) */
private recentTasks(): FbTaskItem[] {
return this.tasks.filter((t: FbTaskItem): boolean => t.status === 'doing' || t.overdue);
/** `YYYY-MM-DD HH:MM:SS` → `YYYY-MM-DD`(只取日期,与网页版 `substr(...,0,10)` 一致) */
private memoryDate(s: string): string {
return s.length >= 10 ? s.substring(0, 10) : s;
}
}
+173 -49
View File
@@ -16,12 +16,15 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbLedgerBook, FbLedgerEntry } from '../../model/Types';
import {
FB_DEMO_LEDGER_BOOKS, FB_DEMO_LEDGER_ENTRIES, fbLedgerKindName, fbMoney
} from '../../model/DemoData';
import { FbGroup, FbLedgerBook, FbLedgerEntry, FbLedgerDetail} from '../../model/Types';
import { fbLedgerKindName, fbMoney } from '../../model/DemoData';
import { parseLedger, parseLedgerEntries } from '../../data/FbParse';
import { loadModule, loadPath } from '../../data/FbModuleLoad';
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_LEDGER } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbLedgerPage {
@@ -35,45 +38,139 @@ export struct FbLedgerPage {
/** 模块主题色(财务 = 橄榄绿) */
private accent: string = '#5E7A3C';
private books: FbLedgerBook[] = FB_DEMO_LEDGER_BOOKS;
private entries: FbLedgerEntry[] = FB_DEMO_LEDGER_ENTRIES;
/**
* 账本列表(`/api/v1/ledger`)。
* ⚠️ 初值空数组:先渲染假数据会"闪一下"。
*/
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local books: FbLedgerBook[] = [];
/** 当前这本账的明细(`/ledger/<id>/entries`) */
@Local entries: FbLedgerEntry[] = [];
/** 顶部提示(同步失败 / 未登录) */
@Local syncNote: string = '';
/** 当前账本 id(0 = 用第一本) */
@Local bookId: number = 0;
build() {
Scroll() {
Column() {
this.topBar()
/**
* 页面出现:**两级加载**。
*
* ⚠️ 账本必须走两级:`/ledger`(有哪些本)→ `/ledger/<id>/entries`(明细 + 汇总)。
* 净资产只在这第二级里 ⇒ 拿到第一本之后要立刻拉一次明细。
*/
aboutToAppear(): void {
this.load();
}
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.currentBook().name)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.books.length.toString() + ' 本账 · '
+ this.currentBook().entryCount.toString() + ' 条账目')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一笔',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
private async load(): Promise<void> {
await loadModule<FbLedgerBook[]>('ledger', parseLedger,
(rows: FbLedgerBook[]): void => {
this.books = rows;
// 选中当前本(没选过就用第一本)
let target: number = this.bookId;
if (target <= 0 && rows.length > 0) {
target = rows[0].id;
this.bookId = target;
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
if (target > 0) {
this.loadEntries(target);
}
},
(note: string): void => {
this.syncNote = note;
});
}
// ---- 账本切换(横向)----
Scroll() {
/** 拉某本账的明细(含汇总) */
private async loadEntries(id: number): Promise<void> {
// ⚠️ 用 `loadPath`(路径带 id,没法登记在静态模块表里)。
const key: string = 'ledger_detail_' + id.toString();
await loadPath<FbLedgerDetail>(key, '/api/v1/ledger/' + id.toString() + '/entries',
parseLedgerEntries,
(d: FbLedgerDetail): void => {
this.entries = d.entries;
// ⚠️ 净资产写回**这本账**(页面的 `netCent()` 是从 `currentBook()` 上取的)。
// 概览接口不返回 net,所以这里补上 —— 不新增页面字段,
// 避免和已有的 `netCent()` 方法重名(实测撞过一次)。
for (const bk of this.books) {
if (bk.id === id) {
bk.netCent = d.netCent;
}
}
},
(note: string): void => {
this.syncNote = note;
});
}
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
Scroll() {
Column() {
this.topBar()
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.currentBook().name)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.books.length.toString() + ' 本账 · '
+ this.currentBook().entryCount.toString() + ' 条账目')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一笔',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 账本切换(横向滚动条)----
// ⚠️ 这层 `Scroll` **必须保留**:它是**横向**滚动(账本多时排不下)。
// 它与"页面纵向滚动"是两码事,不能被拍平(拍平会整条挤在一起)。
// 也**不挂** `fbOnScroll` —— 横向滚动不该影响纵向的悬浮栏收起逻辑。
Scroll() {
Row({ space: 8 }) {
ForEach(this.books, (b: FbLedgerBook) => {
Row({ space: 6 }) {
Text(b.name)
@@ -95,15 +192,16 @@ export struct FbLedgerPage {
this.bookId = b.id;
})
}, (b: FbLedgerBook): string => 'bk_' + b.id.toString())
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
.scrollable(ScrollDirection.Horizontal)
// 🔴 横向滚动上报(让抽屉给横滑组件让位)—— 见 common/FbScroll.ets
.onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
Scroll() {
Column({ space: 0 }) {
// ---- 概览卡 ----
@@ -234,17 +332,43 @@ export struct FbLedgerPage {
.width('100%')
}
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '账本设置',
items: FB_SET_LEDGER,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一条账目:名字 + 归属人 + 金额 + 盘点入口 */
@@ -0,0 +1,674 @@
/**
* 家书 FamilyBook · 登录页(鸿蒙端)
*
* 完整链路(用户 2026-10-17「完整的登录功能」):
* ① 第一次打开 → **输入服务器地址** → `GET /api/v1/ping` 验"这上面真是家书";
* ② 验通了把地址**持久化** → 第二步输「账号 + 密码」;
* ③ `POST /api/v1/login` → 拿到 API 令牌 → 存进 `FbTokenStore`;
* ④ **立刻全量同步**(12 个模块,带实时进度)→ 进主界面;
* ⑤ 之后每个请求都由 `fbApi()` 自动带上 `Authorization: Bearer <token>`
* (见 `FbHttp.fbApi`,令牌不在各页面里各传一遍)。
*
* 🔴 为什么地址要问用户,而不是写死在代码里(v55 的核心改动):
* 写死意味着这个 App 只能连我们自己那一台 —— 别人装上去就是
* "登录永远失败",而且**连失败原因都看不到**(他甚至不知道地址能改)。
* ⇒ 地址是运行期配置(`FbServerStore`),第一次问一次,之后一直用。
*
* 🔴 为什么第二步既认手机号也认用户名:
* 一家人里老人记不住手机号,但记得住自己叫什么。服务端 `login` 两步查找
* (先手机号哈希、再姓名),重名时明确要求改用手机号(见 ApiHandler 的说明)。
*
* 🔴 三种进入方式(都要有,别只做第一种):
* ① **登录**:地址 + 账号密码(正常路径);
* ② **先不登录**:本地优先架构下,没令牌照样能看已同步的数据。
* 把用户堵在登录页是**错的**(他可能只是没网,或家里老人不需要同步);
* ③ 已登录(令牌还在)时**根本不显示本页**(在 Index 里就分流掉了)。
*
* ⚠️ 密码**不做任何本地缓存**:只在提交那一刻用一次,用完立刻清
* (连变量都尽量短命)。App 里唯一要留的是**令牌**。
* ⚠️ 换服务器 = 换了一台机器,旧令牌在新机器上无效 ——
* 所以设置页的「更换服务器」会连带清掉令牌(见 FbSettingsPage)。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbLoginClient, FbLoginResult } from '../../data/FbLogin';
import { FB_DEFAULT_SERVER, fbProbeServer, FbProbeResult } from '../../data/FbHttp';
import { FB_SERVER_STORE, fbNormalizeServer, fbServerLooksValid } from '../../data/FbServerStore';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
// ⚠️ `KeyboardAvoidMode` 有两个同名枚举:
// · `component/common.d.ts` 里的(DEFAULT/NONE);
// · `@ohos.arkui.UIContext` 里的(OFFSET/RESIZE/NONE)—— **setKeyboardAvoidMode 要的是这个**。
// 两者同名但不同型,不导入就会报 "not assignable to parameter of type"。
import { KeyboardAvoidMode } from '@kit.ArkUI';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { FbSyncAllResult, syncAll } from '../../data/FbSyncAll';
// ⚠️ 注意与 `model/Types` 里的 `FbSession`(数据模型)不是一回事:
// 这个 `FB_SESSION` 是**会话状态机**(校验/离线计时),见 data/FbSession.ets
import { FB_SESSION } from '../../data/FbSession';
import { FbAccount, FbSession } from '../../model/Types';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbLoginPage';
/**
* 登录后"原地等同步"的上限(毫秒)。
*
* 🔴 为什么要有个上限(而不是一直等同步跑完):
* 12 个模块最坏情况要几分钟(每模块自带 20 秒网络超时)。
* 让用户在登录页盯着转圈几分钟,跟"卡死"没有区别。
* ⇒ 15 秒还没完就**放他进去**(数据在后台继续落地,本地优先架构本就能这么干),
* 同时给一个「立即进入」让他自己决定要不要等。
*/
const SYNC_WAIT_MS: number = 15000;
@ComponentV2
export struct FbLoginPage {
/**
* 全局状态(只为拿**状态栏高度**——应用开了全屏布局,
* 登录页也要自己给状态栏让位,见 `build()` 里品牌区的 padding)。
*/
@Param app: FbAppState = fbAppState();
/** 登录成功回调(由 Index 传入:进主界面) */
@Event onLoggedIn: () => void = (): void => {
};
/** 「先不登录」回调(不登录也进主界面) */
@Event onSkip: () => void = (): void => {
};
/**
* 从第几步开始(0 = 按本地有没有地址自动决定)。
*
* 由 `Index` 从 `FbAppState.loginStartStep` 传进来 ——
* 设置页点「更换服务器」是 1(连地址一起换),点「退出登录」是 2(地址还留着)。
* ⚠️ 它是 `@Param`:只在组件**创建**时参与 `aboutToAppear`,
* 运行中改它不会把用户从第二步弹回第一步(那正是我们要的)。
*/
@Param startStep: number = 0;
/**
* 🔴 v57(用户 2026-10-17):是否允许「先不登录 / 离线使用」。
*
* `true` = 从没登录过(首次使用)—— 本地优先,不该把人堵在登录页;
* `false` = 被服务端踢了、或用户主动退出登录 ——
* 用户原话:「退到登录页面之后,里面所有的资源、所有的内容都是不允许看的。」
* ⇒ 这时候留一个"先不登录"按钮,等于给安全策略开了个后门。
*
* ⚠️ `@Param` 由 `Index` 按 `app.lockReason === ''` 决定,登录页自己不改它。
*/
@Param allowSkip: boolean = true;
/** 当前步骤:1 = 填服务器地址,2 = 填账号密码 */
@Local step: number = 1;
/** 服务器地址输入框 */
@Local serverInput: string = '';
/** 账号(手机号或用户名) */
@Local account: string = '';
@Local password: string = '';
/** 是否正在提交(提交中禁用按钮,避免重复请求) */
@Local busy: boolean = false;
/** 错误提示(空串 = 无) */
@Local error: string = '';
/** 探测通过后服务端自报的应用名(通常「家书」) */
@Local serverName: string = '';
/** 是否正在"登录后同步"这一段 */
@Local syncing: boolean = false;
/** 同步进度文案(当前模块名) */
@Local syncNote: string = '';
@Local syncDone: number = 0;
@Local syncTotal: number = 12;
/**
* 是否已经"进到应用里"(防重复进入)。
*
* ⚠️ 不是 @Local:它只用于代码里判重(同步跑完 与 用户点「立即进入」
* 可能同时发生),不参与渲染,加状态标记只会触发多余重绘。
*/
private entered: boolean = false;
/** 主题色(与「名册」模块一致,登录页用砖红) */
private accent: string = '#A84A28';
/**
* 页面出现时:决定从哪一步开始。
*
* ⚠️ 已配过地址(老用户 / 退出登录后重进)⇒ **直接从第二步开始**,
* 并预填地址:让用户看到"我连的是哪台",而不是每次重填一遍。
*/
aboutToAppear(): void {
try {
// 🔴 取 `OFFSET`(**页面上移**),不是 RESIZE(压缩高度)。
// SDK 注释:RESIZE 会重排页面、且与 `expandSafeArea(KEYBOARD)` 不能共用;
// OFFSET 只是把页面往上推,对"表单在下方"的登录页更稳(默认值也正是 OFFSET)。
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.OFFSET);
} catch (err) {
hilog.warn(DOMAIN, TAG, '设置键盘避让失败(不影响登录):%{public}s', JSON.stringify(err));
}
const saved: string = FB_SERVER_STORE.get();
// ⚠️ 三步判断,顺序不能反:
// ① 调用方**显式指定**了起始步(设置页的「更换服务器 / 退出登录」);
// ② 否则按"本地有没有地址"自动决定(正常启动)。
if (this.startStep === 1) {
this.serverInput = saved !== '' ? saved : FB_DEFAULT_SERVER;
this.step = 1;
} else if (this.startStep === 2 && saved !== '') {
this.serverInput = saved;
this.step = 2;
} else if (saved !== '') {
this.serverInput = saved;
this.step = 2;
} else {
// 首次:预填一个默认地址(老用户升级后不用手打域名)。
// ⚠️ 这只是输入框的**初始值**,必须由用户点「连接」确认后才写入 store。
this.serverInput = FB_DEFAULT_SERVER;
this.step = 1;
}
}
/** 拿应用上下文(拿不到就返回 undefined,由调用方给提示) */
private ctx(): common.Context | undefined {
return this.getUIContext().getHostContext();
}
/** 第一步能不能提交 */
private canConnect(): boolean {
return !this.busy && this.serverInput.trim() !== '';
}
/** 第二步能不能提交 */
private canSubmit(): boolean {
return !this.busy && !this.syncing && this.account.trim() !== '' && this.password !== '';
}
/**
* 第一步:验证并保存服务器地址。
*
* 🔴 只有 `fbProbeServer` 验通才写 store:
* 先存再验的话,用户填错一次地址,之后每次启动都会对着一个错的地址
* 发请求(而且他很难意识到"错在地址")。
*/
private async connect(): Promise<void> {
if (!this.canConnect()) {
return;
}
this.error = '';
const base: string = fbNormalizeServer(this.serverInput);
if (!fbServerLooksValid(base)) {
this.error = '地址看起来不对,应该形如 https://family.example.com';
return;
}
const c: common.Context | undefined = this.ctx();
if (c === undefined) {
this.error = '应用上下文不可用,请重启应用再试';
return;
}
this.busy = true;
const r: FbProbeResult = await fbProbeServer(base);
if (!r.ok) {
this.busy = false;
this.error = r.error !== '' ? r.error : '连接失败,请检查地址';
return;
}
const saved: string = await FB_SERVER_STORE.set(c, base);
this.busy = false;
if (saved === '') {
this.error = '地址没能保存(本地存储异常)';
return;
}
this.serverInput = saved;
this.serverName = r.app;
this.step = 2;
hilog.info(DOMAIN, TAG, '服务器地址已确认:%{public}s', saved);
}
/** 已确认的服务器主机部分(显示用:去掉协议,省一行宽度) */
private serverHost(): string {
const base: string = FB_SERVER_STORE.get();
return base.replace(/^https?:\/\//i, '');
}
/**
* 第二步:登录 → 存令牌 → 同步。
*
* 🔴 登录成功后**不立刻跳转**,而是停在原地显示同步进度:
* 用户刚点了"登录",最想看的是"我的数据进来了"。
* 直接跳进主界面(各页还是空的、慢慢填)会让人觉得"没连上"。
* ⇒ 给 15 秒的同步窗口(见 `SYNC_WAIT_MS` 的说明),完事或有超时就进去。
*/
private async submit(): Promise<void> {
if (!this.canSubmit()) {
return;
}
const c: common.Context | undefined = this.ctx();
if (c === undefined) {
this.error = '应用上下文不可用,请重启应用再试';
return;
}
this.busy = true;
this.error = '';
const res: FbLoginResult = await FbLoginClient.login(this.account.trim(), this.password);
// ⚠️ 无论成功失败,**立刻清掉内存里的密码**(它没有任何理由继续留在页面上)
this.password = '';
if (!res.ok) {
this.busy = false;
this.error = res.error !== '' ? res.error : '登录失败,请重试';
return;
}
const saved: boolean = await FB_TOKEN_STORE.set(c, res.token);
if (!saved) {
// 令牌拿回来了却存不下 —— 这不是"登录失败",是**本地存储**的问题,
// 提示要能让用户/我们区分开。
this.busy = false;
this.error = '登录成功但令牌没能保存(本地存储异常)';
return;
}
/* 🔴 v57:记下"刚跟服务器通过话"。
离线 24 小时判定靠的就是这个时间(`FbSession.offlineMs()`)。
不记的话,登录后第一次断网启动就会被判成"离线超时"。
⚠️ 用服务端返回的时间(`res.serverTime`)而不是本机时间 ——
本机时钟可以改,服务端时间不能。 */
await FB_SESSION.markVerified(c, res.serverTime);
this.busy = false;
this.applySession(res);
hilog.info(DOMAIN, TAG, '登录成功,person_id=%{public}d', res.personId);
// ---- 登录成功 → 全量同步(带实时进度)----
this.syncing = true;
this.syncNote = '准备同步…';
this.syncDone = 0;
const job: Promise<FbSyncAllResult> = syncAll(
(label: string, done: number, total: number): void => {
this.syncNote = label;
this.syncDone = done;
this.syncTotal = total;
});
// ⚠️ 同步**不等它跑完**(可能几分钟):设定窗口到点就放人进去,
// 同步本身在后台继续(`syncAll` 的 Promise 自己会跑到底)。
await Promise.race([job, this.wait(SYNC_WAIT_MS)]);
this.finish();
}
/** 等一会儿(ArkTS 没有 setTimeout 的 Promise 版) */
private wait(ms: number): Promise<void> {
return new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
}
/**
* 把登录返回的身份写进全局会话。
*
* ⚠️ **整体替换对象**而不是改字段:`FbSession`/`FbAccount` 是 interface,
* V2 对"对象的深层属性"观察不可靠;换掉引用才能保证读到它的页面都刷新
* (设置页顶部的头像与姓名就靠这个 —— 否则永远显示演示数据里的那个名字)。
*/
private applySession(res: FbLoginResult): void {
const old: FbSession = this.app.session;
const me: FbAccount = {
id: res.personId > 0 ? res.personId : old.me.id,
name: res.personName !== '' ? res.personName : old.me.name,
kinship: old.me.kinship,
avatarColor: old.me.avatarColor,
role: res.role !== '' ? res.role : old.me.role,
isGuest: res.role === 'guest',
canWrite: res.role !== '' && res.role !== 'guest',
};
const next: FbSession = {
loggedIn: true,
familyId: old.familyId,
familyTitle: res.familyName !== '' ? res.familyName : old.familyTitle,
slogan: old.slogan,
me: me,
};
this.app.session = next;
}
/** 进主界面(幂等:同步跑完 与 用户点「立即进入」可能同时发生) */
private finish(): void {
if (this.entered) {
return;
}
this.entered = true;
this.syncing = false;
this.onLoggedIn();
}
/** 回第一步换服务器(账号密码保留,万一只是地址写错了) */
private backToServer(): void {
this.step = 1;
this.error = '';
this.serverName = '';
}
build() {
Stack({ alignContent: Alignment.TopStart }) {
Scroll() {
Column({ space: 0 }) {
// ---- 品牌区:logo + 应用名(用**同一张**桌面图标,见 AppScope 的说明)----
// 🔴 用**横向**排布(logo 在左、文字在右),不用纵向堆叠。
// 原因(实测数据说话):键盘弹出后 Scroll 的可视高度只有 **1365px**
// (屏幕 2444、键盘占 1488 起);而**纵向**品牌区要 548px,
// 再加表单与按钮,总高超过 1365 ⇒ **按钮被裁在可视区之外,用户点不到**。
Row({ space: 12 }) {
Image($r('app.media.fb_logo'))
.width(48)
.height(48)
.borderRadius(12)
.border({ width: 1, color: FbColors.line })
Column({ space: 2 }) {
Text('家书')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.step === 1 ? '先连上你家的服务器' : '一家人的名册、日程与记忆')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 🔴 top 加**状态栏高度**:应用已开 `setWindowLayoutFullScreen(true)`,
// 窗口不再为状态栏让位 ⇒ 登录页品牌区也要自己让出这一截,
// 否则 logo 与「家书」会被时间/信号图标压住。
.padding({
left: FbMetric.pagePad,
right: FbMetric.pagePad,
top: 24 + this.app.statusBarH,
bottom: 20
})
if (this.step === 1) {
this.serverStep()
} else {
this.accountStep()
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
// ---- 同步遮罩(登录成功后浮在上面,不打断下面的表单状态)----
if (this.syncing) {
this.syncOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
}
/** 第一步:服务器地址 */
@Builder
serverStep() {
Column({ space: 0 }) {
Text('服务器地址')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ bottom: 6 })
TextInput({ placeholder: 'https://family.example.com', text: this.serverInput })
// ⚠️ 用 `Normal` 而不是 `URL`:`URL` 键盘上**没有 `/` 和 `.` 以外的符号**,
// 内网地址(`http://192.168.1.8:8080`)需要冒号和数字,Normal 更宽松。
.type(InputType.Normal)
.maxLength(200)
.fontSize(FbMetric.fontSmall)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.serverInput = v;
})
.onSubmit((): void => {
this.connect();
})
this.errorBox()
Text(this.busy ? '正在连接…' : '连 接')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.backgroundColor(this.canConnect() ? this.accent : FbColors.faint)
.borderRadius(FbMetric.radiusS)
.margin({ top: 18 })
.onClick((): void => {
this.connect();
})
Text('就是你在浏览器里打开家书网页版时用的那个地址。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 10 })
// 没服务器也能进(看本机已同步的数据)
// 🔴 v57:只有「从没登录过」才给这个出口(见 allowSkip 的说明)
if (this.allowSkip) {
Text('先不连接,离线使用')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 16, bottom: 16 })
.margin({ top: 6 })
.onClick((): void => {
this.onSkip();
})
}
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
/** 第二步:账号 + 密码 */
@Builder
accountStep() {
Column({ space: 0 }) {
// ---- 已连的服务器(可点「更换」)----
Row({ space: 8 }) {
Text('服务器')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(this.serverHost())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('更换')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.padding({ left: 6, right: 2 })
.onClick((): void => {
this.backToServer();
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
/* 🔴 v57:为什么突然要重新登录 —— 必须写出来。
用户被远程踢掉时,App 只会"跳回登录页"。不说明原因的话,
他第一反应是"App 坏了/我不记得密码了",而不是"我的号被退了"。 */
if (this.app.lockReason !== '') {
Text(this.app.lockReason)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.red)
.textAlign(TextAlign.Center)
.lineHeight(18)
.width('100%')
.padding({ top: 12, bottom: 2 })
}
// ---- 账号 ----
Text('账号')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 16, bottom: 6 })
TextInput({ placeholder: '手机号或姓名', text: this.account })
// ⚠️ 这里**不能**用 `InputType.PhoneNumber`:有的人是用姓名登录的,
// 数字键盘上打不出汉字(实测会被键盘挡住、只能退出去复制粘贴)。
.type(InputType.Normal)
.maxLength(60)
.fontSize(FbMetric.fontSmall)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.account = v;
})
// ---- 密码 ----
Text('密码')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 14, bottom: 6 })
TextInput({ placeholder: '登录密码', text: this.password })
// ⚠️ `InputType.Password`:系统负责遮蔽显示;
// 别自己用 `***` 替换 —— 那样光标位置/输入法都会出问题。
.type(InputType.Password)
.maxLength(72)
.fontSize(FbMetric.fontSmall)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
this.password = v;
})
// 键盘上的"完成"直接提交,省一次点击
.onSubmit((): void => {
this.submit();
})
this.errorBox()
// ---- 登录按钮 ----
Text(this.busy ? '正在登录…' : '登 录')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.width('100%')
.height(44)
.backgroundColor(this.canSubmit() ? this.accent : FbColors.faint)
.borderRadius(FbMetric.radiusS)
.margin({ top: 18 })
.onClick((): void => {
this.submit();
})
// ---- 先不登录 ----
// 🔴 v57:被踢 / 主动退出后**没有**这个出口(见 allowSkip 的说明)
if (this.allowSkip) {
Text('先不登录(看本地已有的数据)')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 16, bottom: 16 })
.margin({ top: 6 })
.onClick((): void => {
this.onSkip();
})
}
Text('账号和密码与网页版是同一套;手机号或姓名都可以。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
.padding({ bottom: 8 })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
/** 错误提示条(空串时不占位) */
@Builder
errorBox() {
if (this.error !== '') {
Row({ space: 6 }) {
Text('!')
.fontSize(FbMetric.fontSmall)
.fontColor('#A32D2D')
.fontWeight(FontWeight.Bold)
Text(this.error)
.fontSize(FbMetric.fontSmall)
.fontColor('#A32D2D')
.layoutWeight(1)
.maxLines(4)
}
.width('100%')
.margin({ top: 12 })
.alignItems(VerticalAlign.Top)
}
}
/**
* 同步遮罩:登录成功后的"正在把数据搬到这台手机"。
*
* ⚠️ 必须**挡住**下面的表单(半透明底 + 全屏),否则用户会去点「登录」第二次。
* ⚠️ 给「立即进入」:网络慢的家庭(农村宽带/4G)可能真要跑一两分钟,
* 不能把人锁在登录页 —— 数据在后台继续同步,进去看得到多少是多少。
*/
@Builder
syncOverlay() {
Column({ space: 0 }) {
Column({ space: 14 }) {
Text('正在把家里的数据同步到这台手机')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.textAlign(TextAlign.Center)
Text(this.syncNote !== '' ? this.syncNote : '准备中…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
// 进度条(用 `Progress` 而不是自制矩形:它与系统动效一致)
Progress({ value: this.syncDone, total: this.syncTotal > 0 ? this.syncTotal : 12,
type: ProgressType.Linear })
.width('80%')
.color(this.accent)
Text(this.syncDone.toString() + ' / ' + this.syncTotal.toString() + ' 个模块')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text('立即进入(后台继续同步)')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.margin({ top: 6 })
.onClick((): void => {
this.finish();
})
}
.width('86%')
.padding({ top: 26, bottom: 20, left: 18, right: 18 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: FbColors.line })
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
// 半透明遮住整页(含状态栏区域,因为窗口是全屏的)
.backgroundColor('#B3F7F3EA')
}
}
+55 -3
View File
@@ -14,9 +14,11 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbNoteItem, FbNotificationItem } from '../../model/Types';
import { FB_DEMO_NOTES, FB_DEMO_NOTIFICATIONS } from '../../model/DemoData';
import { parseNotes, parseNotifications } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbAvatar, FbDot, FbEmptyBox, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
// ============================================================
// 笔记
@@ -34,7 +36,28 @@ export struct FbNotesPage {
/** 模块主题色(笔记 = 褐灰) */
private accent: string = '#7A6A4F';
private all: FbNoteItem[] = FB_DEMO_NOTES;
/** 笔记列表(`/api/v1/notes`)。空初值:别先闪假数据。 */
@Local all: FbNoteItem[] = [];
/** 顶部提示 */
@Local syncNote: string = '';
/**
* 页面出现:读缓存 → 拉接口(本地优先)。
* ⚠️ 不自己开库:`EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbNoteItem[]>('notes', parseNotes,
(rows: FbNoteItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
build() {
Scroll() {
@@ -100,6 +123,12 @@ export struct FbNotesPage {
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
}
@Builder
@@ -171,7 +200,24 @@ export struct FbNotificationsPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private all: FbNotificationItem[] = FB_DEMO_NOTIFICATIONS;
/** 通知列表(`/api/v1/notifications`) */
@Local all: FbNotificationItem[] = [];
/** 顶部提示 */
@Local syncNote: string = '';
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbNotificationItem[]>('notifications', parseNotifications,
(rows: FbNotificationItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
build() {
Scroll() {
@@ -233,6 +279,12 @@ export struct FbNotificationsPage {
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
+461 -36
View File
@@ -7,30 +7,399 @@
*
* 页头右侧两个入口:搜索、设置(影院管理 / 统计 / 年度报告 —— 与 Web 端一致,
* 「影院」不单独占抽屉位置,它是观影模块的一部分)。
*
* 🔴 **本地优先(local-first)的第一个落地模块**(用户 2026-10-12 定):
* 数据流是「接口 → 本地库 → UI」,而不是「UI 直接读接口」。
* · `aboutToAppear` 时先读**本地库**渲染(有数据就立刻显示,不等网络);
* · 然后**后台**跑一次 `sync()` 把服务端数据补进来;
* · 同步完再读一次本地库刷新界面。
* 这样断网时页面照常可用(因为读的本来就是本地),
* 有网时数据会自动变新。
*
* ⚠️ 为什么不在 `aboutToAppear` 里 `await sync()` 再渲染:
* 那样就成了"网络优先"——转圈等 2 秒才出内容,
* 断网时更是完全空白。**先渲染本地、再后台同步**是这套架构的要点。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbMovieItem, FbKinChip } from '../../model/Types';
import { FB_DEMO_CINEMAS, FB_DEMO_MOVIES } from '../../model/DemoData';
import { FbKinChip } from '../../model/Types';
import { FbCinemaItem } from '../../data/FbMovieRepo';
import { FbDot, FbEmptyBox, FbIconButton, FbPersonChip, FbPrimaryButton, FbRating, FbSectionTitle } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { FbDb } from '../../data/FbDb';
import { FbMovieDetail, FbMovieListItem, FbMovieRepo, FbSyncResult } from '../../data/FbMovieRepo';
import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet';
import { FbCinemaSheet } from '../../components/FbCinemaSheet';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_MOVIES } from '../../model/FbSettingsMenus';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { fbImageUrl } from '../../data/FbHttp';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbMoviesPage';
/**
* 把接口的完整时间('YYYY-MM-DD HH:mm:ss')裁成卡片上够用的短形式。
*
* ⚠️ 只在**显示**时裁,存库仍存完整值 —— 否则详情页/排序会丢精度。
* ⚠️ 输入可能为空(老数据没填时间)⇒ 必须返回空串而不是 'undefined'。
*/
function shortTime(v: string): string {
if (v.length < 16) {
return v; // 短于 'YYYY-MM-DD HH:mm' 就原样返回(含空串)
}
return v.substring(0, 16); // 去掉最后的 ':ss'
}
@ComponentV2
export struct FbMoviesPage {
@Param app: FbAppState = fbAppState();
/**
* 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。
* 🔴 用户要求「向上滑动时标题栏跟着一起滑走」。
* 🔴 用户要求「向上滑动时滑动时标题栏跟着一起滑走」。
*/
@BuilderParam topBar: () => void;
private movies: FbMovieItem[] = FB_DEMO_MOVIES;
private cinemas: string[] = FB_DEMO_CINEMAS;
/**
* 观影列表(**来自本地库**)。
*
* ⚠️ 初值给空数组而不是演示数据:一上来就显示假数据、随后被真数据替换,
* 用户会看到"闪一下"。空数组 + 一个明确的"加载中/暂无"状态更诚实。
* (演示数据仍保留在 DemoData 里,供后续做空态预览用。)
*/
@Local movies: FbMovieListItem[] = [];
/** 是否正在首次加载(决定显示"加载中"还是"暂无记录") */
@Local loading: boolean = true;
/** 观影页顶部一行提示(自检/同步状态共用;谁最后写谁显示) */
@Local syncNote: string = '';
/** 临时自检结果(与同步提示分开,避免互相覆盖) */
/**
* 海报加载失败的电影 id。
*
* 🔴 为什么需要它:海报是网络图,取不到是**常态**(没网、图被删、令牌失效)。
* 不处理的话这张卡会一直显示"半张白图",用户以为页面坏了。
* ⇒ 记下失败的 id,那张卡改回"图标 + 片名"的占位(与"本来就没海报"同观感)。
* ⚠️ 用 `@Local` + 数组整体替换来触发刷新:
* ArkUI 的 `@Local` 只代理第一层,直接 `push` 到数组里**不会刷新界面**
* (§05-state 的规则)。所以每次都用新数组赋值。
*/
@Local posterFailIds: number[] = [];
// ---- 详情浮层 ----
/** 浮层是否显示 */
@Local sheetOpen: boolean = false;
/** 浮层里的详情(null = 还没加载出来) */
@Local sheetDetail: FbMovieDetail | null = null;
/** 详情是否加载中 */
@Local sheetLoading: boolean = false;
/** 详情加载失败信息(空串 = 没失败) */
@Local sheetError: string = '';
/** 浮层对应的列表项评分(详情接口不给评分,从列表带,见 FbMovieRepo.detail 说明) */
@Local sheetRating: number = 0;
@Local sheetReviewCount: number = 0;
/**
* 浮层正在展示的电影 id。
*
* ⚠️ 两个用途,缺一不可:
* ① **防重入**:连点同一条时不重复叠浮层(实测会叠层,照片数翻倍);
* ② **防串数据**:详情是异步拉的,回来时要确认"还是这条"才写入
* (否则点 A 又点 B,A 的数据会糊到 B 的浮层上)。
*/
@Local sheetMovieId: number = 0;
/**
* 影院列表(**从本地库读**,`/api/v1/cinemas` 的同步结果)。
*
* 🔴 之前这里是 `FB_DEMO_CINEMAS`(写死的演示数组),只用来显示"影院 N 家"
* —— 而「5 家」那个数字**恰好**跟真实值一样,所以看不出是假的。
* 改成真实列表后,数字与列表同源,不会再对不上。
*/
@Local cinemas: FbCinemaItem[] = [];
/**
* 模块设置浮层是否打开。
*
* 🔴 用户 2026-10-16:右上角按钮统一改成网页版那种**三条横线**(☰),
* 点开是**这个模块的设置菜单**(照 `templates/movies/_settings_menu.php`)。
*/
@Local settingsOpen: boolean = false;
/** 影院浮层是否打开 */
@Local cinemaSheetOpen: boolean = false;
/**
* 🔴 浮层开合时**让底部悬浮栏让位**(与名册页同一处 bug,见 `FbPeoplePage` 的说明)。
*
* 浮层装在**页面自己的 Stack** 里,而悬浮栏在**更外层 `Index` 的 Stack** 里
* ⇒ 悬浮栏层级更高 ⇒ 浮层底部的按钮会与它重叠、点击被抢走。
* ⇒ 用 `@Monitor` 一处声明、自动跟随派生(避免在多个赋值点手写、漏一处就出事)。
*/
@Monitor('sheetOpen', 'cinemaSheetOpen', 'settingsOpen')
onSheetChanged(): void {
const app: FbAppState = fbAppState();
app.sheetCount = (this.sheetOpen || this.cinemaSheetOpen || this.settingsOpen) ? 1 : 0;
}
/** 模块主题色(观影 = 紫) */
private accent: string = '#7A4E8C';
private repo: FbMovieRepo = FbMovieRepo.inst();
/**
* 页面出现:先读本地库渲染,再后台同步。
* ⚠️ 不能声明成 async 让框架 await —— 生命周期钩子返回 void 即可,
* 异步逻辑在内部自己跑(否则会影响页面首帧时机)。
*/
aboutToAppear(): void {
this.bootstrap();
}
/** 该电影的海报是否已确认加载失败 */
private posterFailed(id: number): boolean {
return this.posterFailIds.indexOf(id) >= 0;
}
/**
* 记下"这张海报加载失败"。
*
* ⚠️ 必须**换成新数组**赋值(`concat`),不能 `push` ——
* `@Local` 不代理数组内部变化,push 进去界面不会刷新
* (表现为"图片还是白的,占位也没出现",看着像没生效)。
* ⚠️ 已经记过就不重复加:`onError` 可能被多次触发,
* 重复添加会让数组无限增长(虽然去重判断也要 O(n),但这里 n 很小)。
*/
private markPosterFailed(id: number): void {
if (this.posterFailed(id)) {
return;
}
this.posterFailIds = this.posterFailIds.concat([id]);
}
/**
* 点卡片 → 打开详情浮层。
*
* 🔴 先"立刻开浮层 + 显示加载中",再异步拉详情:
* 如果等详情回来才开浮层,用户点下去会有**明显延迟**(网络快则一闪、
* 慢则好几秒毫无反应,用户以为没点到)。
* 先把浮层弹出来、里面写"正在加载详情…",体感完全不同。
*
* ⚠️ 未登录(没令牌)时**不开浮层**,而是给一句提示:
* 详情要连服务端拉,没令牌必然失败;让用户看到"加载失败"不如
* 直接说清"没连服务器"。
*/
private openDetail(mv: FbMovieListItem): void {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.syncNote = '未连接服务器,详情需要联网查看';
return;
}
// 🔴 **防重入**:已经是这条、且浮层已经开着 ⇒ 直接返回。
// 不加这个判断时,连点卡片会**反复叠加浮层**
// (2026-10-12 实测:浮层里的照片数从 5 变成 9,就是叠了两层)。
if (this.sheetOpen && this.sheetMovieId === mv.id) {
return;
}
this.sheetMovieId = mv.id;
// 先把浮层弹出来(带加载态),再拉数据
this.sheetRating = mv.reviewAvg;
this.sheetReviewCount = mv.reviewCount;
this.sheetDetail = null;
this.sheetError = '';
this.sheetLoading = true;
this.sheetOpen = true;
this.loadDetail(mv.id, token, mv);
}
/** 拉详情并填进浮层 */
private async loadDetail(id: number, token: string, mv: FbMovieListItem): Promise<void> {
const d: FbMovieDetail = await this.repo.detail(id, token, mv);
// ⚠️ 用户可能在等待期间**已经关掉浮层**(或点了别的卡片)
// ⇒ 回来时先确认浮层还开着、且**还是这一条**,否则会把旧数据糊到新浮层上。
if (!this.sheetOpen || this.sheetMovieId !== id) {
return;
}
this.sheetLoading = false;
if (!d.ok) {
this.sheetError = d.error;
return;
}
this.sheetDetail = d;
}
/** 启动流程:开库 → 读本地 → 后台同步 → 再读本地 */
private async bootstrap(): Promise<void> {
hilog.info(DOMAIN, TAG, 'bootstrap 开始');
try {
// ① 取 Ability 上下文。
// 🔴 用 `getUIContext().getHostContext()`,**不用全局 `getContext(this)`**:
// 全局 getContext 在 Stage 模型多 ArkUI 实例下可能取错/取不到,
// 规则文件明确要求换掉(r/quick-rules/02-uicontext.md)。
// RDB / preferences 都要靠这个 Context 定位应用沙箱目录。
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
hilog.error(DOMAIN, TAG, '拿不到 Ability Context,本地库无法打开');
this.loading = false;
this.syncNote = '本地存储不可用(上下文获取失败)';
return;
}
hilog.info(DOMAIN, TAG, '拿到 Context,准备打开本地库');
// ② 打开本地库(首装会建表;这一步本身很快)
await FbDb.inst().open(ctx);
hilog.info(DOMAIN, TAG, '本地库打开成功');
} catch (err) {
// ⚠️ 把**真实错误**显示到界面上:
// 这台设备的 hilog 缓冲区读不出内容(已确认),界面是唯一可靠通道。
// 临时诊断用,定位完就换回友好文案。
const e: BusinessError = err as BusinessError;
const detail: string = (e.code !== undefined ? 'code=' + e.code.toString() + ' ' : '') +
(e.message !== undefined ? e.message : JSON.stringify(err));
hilog.error(DOMAIN, TAG, '本地库打开失败:%{public}s', detail);
this.loading = false;
this.syncNote = '本地库打开失败:' + detail;
return;
}
// ③ 先读本地(有数据立刻显示)
await this.reloadFromLocal();
// ④ 后台同步(不阻塞界面)
this.syncInBackground();
}
/** 临时:把本地库自检结果拼成一行显示(定位完删除) */
/** 从本地库读列表刷新界面 */
private async reloadFromLocal(): Promise<void> {
try {
const rows: FbMovieListItem[] = await this.repo.list(300);
this.movies = rows;
} catch (err) {
hilog.error(DOMAIN, TAG, '读本地观影失败:%{public}s', JSON.stringify(err));
}
// 🔴 影院与观影列表**分开 try**。
// 为什么:影院是"附属信息"(只是页头那行"影院 N 家"),
// 它读失败**不该**影响观影列表的显示。
// ⚠️ 踩过的坑:一开始把这段塞进了上面的 `catch` 里
// (脚本按"插在 finally 前"定位,结果落进了 catch)——
// 表现为"只有读失败时才有影院",而正常路径永远是 0 家。
// ⇒ **自动插入代码后必须看插入点的上下文**,别只看"插进去了"。
try {
this.cinemas = await this.repo.cinemaList();
} catch (err) {
hilog.warn(DOMAIN, TAG, '读本地影院失败:%{public}s', JSON.stringify(err));
}
this.loading = false;
}
/**
* 后台同步:拉接口 → 落本地库 → 再读一次刷新界面。
*
* ⚠️ 失败**不弹窗、不清空已有数据** —— 本地优先下"同步失败"只是
* "这次没更新",用户手上的数据仍然可用。只在页面上留一行小字说明。
*/
private async syncInBackground(): Promise<void> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
// 还没有令牌:不做同步,但**本地数据照样能看**(这就是落盘的价值)
this.syncNote = '未登录,显示的是本地数据';
return;
}
const res: FbSyncResult = await this.repo.sync(token);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '观影同步失败:%{public}s', res.error);
this.syncNote = '同步失败:' + res.error;
return;
}
this.syncNote = '';
await this.reloadFromLocal(); // 同步成功再刷一次,把新数据显示出来
}
build() {
// 🔴 用 Stack 把"页面内容"和"详情浮层"叠起来:
// 浮层是页面内的一个覆盖层(**不是系统弹窗**,理由见
// FbMovieDetailSheet 的文件头说明)。
Stack({ alignContent: Alignment.Bottom }) {
this.pageContent()
// ---- 详情浮层 ----
if (this.sheetOpen) {
// 半透明遮罩:点它也能关(大屏上比只能点"关闭"更顺手)
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.sheetOpen = false;
})
FbMovieDetailSheet({
detail: this.sheetDetail,
loading: this.sheetLoading,
error: this.sheetError,
listRating: this.sheetRating,
listReviewCount: this.sheetReviewCount,
onClose: (): void => {
this.sheetOpen = false;
}
})
}
// ---- 影院列表浮层 ----
// ⚠️ 与详情浮层**同一个 Stack**、同一个层级:
// 两者不会同时打开(详情由点电影触发、影院由点"影院 N 家"触发),
// 所以不需要额外处理"谁盖谁"。
if (this.cinemaSheetOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.cinemaSheetOpen = false;
})
FbCinemaSheet({
cinemas: this.cinemas,
onClose: (): void => {
this.cinemaSheetOpen = false;
}
})
}
// ---- 模块设置浮层(三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '观影设置',
items: FB_SET_MOVIES,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 目前只有「影院管理」是已实现的;其余在菜单里显示「暂未开放」
if (action === 'cinema') {
this.settingsOpen = false;
this.cinemaSheetOpen = true;
}
}
})
}
}
.width('100%')
.height('100%')
}
/** 页面主体(原 build 的内容,抽出来给 Stack 用) */
@Builder
pageContent() {
Scroll() {
Column() {
this.topBar()
@@ -54,11 +423,15 @@ export struct FbMoviesPage {
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 「影院」不单独占抽屉位置 —— 它是观影模块的一部分,从这里进
// 🔴 设置按钮 = 网页版那种**三条横线**(☰);「影院」也不单独占抽屉位置,
// 它是观影模块的一部分,从设置菜单里进(与网页版一致)。
FbIconButton({
icon: $r('app.media.fb_ic_gear'),
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
dia: 36
dia: 36,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.width('100%')
@@ -85,18 +458,51 @@ export struct FbMoviesPage {
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 4 })
.alignItems(VerticalAlign.Center)
// ---- 影院数提示 ----
// ---- 影院数(可点:看影院列表)----
// 🔴 之前这是个**纯文字**(只能看数字);现在点它能看到名单 ——
// Web 端「影院管理」就在观影模块里,这里给它一个入口。
Row({ space: 6 }) {
Blank()
Text('影院 ' + this.cinemas.length.toString() + ' 家')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text('›')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.onClick((): void => {
this.cinemaSheetOpen = true;
})
.padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 6, bottom: 6 })
// ---- 同步状态提示(只在有话说时显示,不占位)----
if (this.syncNote !== '') {
Row() {
Text(this.syncNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
}
// ---- 海报网格 ----
if (this.movies.length === 0) {
if (this.loading) {
// ⚠️ "加载中"与"暂无记录"必须分开:本页先读本地库(通常几毫秒),
// 所以 loading 几乎一闪而过;若把它和空态混在一起,
// 用户会看到"还没有观影记录"闪一下再变成列表。
Row() {
Text('正在读取本地数据…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 40, bottom: 40 })
.justifyContent(FlexAlign.Center)
} else if (this.movies.length === 0) {
FbEmptyBox({
title: '还没有观影记录',
hint: '一家人一起看过的电影,都会以海报墙的方式排在这里',
@@ -104,11 +510,11 @@ export struct FbMoviesPage {
})
} else {
Grid() {
ForEach(this.movies, (mv: FbMovieItem) => {
ForEach(this.movies, (mv: FbMovieListItem) => {
GridItem() {
this.movieCard(mv)
}
}, (mv: FbMovieItem): string => 'mv_' + mv.id.toString())
}, (mv: FbMovieListItem): string => 'mv_' + mv.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
@@ -118,7 +524,6 @@ export struct FbMoviesPage {
// Column 的高度由内容决定,layoutWeight(1) 会去分"剩余空间"(此时为 0)
// ⇒ Grid 高度塌成 0、整页只有顶栏和页头。
.width('100%')
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
@@ -131,38 +536,52 @@ export struct FbMoviesPage {
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
/** 一张电影卡:海报位(2:3)+ 片名 + 元信息 + 同看的人 */
@Builder
movieCard(mv: FbMovieItem) {
movieCard(mv: FbMovieListItem) {
Column({ space: 0 }) {
// 海报位:有海报就放图,没有就画占位(Web 端用「影」字占位,端上用图标)
// 海报位:有海报就放真图,没有/加载失败就画占位(与 Web 端一致)
Stack({ alignContent: Alignment.Center }) {
if (mv.hasPoster) {
// TODO(接口):接上 media.php 的海报地址后换掉这个纯色块
Column()
if (mv.posterKey !== '' && !this.posterFailed(mv.id)) {
// 🔴 真海报:走服务端 `media.php` 代理,令牌用 URL query 带过去
// (`Image()` 无法加自定义请求头,见 fbImageUrl 的说明)。
Image(fbImageUrl(mv.posterKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.linearGradient({
angle: 145,
colors: [[fbPalette(this.accent).brand2, 0.0], [fbPalette(this.accent).ink, 1.0]]
.objectFit(ImageFit.Cover)
// ⚠️ `alt` 给一张底图:图片没加载出来时先显示它,不会闪白块
.alt($r('app.media.fb_ic_movies'))
// ⚠️ 不 `syncLoad`:海报是大图,同步加载会卡住列表滚动。
// ⚠️ 加载失败**必须记下来**(见 onError):否则这张卡会一直
// 显示"半张白图",用户以为坏了,其实只是这张图取不到。
.onError((): void => {
this.markPosterFailed(mv.id);
})
} else {
// 无海报 / 加载失败:底图 + 图标/片名占位
Column()
.width('100%')
.height('100%')
.backgroundColor(FbColors.inputBg)
}
Column({ space: 6 }) {
Image($r('app.media.fb_ic_movies'))
.width(mv.hasPoster ? 26 : 22)
.height(mv.hasPoster ? 26 : 22)
.renderMode(ImageRenderMode.Template)
.fillColor(mv.hasPoster ? '#FFFFFFB3' : FbColors.faint)
if (!mv.hasPoster) {
// 占位层:只在"没有海报"或"海报加载失败"时显示(有真图时不遮挡)
if (mv.posterKey === '' || this.posterFailed(mv.id)) {
Column({ space: 6 }) {
Image($r('app.media.fb_ic_movies'))
.width(22)
.height(22)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text(mv.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
@@ -187,7 +606,9 @@ export struct FbMoviesPage {
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(mv.watchedAt)
// ⚠️ 显示上只取「日期 + 时:分」:接口给的是完整 DATETIME(含秒),
// 秒在卡片上纯属噪音。
Text(shortTime(mv.startsAt))
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
@@ -210,15 +631,15 @@ export struct FbMoviesPage {
Row({ space: 8 }) {
if (mv.reviewCount > 0) {
FbRating({ score: mv.rating, max: 10 })
FbRating({ score: mv.reviewAvg, max: 10 })
}
Blank()
// 同看的人(只出头像,空间紧)
Row({ space: 2 }) {
ForEach(mv.viewers.slice(0, 3), (v: FbKinChip) => {
FbPersonChip({ name: v.shown, color: v.avatarColor, withText: false })
.padding({ left: 0, right: 0 })
}, (v: FbKinChip): string => 'vw_' + mv.id.toString() + '_' + v.id.toString())
// 同看人数(只出数字,空间紧;点头像看详情是后续的事)
if (mv.attendeeCount > 0) {
Text(mv.attendeeCount.toString() + ' 人同看')
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
}
}
.width('100%')
@@ -232,5 +653,9 @@ export struct FbMoviesPage {
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.clip(true)
// 🔴 点卡片 → 打开详情浮层(用户 2026-10-12 反馈"点不进去")
.onClick((): void => {
this.openDetail(mv);
})
}
}
File diff suppressed because it is too large Load Diff
@@ -11,9 +11,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbResourceItem } from '../../model/Types';
import { FB_DEMO_RESOURCES, fbGroupResource } from '../../model/DemoData';
import { fbGroupResource } from '../../model/DemoData';
import { parseResources } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_RESOURCES } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbResourcesPage {
@@ -27,88 +32,174 @@ export struct FbResourcesPage {
/** 模块主题色(资源 = 靛蓝) */
private accent: string = '#5566A8';
private all: FbResourceItem[] = FB_DEMO_RESOURCES;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local all: FbResourceItem[] = [];
/** 当前筛选类别('' = 全部) */
@Local kindFilter: string = '';
build() {
Scroll() {
Column() {
this.topBar()
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.all.length.toString() + ' 项资源')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('证件 / 资料 / 附件,可共享给家庭成员')
private async load(): Promise<void> {
await loadModule<FbResourceItem[]>('resources', parseResources,
(rows: FbResourceItem[]): void => {
this.all = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
Scroll() {
Column() {
this.topBar()
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.all.length.toString() + ' 项资源')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('证件 / 资料 / 附件,可共享给家庭成员')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 类别筛选 ----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('证件', 'document')
this.kindChip('资料', 'material')
Blank()
Text('共享给我 ' + this.sharedCount().toString() + ' 项')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 类别筛选 ----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('证件', 'document')
this.kindChip('资料', 'material')
Blank()
Text('共享给我 ' + this.sharedCount().toString() + ' 项')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里还没有资源',
hint: '家里的证件、资料和附件都可以收进来,需要时一键共享',
icon: $r('app.media.fb_ic_resources')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbResourceItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.items.length.toString() + ' 项') }) {
ForEach(g.items, (r: FbResourceItem) => {
ListItem() {
this.resourceCard(r)
}
}, (r: FbResourceItem): string => 'res_' + r.id.toString())
}
}, (g: FbGroup<FbResourceItem>): string => 'rsg_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里还没有资源',
hint: '家里的证件、资料和附件都可以收进来,需要时一键共享',
icon: $r('app.media.fb_ic_resources')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbResourceItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.items.length.toString() + ' 项') }) {
ForEach(g.items, (r: FbResourceItem) => {
ListItem() {
this.resourceCard(r)
}
}, (r: FbResourceItem): string => 'res_' + r.id.toString())
}
}, (g: FbGroup<FbResourceItem>): string => 'rsg_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '资源设置',
items: FB_SET_RESOURCES,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一项资源卡 */
@@ -13,8 +13,21 @@
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbModuleDef, FB_MODULES, FB_PRESET_COLORS } from '../../model/Modules';
import { FbAvatar, FbDot, FbTag } from '../../components/Widgets';
import { FbAvatar, FbDot, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { FB_SERVER_STORE } from '../../data/FbServerStore';
import { FB_SYNC_ALL_PROGRESS, FbSyncAllResult, syncAll } from '../../data/FbSyncAll';
import { common } from '@kit.AbilityKit';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fbApi } from '../../data/FbHttp';
import { FB_SESSION } from '../../data/FbSession';
import { FB_DEVICE_STORE, FB_APP_VERSION } from '../../data/FbDeviceStore';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbSettings';
@ComponentV2
export struct FbSettingsPage {
@@ -31,6 +44,163 @@ export struct FbSettingsPage {
'resources', 'tasks', 'days', 'ledger', 'books', 'notes'];
/** 当前展开的配色分组('' = 都没展开) */
@Local colorOpenKey: string = '';
/** 令牌输入框的当前内容(「手动粘贴令牌」这个高级入口用) */
@Local tokenInput: string = '';
/** 账号区的状态说明(登录 / 同步 / 登出的结果) */
@Local tokenNote: string = '';
/** 「手动粘贴令牌」高级入口是否展开(v55:已不是主路径) */
@Local advancedOpen: boolean = false;
/** 设置页的主题色(与模块「个人设置」一致,取砖红) */
private accent: string = '#A84A28';
/**
* 页面出现时:不需要回填任何东西。
*
* ⚠️ v55 之前这里会把"已配置令牌"写进输入框 —— 现在登录是主路径,
* 令牌输入框已经收进「高级」,不再需要在进页面时提示一次。
*/
aboutToAppear(): void {
this.tokenNote = '';
}
/** 账号区状态文案 */
private tokenStateText(): string {
if (!FB_TOKEN_STORE.has()) {
return '未登录(只显示本地已有的数据)';
}
// ⚠️ 显示的是**会话里的姓名**,不是令牌 —— 令牌绝不出现在界面上
const who: string = this.app.session.me.name;
const role: string = this.app.session.me.role === 'admin' ? '管理员'
: (this.app.session.me.role === 'guest' ? '只读访客' : '家庭成员');
return '已登录:' + who + ' · ' + role;
}
/** 账号区状态颜色:已登录用绿、未登录用灰 */
private tokenStateColor(): Resource {
return FB_TOKEN_STORE.has() ? $r('app.color.green') : FbColors.muted;
}
/** 当前服务器(去掉协议省一行宽度;没配就明说) */
private serverHost(): string {
const base: string = FB_SERVER_STORE.get();
if (base === '') {
return '还没设置';
}
return base.replace(/^https?:\/\//i, '');
}
/**
* 手动同步一次。
*
* ⚠️ 登录后会自动全量同步,但用户可能"隔了几天再打开、想知道有没有新数据"
* —— 给他一个明确的、随时可按的入口。
*/
private async syncNow(): Promise<void> {
if (!FB_TOKEN_STORE.has()) {
this.tokenNote = '还没登录,无法同步';
return;
}
this.tokenNote = '正在同步…';
// 实时进度(定时器把当前步骤刷到界面上)。
// ⚠️ 本机 hilog 读不出内容 ⇒ 不给实时进度,用户会以为卡死。
const timer: number = setInterval((): void => {
this.tokenNote = '同步中…(' + FB_SYNC_ALL_PROGRESS + ')';
}, 700);
const res: FbSyncAllResult = await syncAll();
clearInterval(timer);
this.tokenNote = res.ok ? ('✓ ' + res.note) : ('部分未完成:' + res.note);
}
/**
* 退出登录:清令牌 + 回登录页(**地址保留**)。
*
* 🔴 为什么不顺手清本地数据:本地优先架构下"没登录"不等于"没数据"。
* 用户可能只是在别人手机上登出、或暂时断网 —— 把已同步的数据一起删掉,
* 等于让他下次进来面对一片空白(还得重新全量同步)。
*/
private async logout(): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
this.tokenNote = '拿不到应用上下文,无法退出';
return;
}
/* 🔴 v57(用户 2026-10-17):「先删本地令牌(这是主防线,捡到手机的人拿不到)
→ 再尽力通知服务端」。
⚠️ 顺序不能反:离线时通知发不出去,如果等它成功再清本地,
用户会看到"点了退出却没退"。本地那一刀是唯一可靠的。
⚠️ 服务端那一条**失败也不报错**:它只是让"这台设备"从设备列表里消失得
更及时,删不掉就等它 24h 后自然过期,或用户在网页端手动踢。 */
const token: string = FB_TOKEN_STORE.get();
await FB_TOKEN_STORE.clear(ctx);
await FB_SESSION.clear(ctx);
if (token !== '') {
fbApi('/api/v1/logout', { method: 'POST', token: token, authProbe: true })
.catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '通知服务端退出失败(已本地退出):%{public}s',
JSON.stringify(err));
});
}
this.tokenNote = '';
// 回登录页,从"输账号密码"开始(地址还留着)
this.app.lockReason = '你已退出登录';
this.app.loginStartStep = 2;
this.app.needLogin = true;
}
/**
* 更换服务器:回登录页第一步重填地址。
*
* 🔴 必须**同时清掉令牌**:令牌是那台服务器签发的,换了机器一定无效 ——
* 留着只会让用户在第二步莫名其妙地 401("密码明明是对的")。
* ⚠️ 地址本身**不清**:登录页第一步会预填它,用户改起来更方便。
*/
private async changeServer(): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx !== undefined) {
await FB_TOKEN_STORE.clear(ctx);
// ⚠️ 会话校验记录也要清:换了服务器,"上次校验成功"是旧服务器的事,
// 留着会让新服务器上的离线判定带着旧时间(v57)
await FB_SESSION.clear(ctx);
}
this.tokenNote = '';
this.app.lockReason = '换了服务器,需要重新登录';
this.app.loginStartStep = 1;
this.app.needLogin = true;
}
/**
* 保存手动粘贴的令牌并立刻同步一次(「高级」里的路)。
*
* ⚠️ 为什么还留着它:网页版「个人设置 → API 令牌」签发的令牌,
* 在"不方便输密码"或"只给某台设备一把只读令牌"时仍然有用。
* 但它**不再是主路径** —— 正常用户走登录。
*/
private async saveTokenAndSync(): Promise<void> {
const t: string = this.tokenInput.trim();
if (t === '') {
this.tokenNote = '请先粘贴令牌';
return;
}
// 🔴 没地址时令牌一定没用:请求拼出来是个相对地址,报的错还跟"令牌"无关
if (FB_SERVER_STORE.get() === '') {
this.tokenNote = '请先设置服务器地址';
return;
}
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
this.tokenNote = '拿不到应用上下文,无法保存';
return;
}
this.tokenNote = '正在保存…';
const ok: boolean = await FB_TOKEN_STORE.set(ctx, t);
if (!ok) {
this.tokenNote = '令牌保存失败';
return;
}
this.tokenInput = '';
await this.syncNow();
}
build() {
Scroll() {
@@ -75,6 +245,124 @@ export struct FbSettingsPage {
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== 账号与服务器(登录 / 同步 / 登出)====================
// 🔴 v55(用户 2026-10-17「完整的登录功能」)——
// 这里从"手工粘贴令牌"变成了**登录态管理**:
// · 登录走登录页(地址 + 账号密码),这里只显示"当前是谁、连的哪台";
// · 「同步全部数据」保留(用户想立刻拉新数据时按一下);
// · 「退出登录 / 更换服务器」都回登录页(本应用没有路由栈,
// 靠 `app.needLogin` 这个状态位回去,见 Index)。
// ⚠️ 手工粘贴令牌降级成「高级」:它是"给某台设备一把只读令牌"这类
// 特殊场景的备用路,不该出现在普通人的视线里。
Column({ space: 0 }) {
this.rowTitle('账号与服务器')
// 登录状态(谁、什么角色)
Text(this.tokenStateText())
.fontSize(FbMetric.fontSmall)
.fontColor(this.tokenStateColor())
.width('100%')
.maxLines(2)
.padding({ top: 2, bottom: 8 })
// 当前服务器(可点「更换」)
Row({ space: 8 }) {
Text('服务器')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text(this.serverHost())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('更换')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.padding({ left: 8, top: 4, bottom: 4 })
.onClick((): void => { this.changeServer(); })
}
.width('100%')
.padding({ top: 2, bottom: 10 })
.alignItems(VerticalAlign.Center)
// 操作:同步 / 退出(未登录时只有同步,且点了会提示)
Row({ space: 8 }) {
Text('同步全部数据')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.height(36)
.layoutWeight(1)
.backgroundColor(this.accent)
.borderRadius(FbMetric.radiusS)
.onClick((): void => { this.syncNow(); })
if (FB_TOKEN_STORE.has()) {
Text('退出登录')
.fontSize(FbMetric.fontSmall)
// ⚠️ FbColors 里没有 danger(只有 ink/muted/faint/line…),
// 这里用字面色值。别写成 FbColors.danger —— 那会编译不过。
.fontColor('#A32D2D')
.textAlign(TextAlign.Center)
.height(36)
.width(88)
.border({ width: 1, color: FbColors.line })
.borderRadius(FbMetric.radiusS)
.onClick((): void => { this.logout(); })
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
// ---- 高级:手动粘贴 API 令牌 ----
Text(this.advancedOpen ? '收起「手动粘贴令牌」' : '高级:手动粘贴 API 令牌')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 12, bottom: 6 })
.onClick((): void => { this.advancedOpen = !this.advancedOpen; })
if (this.advancedOpen) {
TextInput({ placeholder: '粘贴 API 令牌(fb_ 开头)', text: this.tokenInput })
.type(InputType.Normal)
.fontSize(FbMetric.fontSmall)
.maxLength(128)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.padding({ left: 10, right: 10 })
.onChange((v: string): void => {
this.tokenInput = v;
})
FbPrimaryButton({
label: '保存并同步',
bg: this.accent,
icon: $r('app.media.fb_ic_check'),
onTap: (): void => { this.saveTokenAndSync(); }
})
.margin({ top: 8 })
}
// 状态(登录 / 同步 / 登出的结果都落在这里)
if (this.tokenNote !== '') {
Text(this.tokenNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(3)
.margin({ top: 8 })
}
Text('退出登录不会删除已经同步到本机的数据,离线也能看;换个服务器则要重新登录。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.maxLines(3)
.margin({ top: 8 })
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== 通知 ====================
Column({ space: 0 }) {
this.rowTitle('通知')
@@ -144,9 +432,13 @@ export struct FbSettingsPage {
// ==================== 关于 ====================
Column({ space: 0 }) {
this.rowTitle('关于')
this.plainRow('版本', '0.1.0(界面框架)')
this.plainRow('数据来源', '待接入 · 当前为演示数据')
this.plainRow('服务器', '未配置')
this.plainRow('版本', FB_APP_VERSION)
this.plainRow('服务器', this.serverHost())
this.plainRow('账号', FB_TOKEN_STORE.has() ? this.app.session.me.name : '未登录')
/* v57:这台设备在服务端叫什么。
用户 2026-10-17:「只有 uuid 用户无法确定是哪台设备。」
⇒ 网页端「设备管理」里显示的就是这个名字,用户要能对上号。 */
this.plainRow('这台设备', FB_DEVICE_STORE.getDeviceName())
}
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
@@ -162,6 +454,12 @@ export struct FbSettingsPage {
.backgroundColor(FbColors.paper)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
+161 -68
View File
@@ -12,9 +12,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbTaskItem } from '../../model/Types';
import { FB_DEMO_TASK_BOARDS, FB_DEMO_TASKS, fbGroupTask } from '../../model/DemoData';
import { fbGroupTask } from '../../model/DemoData';
import { parseTasks } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbCheckCircle, FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch, fbOnHScroll } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_TASKS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbTasksPage {
@@ -26,93 +31,181 @@ export struct FbTasksPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private boards: string[] = FB_DEMO_TASK_BOARDS;
private boards: string[] = [];
/** 模块主题色(任务 = 赭红) */
private accent: string = '#A8574F';
private tasks: FbTaskItem[] = FB_DEMO_TASKS;
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local tasks: FbTaskItem[] = [];
/** 当前筛选的看板('' = 全部) */
@Local boardFilter: string = '';
/**
* 页面出现:先读缓存、再拉接口(本地优先,见 FbModuleRepo 的说明)。
*
* ⚠️ 不在这里打开本地库 —— `EntryAbility.onCreate` 已统一打开。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbTaskItem[]>('tasks', parseTasks,
(rows: FbTaskItem[]): void => {
this.tasks = rows;
},
(note: string): void => {
this.syncNote = note;
});
}
/** 顶部提示(同步失败 / 未登录时显示一行小字) */
@Local syncNote: string = '';
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.doingCount().toString() + ' 项在做')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.todoCount().toString() + ' 项待办'
+ (this.overdueCount() > 0 ? ' · ' + this.overdueCount().toString() + ' 项逾期' : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(this.overdueCount() > 0 ? FbColors.red : FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Scroll() {
Column() {
this.topBar()
FbPrimaryButton({
label: '新建任务',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 看板筛选条(横向滚动)----
Scroll() {
// ---- 页头 ----
Row({ space: 8 }) {
this.boardChip('全部', '')
ForEach(this.boards, (b: string) => {
this.boardChip(b, b)
}, (b: string): string => 'bd_' + b)
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
Column({ space: 2 }) {
Text(this.doingCount().toString() + ' 项在做')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.todoCount().toString() + ' 项待办'
+ (this.overdueCount() > 0 ? ' · ' + this.overdueCount().toString() + ' 项逾期' : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(this.overdueCount() > 0 ? FbColors.red : FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// ---- 按状态分组的列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里没有任务',
hint: '新建时会顺带指定它属于哪个看板、做到哪一步',
icon: $r('app.media.fb_ic_tasks')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbTaskItem>) => {
ListItemGroup({ header: this.groupHeader(g) }) {
ForEach(g.items, (t: FbTaskItem) => {
ListItem() {
this.taskCard(t)
}
}, (t: FbTaskItem): string => 'tk_' + t.id.toString())
FbPrimaryButton({
label: '新建任务',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
}, (g: FbGroup<FbTaskItem>): string => 'grp_' + g.key)
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 看板筛选条(横向滚动)----
Scroll() {
Row({ space: 8 }) {
this.boardChip('全部', '')
ForEach(this.boards, (b: string) => {
this.boardChip(b, b)
}, (b: string): string => 'bd_' + b)
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
// 🔴 横向滚动上报(让抽屉给横滑组件让位)—— 见 common/FbScroll.ets
.onDidScroll(fbOnHScroll)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.padding({ bottom: 10 })
// ---- 按状态分组的列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里没有任务',
hint: '新建时会顺带指定它属于哪个看板、做到哪一步',
icon: $r('app.media.fb_ic_tasks')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbTaskItem>) => {
ListItemGroup({ header: this.groupHeader(g) }) {
ForEach(g.items, (t: FbTaskItem) => {
ListItem() {
this.taskCard(t)
}
}, (t: FbTaskItem): string => 'tk_' + t.id.toString())
}
}, (g: FbGroup<FbTaskItem>): string => 'grp_' + g.key)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '任务设置',
items: FB_SET_TASKS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 看板筛选胶囊 */
+153 -48
View File
@@ -15,9 +15,14 @@
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem, FbTripNode } from '../../model/Types';
import { FB_DEMO_TRIPS, FB_DEMO_TRIP_NODES, fbMoney } from '../../model/DemoData';
import { fbMoney } from '../../model/DemoData';
import { parseTrips } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
@ComponentV2
export struct FbTripsPage {
@@ -29,71 +34,171 @@ export struct FbTripsPage {
@BuilderParam topBar: () => void;
@Param app: FbAppState = fbAppState();
private trips: FbTripItem[] = FB_DEMO_TRIPS;
private nodes: FbTripNode[] = FB_DEMO_TRIP_NODES;
/**
* 旅程列表(**来自接口** `/api/v1/trips`)。
*
* ⚠️ 初值给空数组而不是演示数据:先渲染假的、再被真数据替换,
* 用户会看到"闪一下"。
*/
/**
* 模块设置浮层是否打开(右上角三条横线点开的那个)。
*
* 🔴 用户 2026-10-16:「所有页面的设置按钮全部都改成和网页版一致,
* 就是**三个横线**那种设置按钮。」
* 网页版的做法:动作区最右一个 ☰ → 点开是**这个模块的设置菜单**。
*/
@Local settingsOpen: boolean = false;
@Local trips: FbTripItem[] = [];
/**
* 行程节点。
*
* ⚠️ 接口 `/trips` 只给旅程**本身**(含 `node_count`),不给每一段。
* 所以这里保持空 —— 展开时才去拉 `/trips/<id>`(待实现)。
* 不假装有数据:宁可展开后显示"暂无行程段"。
*/
@Local nodes: FbTripNode[] = [];
/** 顶部提示(同步状态) */
@Local syncNote: string = '';
/** 模块主题色(旅程 = 绿) */
private accent: string = '#3F7D5A';
/** 当前展开的旅程 id(0 = 都没展开) */
@Local expandedId: number = 0;
/**
* 页面出现:先缓存、再网络(与观影/名册同一套"本地优先"流程)。
*
* ⚠️ 本页**不自己打开本地库** —— 已在 `EntryAbility.onCreate` 统一打开了
* (见那里的说明)。页面只管用。
*/
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
await loadModule<FbTripItem[]>('trips', parseTrips,
(rows: FbTripItem[], fromCache: boolean): void => {
this.trips = rows;
this.syncNote = fromCache ? '' : '';
},
(note: string): void => {
this.syncNote = note;
});
}
/**
* 设置浮层开着 ⇒ 让底部悬浮快捷栏让位。
*
* 🔴 悬浮栏在**更外层 Index 的 Stack** 里,层级比本页高 ⇒
* 浮层底部的按钮会被它盖住、点击被抢走(名册/日程页都踩过)。
*/
@Monitor('settingsOpen')
onSettingsChanged(): void {
fbAppState().sheetCount = this.settingsOpen ? 1 : 0;
}
build() {
Scroll() {
Column() {
this.topBar()
// 🔴 外层 Stack:**模块设置浮层**要浮在页面之上
// (本页原来是 Scroll 直接作根,没有 Stack,这里补一层)
Stack({ alignContent: Alignment.Bottom }) {
// ---- 页头操作条 ----
Row({ space: 8 }) {
Column({ space: 1 }) {
Text('共 ' + this.trips.length.toString() + ' 个旅程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('每个旅程里可以有若干段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Scroll() {
Column() {
this.topBar()
FbPrimaryButton({
label: '新建旅程',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
// ---- 页头操作条 ----
Row({ space: 8 }) {
Column({ space: 1 }) {
Text('共 ' + this.trips.length.toString() + ' 个旅程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('每个旅程里可以有若干段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
if (this.trips.length === 0) {
FbEmptyBox({
title: '还没有旅程',
hint: '去过的地方会按时间排在这里,点开能看到每一段行程',
icon: $r('app.media.fb_ic_trips')
})
} else {
List({ space: 12 }) {
ForEach(this.trips, (t: FbTripItem) => {
ListItem() {
this.tripCard(t)
FbPrimaryButton({
label: '新建旅程',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰)—— 用户 2026-10-16。
// 点开是本模块的设置菜单(条目照网页版 `templates/<模块>/_settings_menu.php`)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbColors.muted,
onTap: (): void => {
this.settingsOpen = true;
}
}, (t: FbTripItem): string => 'trip_' + t.id.toString())
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (this.trips.length === 0) {
FbEmptyBox({
title: '还没有旅程',
hint: '去过的地方会按时间排在这里,点开能看到每一段行程',
icon: $r('app.media.fb_ic_trips')
})
} else {
List({ space: 12 }) {
ForEach(this.trips, (t: FbTripItem) => {
ListItem() {
this.tripCard(t)
}
}, (t: FbTripItem): string => 'trip_' + t.id.toString())
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
// ---- 模块设置浮层(右上角三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#00000066')
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
title: '旅程设置',
items: FB_SET_TRIPS,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 条目里 `action === ''` 的是「暂未开放」(组件里已画灰、点了不响应)
// ⇒ 这里只需处理已实现的;目前都还没有,保持浮层开着即可。
}
})
}
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** 一张旅程卡:标题 + 时间 + 进度;展开后接行程时间轴 */
+19 -1
View File
@@ -14,6 +14,15 @@
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
},
{
// 🔴 质感握姿(左手/右手握持)—— 用户 2026-10-16 第 4 条。
// `multimodalAwareness.motion` 的 `holdingHandChanged` 要求此权限。
// ⚠️ 它是 `system_grant` + `availableLevel: normal` + `provisionEnable: false`
// (在 SDK 的权限定义里核过)⇒ **安装即获得,不会弹授权框**。
// ⚠️ API 20 起才有;低版本设备会抛 801(能力不支持)——
// 代码里已 try/catch 兜住,拿不到就退回默认方向。
"name": "ohos.permission.DETECT_GESTURE"
}
],
"deliveryWithInstall": true,
@@ -24,7 +33,16 @@
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
// 🔴 v45 修:原为 `$media:layered_image`(分层图标),导致桌面图标
// 外面有一圈米色底、且图案显得小。原因:
// · `background.png` 是**纯米色满幅图**(512²)⇒ 那圈底色;
// · `foreground.png` 的图案**只占 44%**,系统还要留安全区
// ⇒ 图案被缩得更小。
// 另一个真 bug:`app.json5` 的分层图在 **AppScope** 下找,
// 但 `background/foreground.png` **只在 entry 里**,AppScope 里没有
// ⇒ 引用落空。
// ⇒ 直接改用与应用内左上角**同一个** `fb_logo`(图案几乎铺满)。
"icon": "$media:fb_logo",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB