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:
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user