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
+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;
}
}