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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user