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
@@ -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)
}
}