端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)

#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
This commit is contained in:
2026-11-05 12:41:04 +08:00
parent 88fa38ecf9
commit 86de59616c
35 changed files with 5818 additions and 911 deletions
+421
View File
@@ -0,0 +1,421 @@
/**
* 岁月家 FamilyBook · 系统日历镜像(入站回程)
*
* 🔴 2026-11-03 新增,2026-11-04 改为**按日历本多账户**。
* 照 SyncCalendar 的 `MirrorInbound`。
*
* ## 这是「系统日历 ↔ 岁月家 App ↔ 服务器」闭环的**回程**
*
* 出站(服务器 → 系统日历)在 `FbCalMirror`,本文件负责反向:
* 用户在系统日历里改了 / 新建了 / 删了,都要回写到服务器。
*
* ## 多账户带来的结构变化(2026-11-04)
*
* 第一版只有一个账户(`岁月家`),所以只需对账一次。
* 现在每个日历本一个账户(`岁月家·工作`、`岁月家·家庭`…),
* ⇒ **必须逐个账户对账**,且每个账户的条目只能回写到它对应的那个本。
*
* for (每个镜像账户) {
* bucket = 从账户名解析出的 book<id>
* bookId = 从 bucket 解析出的数字
* 对该账户内的条目做三方比对,回写时指定 calendar_id = bookId
* }
*
* ⚠️ 跨本错写是这里最容易犯的错:把"工作"账户里的一条写进"家庭"本。
* 所以 `pushCreate` / `pushUpdate` 都必须带 `calendar_id = bookId`。
*
* ## 防回灌(唯一的核心难题,与单账户时相同)
*
* 系统日历里既有"我们写进去的",也有"用户改的/新建的/删的"。
* 靠 `mirror_snapshot`(上次写入值的内容指纹)三方比对:
*
* | 系统日历当前状态 | 判定 | 动作 |
* |-----------------------------------|------------------|---------------------------|
* | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 |
* | 有 identifier,指纹 != 快照 | 用户改了 | 写回服务器 |
* | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) |
* | 快照有,系统里没了 | 用户删了 | 服务器删除 |
* | identifier 不是我们的格式 | 用户在系统日历新建 | 新建并推到服务器 |
*
* ⚠️ 「没有快照就补快照后跳过」这条极关键:首次启用时快照是空的,
* 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。
*
* ⚠️ 必须有网络才能回写(SyncCalendar 是离线排队,这里直接推)。
* 离线时本轮跳过,下次再对账 —— 不会丢改动,因为快照没更新、
* 下一轮还会判定成"用户改了"。
*/
import { common } from '@kit.AbilityKit';
import { calendarManager } from '@kit.CalendarKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbApiResult } from './FbHttp';
import { FbCalEventInput, FbCalRepo } from './FbCalRepo';
import { FbCalMirror, FbMirrorSnap } from './FbCalMirror';
import { FbCalStore } from './FbCalStore';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { FbDiag } from '../common/FbDiag';
/** 一次入站对账的统计 */
export class FbInboundResult {
created: number = 0;
updated: number = 0;
deleted: number = 0;
skipped: number = 0;
failed: number = 0;
/** 对账了几个账户 */
books: number = 0;
}
export class FbCalInbound {
/** 账户 name 前缀,必须与 `FbCalMirror` 完全一致 */
private static readonly ACCOUNT_PREFIX: string = 'familybook.mirror.book';
/**
* 入站对账主入口:**逐个镜像账户**对账。
*
* ⚠️ 必须在出站镜像 `FbCalMirror.syncNow()` **之前**调用 ——
* 它产生的服务器改动要赶上本轮,否则下一轮出站会用旧数据把用户改动覆盖掉。
*/
static async reconcile(ctx: common.Context): Promise<FbInboundResult> {
const res: FbInboundResult = new FbInboundResult();
if (!await FbCalMirror.hasPermission()) {
return res;
}
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
FbDiag.log('INBOUND', '未登录,跳过入站对账');
return res;
}
try {
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(ctx);
// 收集所有镜像账户
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
const mirrors: calendarManager.Calendar[] = [];
for (const c of cals) {
if (c.getAccount().name.startsWith(FbCalInbound.ACCOUNT_PREFIX)) {
mirrors.push(c);
}
}
if (mirrors.length === 0) {
return res; // 还没镜像过,无从对账
}
// 快照一次读全(按 identifier 索引,天然区分桶)
const snaps: Map<string, FbMirrorSnap> = await FbCalMirror.snapLoad(ctx);
// 本地条目索引(uid → 服务器 id + 所属本),一次建好所有账户共用
const localIndex: Map<string, LocalRef> = await FbCalInbound.localIndex();
for (const cal of mirrors) {
const accName: string = cal.getAccount().name;
const bookIdStr: string = accName.substring(FbCalInbound.ACCOUNT_PREFIX.length);
const bookId: number = parseInt(bookIdStr, 10);
if (isNaN(bookId) || bookId <= 0) {
FbDiag.log('INBOUND', '账户名解析不出 bookId,跳过: ' + accName);
continue;
}
const bucket: string = FbCalMirror.bucketOf(bookId);
try {
await FbCalInbound.reconcileBook(ctx, cal, bucket, bookId, snaps, localIndex, token, res);
res.books++;
} catch (err) {
const e = err as BusinessError;
res.failed++;
FbDiag.log('INBOUND', '对账失败 bookId=' + bookId + ': ' + e.code + ' ' + e.message);
}
}
if (res.created + res.updated + res.deleted > 0) {
FbDiag.log('INBOUND', '对账完成:本=' + res.books
+ ' 新建=' + res.created + ' 修改=' + res.updated
+ ' 删除=' + res.deleted + ' 跳过=' + res.skipped + ' 失败=' + res.failed);
}
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '对账异常: ' + e.message);
}
return res;
}
/* ==================== 单个日历本 ==================== */
private static async reconcileBook(ctx: common.Context, cal: calendarManager.Calendar,
bucket: string, bookId: number, snaps: Map<string, FbMirrorSnap>,
localIndex: Map<string, LocalRef>, token: string, res: FbInboundResult): Promise<void> {
const prefix: string = bucket + '|';
// 系统日历现有条目
let events: calendarManager.Event[] = [];
try {
events = await cal.getEvents();
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', 'getEvents() 失败 bookId=' + bookId + ': ' + e.message);
return;
}
// 分成两类:带本桶 identifier 的(可判定改/删)vs 不是我们的(用户新建)
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
const others: calendarManager.Event[] = [];
for (const ev of events) {
const id: string = ev.identifier ?? '';
if (id.startsWith(prefix)) {
mine.set(id, ev);
} else {
others.push(ev);
}
}
// ---------- ① 修改 ----------
for (const id of Array.from(mine.keys())) {
const ev: calendarManager.Event | undefined = mine.get(id);
if (ev === undefined) {
continue;
}
const fp: string = FbCalMirror.fingerprintOf(ev);
const snap: FbMirrorSnap | undefined = snaps.get(id);
if (snap === undefined) {
/* ⚠️ 没有快照 ⇒ 认作是我们自己写的,补一份后跳过。
绝不误判成"用户改了" —— 首次启用时快照是空的,
那样会把全部日程标脏、全量重推服务器。 */
await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp);
res.skipped++;
continue;
}
snaps.delete(id); // 还活着,稍后剩下的才是"被删了"
if (snap.fingerprint === fp) {
res.skipped++; // 没动
continue;
}
const ref: LocalRef | undefined = localIndex.get(id);
if (ref === undefined || ref.id <= 0) {
res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站处理
continue;
}
/* ⚠️ 跨本防护:万一 identifier 的桶与当前账户不一致(历史脏数据),
宁可跳过也不要把 A 本的事写到 B 本。 */
if (ref.calId !== bookId) {
FbDiag.log('INBOUND', '跳过跨本条目 ' + id + '(本地本=' + ref.calId
+ ' 账户本=' + bookId + ')');
res.skipped++;
continue;
}
const ok: boolean = await FbCalInbound.pushUpdate(ref.id, ev, bookId, token);
if (ok) {
await FbCalInbound.saveSnap(ctx, bucket, id, ev, fp);
res.updated++;
} else {
res.failed++;
}
}
// ---------- ② 删除:快照里剩下、且属于本桶的 ----------
for (const id of Array.from(snaps.keys())) {
const snap: FbMirrorSnap | undefined = snaps.get(id);
if (snap === undefined) {
continue;
}
if (snap.bucket !== bucket) {
continue; // 别的桶的,留给那本处理
}
const ref: LocalRef | undefined = localIndex.get(id);
if (ref !== undefined && ref.id > 0 && ref.calId === bookId) {
const ok: boolean = await FbCalInbound.pushDelete(ref.id, token);
if (ok) {
res.deleted++;
} else {
res.failed++;
}
} else {
res.skipped++;
}
await FbCalMirror.snapRemove(ctx, id);
snaps.delete(id);
}
// ---------- ③ 新建:identifier 不是本桶格式的(用户在系统日历里手动加的) ----------
for (const ev of others) {
if (ev.id === undefined) {
continue;
}
/* ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。
否则下一次出站镜像会把同一条再加一遍 ⇒ 系统日历里出现重复。 */
const ident: string = bucket + '|sc-' + ev.id.toString() + '-' + ev.startTime.toString();
const stamped: calendarManager.Event = ev;
stamped.identifier = ident;
try {
await cal.updateEvent(stamped);
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '无法写回 identifier,跳过新建(避免重复): ' + e.message);
res.skipped++;
continue;
}
const newId: number = await FbCalInbound.pushCreate(ev, bookId, token);
if (newId > 0) {
await FbCalInbound.saveSnap(ctx, bucket, ident, ev,
FbCalMirror.fingerprintOf(ev));
res.created++;
} else {
res.failed++;
}
}
}
/* ==================== 推服务器 ==================== */
/**
* 系统日历 Event → 接口输入。
*
* ⚠️ `calendar_id` 必须带上 —— 否则服务端会落到默认本,
* 用户在"工作"账户里新建的事会跑到别处去。
*/
private static toInput(ev: calendarManager.Event, bookId: number): FbCalEventInput {
const input: FbCalEventInput = {
calendar_id: bookId,
title: ev.title !== undefined && ev.title !== '' ? ev.title : '(无标题)',
starts_at: FbCalInbound.fmtDateTime(ev.startTime),
ends_at: FbCalInbound.fmtDateTime(ev.endTime),
all_day: ev.isAllDay === true,
location: ev.location?.location !== undefined ? (ev.location.location as string) : '',
note: ev.description !== undefined ? ev.description : ''
};
return input;
}
private static async pushUpdate(id: number, ev: calendarManager.Event,
bookId: number, token: string): Promise<boolean> {
try {
const r: FbApiResult = await FbCalRepo.inst().update(id,
FbCalInbound.toInput(ev, bookId), token);
if (!r.ok) {
FbDiag.log('INBOUND', '更新服务器失败 id=' + id + ': ' + r.error);
return false;
}
return true;
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '更新服务器异常 id=' + id + ': ' + e.message);
return false;
}
}
private static async pushDelete(id: number, token: string): Promise<boolean> {
try {
const r: FbApiResult = await FbCalRepo.inst().remove(id, token);
if (!r.ok) {
FbDiag.log('INBOUND', '删除服务器失败 id=' + id + ': ' + r.error);
return false;
}
return true;
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '删除服务器异常 id=' + id + ': ' + e.message);
return false;
}
}
/** 新建 → 返回新条目 id(0 = 失败) */
private static async pushCreate(ev: calendarManager.Event, bookId: number,
token: string): Promise<number> {
try {
const r: FbApiResult = await FbCalRepo.inst().create(
FbCalInbound.toInput(ev, bookId), token);
if (!r.ok) {
FbDiag.log('INBOUND', '新建服务器失败: ' + r.error);
return 0;
}
if (r.data === '') {
return 0;
}
const obj: Record<string, Object> = JSON.parse(r.data) as Record<string, Object>;
const idVal: Object | undefined = obj['id'];
if (typeof idVal === 'number') {
return idVal as number;
}
return 0;
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '新建服务器异常: ' + e.message);
return 0;
}
}
/* ==================== 本地索引 ==================== */
/**
* 建 `identifier → 本地条目` 的索引。
*
* identifier 的算法必须与出站 `FbCalMirror.identifierOf()` **完全一致**,
* 否则两边永远对不上号(表现:用户在系统日历改了,服务器没变)。
* ⇒ 这里直接调 `FbCalMirror.identifierOf()`,不自己算。
*/
private static async localIndex(): Promise<Map<string, LocalRef>> {
const out: Map<string, LocalRef> = new Map<string, LocalRef>();
try {
const now: Date = new Date();
const from: Date = new Date(now.getFullYear(), now.getMonth() - 13, 1);
const to: Date = new Date(now.getFullYear(), now.getMonth() + 26, 1);
const p = (n: number): string => n < 10 ? '0' + n : '' + n;
const fromIso: string = from.getFullYear() + '-' + p(from.getMonth() + 1) + '-01';
const toIso: string = to.getFullYear() + '-' + p(to.getMonth() + 1) + '-01';
const rows = await FbCalStore.inst().between(fromIso, toIso);
for (const e of rows) {
if (e.id <= 0 || e.calId <= 0) {
continue; // 衍生条目推不上去
}
const bucket: string = FbCalMirror.bucketOf(e.calId);
const ident: string = FbCalMirror.identifierOf(bucket, e);
const ref: LocalRef = new LocalRef();
ref.id = e.id;
ref.calId = e.calId;
out.set(ident, ref);
}
} catch (err) {
const e = err as BusinessError;
FbDiag.log('INBOUND', '建本地索引失败: ' + e.message);
}
return out;
}
/* ==================== 辅助 ==================== */
private static async saveSnap(ctx: common.Context, bucket: string, identifier: string,
ev: calendarManager.Event, fp: string): Promise<void> {
const s: FbMirrorSnap = new FbMirrorSnap();
s.identifier = identifier;
s.bucket = bucket;
s.uid = FbCalMirror.uidOf(identifier);
s.sysId = ev.id ?? -1;
s.fingerprint = fp;
await FbCalMirror.snapPut(ctx, s);
}
/** 时间戳 → `YYYY-MM-DD HH:MM:SS`(接口要这个格式) */
private static fmtDateTime(ts: number): string {
const d: Date = new Date(ts);
const p = (n: number): string => n < 10 ? '0' + n : '' + n;
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
}
/** 本地条目引用(回写时要知道"改哪条、在哪个本") */
class LocalRef {
/** 服务器条目 id */
id: number = 0;
/** 所属日历本 id —— 用于跨本防护 */
calId: number = 0;
}