端上:#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:
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,17 @@ const K_VIEW: string = 'default_view'; // month | week | agenda
|
||||
const K_STYLE: string = 'display_style'; // timeline | list
|
||||
const K_BOOK: string = 'default_book_id'; // 新建日程默认落到哪本
|
||||
const K_HIDDEN: string = 'hidden_books'; // 逗号分隔的日历本 id
|
||||
/**
|
||||
* 镜像到系统日历的开关。
|
||||
*
|
||||
* 🔴 2026-11-03 新增。用户原话:「新增一个功能,把日历镜像到系统日历中」。
|
||||
* 原因(SyncCalendar 文件头):本应用是 CalDAV 客户端,日程不进系统日历,
|
||||
* 导致小艺 / 桌面日历卡片 / 手表看到的是空的。
|
||||
*
|
||||
* ⚠️ 这里只存"开关状态"。真正的镜像逻辑在 `data/FbCalMirror.ets`,
|
||||
* 这个开关决定要不要跑它。
|
||||
*/
|
||||
const K_MIRROR: string = 'mirror_to_system';
|
||||
|
||||
/** 视图:月 / 周 / 列表(与 SyncCalendar 的 VIEW_ORDER 同一套) */
|
||||
export type FbCalView = 'month' | 'week' | 'agenda';
|
||||
@@ -45,6 +56,8 @@ class FbCalSettingsImpl {
|
||||
private style: FbCalStyle = 'timeline';
|
||||
private bookId: number = 0;
|
||||
private hidden: string = '';
|
||||
/** 是否镜像到系统日历 */
|
||||
private mirror: boolean = false;
|
||||
private store: preferences.Preferences | null = null;
|
||||
private ready: boolean = false;
|
||||
|
||||
@@ -59,10 +72,12 @@ class FbCalSettingsImpl {
|
||||
const s: preferences.ValueType = await this.store.get(K_STYLE, 'timeline');
|
||||
const b: preferences.ValueType = await this.store.get(K_BOOK, 0);
|
||||
const h: preferences.ValueType = await this.store.get(K_HIDDEN, '');
|
||||
const mir: preferences.ValueType = await this.store.get(K_MIRROR, false);
|
||||
this.view = (v === 'week' || v === 'agenda') ? v : 'month';
|
||||
this.style = (s === 'list') ? 'list' : 'timeline';
|
||||
this.bookId = typeof b === 'number' ? b : 0;
|
||||
this.hidden = typeof h === 'string' ? h : '';
|
||||
this.mirror = mir === true;
|
||||
this.ready = true;
|
||||
hilog.info(DOMAIN, TAG, '日程设置预加载完成:%{public}s / %{public}s', this.view, this.style);
|
||||
} catch (err) {
|
||||
@@ -109,6 +124,17 @@ class FbCalSettingsImpl {
|
||||
return this.hiddenIds().indexOf(id) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否镜像到系统日历。
|
||||
*
|
||||
* ⚠️ 这个值**只是开关**,不代表"已经镜像成功" ——
|
||||
* 权限被撤销 / 系统日历服务未就绪时,开关是开的但实际没镜像。
|
||||
* 界面上要同时显示"镜像了多少条"来让用户判断真实状态。
|
||||
*/
|
||||
getMirror(): boolean {
|
||||
return this.mirror;
|
||||
}
|
||||
|
||||
// ---- 异步写(同时更新内存) ----
|
||||
async setView(v: FbCalView): Promise<void> {
|
||||
this.view = v;
|
||||
@@ -120,6 +146,12 @@ class FbCalSettingsImpl {
|
||||
await this.put(K_STYLE, s);
|
||||
}
|
||||
|
||||
/** 写镜像开关 */
|
||||
async setMirror(on: boolean): Promise<void> {
|
||||
this.mirror = on;
|
||||
await this.put(K_MIRROR, on);
|
||||
}
|
||||
|
||||
async setBookId(id: number): Promise<void> {
|
||||
this.bookId = id;
|
||||
await this.put(K_BOOK, id);
|
||||
|
||||
@@ -428,8 +428,16 @@ export class FbTaskCardRow {
|
||||
dueText: string = '';
|
||||
/** `todo` / `doing`(done 的不进卡片) */
|
||||
status: string = '';
|
||||
/** 看板主题色(左侧色条) */
|
||||
/** 看板主题色(左侧色条 / 勾选圈颜色) */
|
||||
boardColor: string = '#A8574F';
|
||||
/**
|
||||
* 看板名。
|
||||
*
|
||||
* 2026-11-04:SyncCalendar 卡片条目的副行是「截止时间 · 日历本」,
|
||||
* 我们的对应物就是「截止时间 · 看板名」。加了它条目信息才完整 ——
|
||||
* 只显示"18:00"看不出是哪个看板的事。
|
||||
*/
|
||||
boardName: string = '';
|
||||
overdue: boolean = false;
|
||||
}
|
||||
|
||||
@@ -442,6 +450,21 @@ export class FbTaskCardData {
|
||||
openCount: number = 0;
|
||||
/** 其中逾期数(标题行给红色提示) */
|
||||
overdueCount: number = 0;
|
||||
/* ---------- 2026-11-04:照 SyncCalendar 的 TodoWidget* 卡片补的字段 ---------- */
|
||||
/** 日期文字(`11月4日`)—— 四个卡片的头部 */
|
||||
dateMd: string = '';
|
||||
/** 星期(`星期三`)—— 头部副行 */
|
||||
weekday: string = '';
|
||||
/**
|
||||
* 今日到期数。
|
||||
*
|
||||
* ⚠️ 与 `overdueCount` 是**两个不同概念**:
|
||||
* `overdueCount` = 已经晚了(该道歉)
|
||||
* `todayCount` = 今天还来得及(该行动)
|
||||
* SyncCalendar 的卡片把"今日到期"放主位、用橙色,就是因为它更能驱动行动。
|
||||
*/
|
||||
todayCount: number = 0;
|
||||
|
||||
/** 逾期优先展示的几条(逾期 > 进行中 > 普通,同组按截止时间升序),最多 4 条 */
|
||||
rowsJson: string = '[]';
|
||||
/** 🔴 v91:4×4/6×4 的滚动列表(同一排序,最多 30 条)—— 2×2/2×4 的卡不吃这个字段 */
|
||||
@@ -458,6 +481,18 @@ function taskRank(t: FbTaskItem): number {
|
||||
}
|
||||
|
||||
/** 一条任务 → 卡片行 */
|
||||
/** 星期中文(`Date.getDay()` 索引 0=周日) */
|
||||
const WEEKDAY_CN: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||
|
||||
/** 卡片用的日期小工具 */
|
||||
class FbCardDate {
|
||||
/** 本地时区的 `YYYY-MM-DD`(**不要用 toISOString()**:那是 UTC,晚上会差一天) */
|
||||
static isoOf(d: Date): string {
|
||||
const p = (n: number): string => n < 10 ? '0' + n : '' + n;
|
||||
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
||||
}
|
||||
}
|
||||
|
||||
function taskRowOf(t: FbTaskItem): FbTaskCardRow {
|
||||
const r: FbTaskCardRow = new FbTaskCardRow();
|
||||
r.id = t.id;
|
||||
@@ -466,6 +501,7 @@ function taskRowOf(t: FbTaskItem): FbTaskCardRow {
|
||||
r.dueText = t.dueText;
|
||||
r.status = t.status;
|
||||
r.boardColor = t.boardColor !== '' ? t.boardColor : '#A8574F';
|
||||
r.boardName = t.board !== undefined ? t.board : '';
|
||||
r.overdue = t.overdue;
|
||||
return r;
|
||||
}
|
||||
@@ -548,6 +584,25 @@ export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardDa
|
||||
return a.dueAt < b.dueAt ? -1 : 1;
|
||||
});
|
||||
|
||||
/* ---------- 2026-11-04:照 SyncCalendar 卡片补的日期与今日到期 ---------- */
|
||||
const now: Date = new Date();
|
||||
out.dateMd = (now.getMonth() + 1).toString() + '月' + now.getDate().toString() + '日';
|
||||
out.weekday = WEEKDAY_CN[now.getDay()];
|
||||
|
||||
/* 今日到期:比 `dueAt` 的**日期部分**与今天是否相同。
|
||||
⚠️ 不用"24 小时内":晚 23:50 和次日 00:10 的任务,用户认知里分属两天。
|
||||
⚠️ `dueAt` 格式是 `2026-10-25 18:00`(见 FbTaskItem 注释),取前 10 位即可。 */
|
||||
const todayIso: string = FbCardDate.isoOf(now);
|
||||
let todayCount: number = 0;
|
||||
for (const t of open) {
|
||||
if (t.dueAt !== '' && t.dueAt.length >= 10 && t.dueAt.substring(0, 10) === todayIso
|
||||
&& !t.overdue) {
|
||||
// 已逾期的今天任务不算"今日到期" —— 它归 overdueCount,否则两个数字会重复计
|
||||
todayCount++;
|
||||
}
|
||||
}
|
||||
out.todayCount = todayCount;
|
||||
|
||||
/* 🔴🔴 v95(用户 2026-10-26):「任务的 2x4 卡片,也是一样的」
|
||||
—— 2×4 的右半改成可滚动后,条数不能只给 4 条(否则 List 滑两下就到底)。
|
||||
⇒ 与 4×4/6×4 的 `all` 合并成同一份(同一个排序、同一个上限 30)。
|
||||
|
||||
@@ -401,6 +401,33 @@ export class FbDb {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- v8 → v9:日历镜像快照表 ----
|
||||
// 🔴 2026-11-03:新增「镜像到系统日历」功能,这张表记录"上次写了什么",
|
||||
// 用于区分"我们写的"和"用户在系统日历里改的"(防回灌死循环)。
|
||||
// ⚠️ 建表语句必须与 FbSchema 里**完全一致** —— 只改一处就是
|
||||
// "新装有、老库没有"或反之。
|
||||
if (from < 9) {
|
||||
const mirDdl: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS mirror_snapshot (
|
||||
identifier TEXT PRIMARY KEY,
|
||||
bucket TEXT NOT NULL DEFAULT '',
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
sys_id INTEGER NOT NULL DEFAULT -1,
|
||||
fingerprint TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mirror_bucket ON mirror_snapshot (bucket)`,
|
||||
];
|
||||
for (const sql of mirDdl) {
|
||||
try {
|
||||
await store.executeSql(sql);
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '建 mirror_snapshot 跳过:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.version = FB_DB_VERSION;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ import { site } from '@kit.MapKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { FbGeoTip } from '../model/Types';
|
||||
import { FbDiag } from '../common/FbDiag';
|
||||
import { FbApiResult, fbApi } from './FbHttp';
|
||||
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbGeoRepo';
|
||||
@@ -71,17 +74,20 @@ interface FbLatLng {
|
||||
* ⚠️ 不需要 token:Map Kit 是本机 HMS Core 能力,
|
||||
* 鉴权走 AGC 应用认证(错误码 1002600003),与我们的 API 令牌无关。
|
||||
*/
|
||||
export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip[]> {
|
||||
async function fbGeoViaMapKit(kw: string, limit: number = 8): Promise<FbGeoTip[]> {
|
||||
const q: string = kw.trim();
|
||||
/* 本地先挡一道:省一次往返,也让"字太少"的抖动更小 */
|
||||
if (q.length < FB_GEO_MIN_LEN) {
|
||||
return [];
|
||||
}
|
||||
|
||||
FbDiag.log('GEO', '=== 联想请求 === kw=[' + q + '] len=' + q.length);
|
||||
|
||||
try {
|
||||
const params: site.QueryAutoCompleteParams = { query: q };
|
||||
const res: site.QueryAutoCompleteResult = await site.queryAutoComplete(params);
|
||||
const sites: site.Site[] | undefined = res.sites;
|
||||
FbDiag.log('GEO', '返回 sites=' + (sites === undefined ? 'undefined' : String(sites.length)));
|
||||
if (sites === undefined || sites.length === 0) {
|
||||
/* ⚠️ 查不到**不是错误**:用户打了半截字本来就可能没结果。
|
||||
直接给空,让面板收起,别弹提示。 */
|
||||
@@ -112,6 +118,8 @@ export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip
|
||||
lat = numberToStr(loc.latitude);
|
||||
}
|
||||
|
||||
FbDiag.log('GEO', ' tip[' + out.length + '] name=[' + name
|
||||
+ '] detail=[' + detail + '] lng=' + lng + ' lat=' + lat);
|
||||
out.push({
|
||||
name: name !== '' ? name : detail,
|
||||
detail: detail,
|
||||
@@ -124,6 +132,7 @@ export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip
|
||||
break;
|
||||
}
|
||||
}
|
||||
FbDiag.log('GEO', '最终返回 ' + out.length + ' 条');
|
||||
return out;
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
@@ -131,10 +140,135 @@ export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip
|
||||
if (e.code !== 1002603001) {
|
||||
hilog.warn(DOMAIN, TAG, '地址联想失败:code=%{public}d %{public}s', e.code, e.message);
|
||||
}
|
||||
/* 🔴 诊断关键:错误码是所有"联想不出来"的根因分类器
|
||||
1002600003 = AGC 应用认证失败(client_id 不对)
|
||||
1002600004 = AGC 没开 Map Kit 服务
|
||||
1002600002 = 连不上 Map Kit 服务器
|
||||
1002603001 = 查不到(正常) */
|
||||
FbDiag.log('GEO', '❌ 异常 code=' + e.code + ' msg=' + e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址联想(**优先走我们的服务端**)。
|
||||
*
|
||||
* 🔴 2026-11-03 改。原来的唯一数据源是 Map Kit,但它报
|
||||
* `1002600003 App authentication failed`(AGC 认证失败),
|
||||
* 而 AGC 那条链路要开地图服务 + 重新生成 Profile,可能等 24 小时。
|
||||
*
|
||||
* 现在的顺序:
|
||||
* ① `/api/v1/geo/suggest` —— 服务端用高德 Web 服务 key 转发 inputtips
|
||||
* ✅ 不依赖 AGC、数据源与网页端完全一致(网页也走高德)
|
||||
* ✅ 高德直接给省市区 + 门牌 + 坐标,地址天生完整
|
||||
* ② Map Kit —— 服务端不通时的兜底(万一有 AGC 权限而服务器故障)
|
||||
*
|
||||
* @param kw 关键词
|
||||
* @param limit 最多几条
|
||||
* @returns 候选列表;**任何失败都返回空数组**,不抛错
|
||||
*/
|
||||
export async function fbGeoTips(kw: string, limit: number = 8): Promise<FbGeoTip[]> {
|
||||
const q: string = kw.trim();
|
||||
if (q.length < FB_GEO_MIN_LEN) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ① 服务端
|
||||
const viaServer: FbGeoTip[] = await fbGeoViaServer(q, limit);
|
||||
if (viaServer.length > 0) {
|
||||
FbDiag.log('GEO', '服务端返回 ' + viaServer.length + ' 条');
|
||||
return viaServer;
|
||||
}
|
||||
|
||||
// ② Map Kit 兜底
|
||||
FbDiag.log('GEO', '服务端无结果,改用 Map Kit');
|
||||
return await fbGeoViaMapKit(q, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端地址联想:`GET /api/v1/geo/suggest?q=xxx&limit=N`
|
||||
*
|
||||
* ⚠️ 需要 API 令牌(`FB_TOKEN_STORE.get()`)。未登录直接返回空,
|
||||
* 不去请求(省一次 401 往返)。
|
||||
*/
|
||||
async function fbGeoViaServer(q: string, limit: number): Promise<FbGeoTip[]> {
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
FbDiag.log('GEO', '服务端: token长度=' + token.length);
|
||||
if (token === '') {
|
||||
FbDiag.log('GEO', '未登录,跳过服务端联想');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const res: FbApiResult = await fbApi('/api/v1/geo/suggest', {
|
||||
method: 'GET',
|
||||
token: token,
|
||||
query: { 'q': q, 'limit': String(limit) },
|
||||
timeoutMs: 6000
|
||||
});
|
||||
FbDiag.log('GEO', '服务端响应 ok=' + res.ok + ' status=' + res.status
|
||||
+ ' dataLen=' + res.data.length + ' code=' + res.code + ' err=' + res.error);
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* 响应形状:{ ok: true, data: { tips: [ {...}, ... ] } }
|
||||
⚠️ `res.data` 是**原始 JSON 字符串**(FbHttp 的约定),要自己解析。 */
|
||||
FbDiag.log('GEO', '原始响应前80字: ' + res.data.substring(0, Math.min(80, res.data.length)));
|
||||
|
||||
/* 🔴 2026-11-03 修:**`res.data` 已经是 data 层本身**。
|
||||
`FbHttp.fbApi()` 会把 `{ok, data}` 信封拆掉再序列化回来,
|
||||
所以这里拿到的是 `{"tips":[...]}`,**没有** 外层 `data`。
|
||||
原代码找 `row['data']` ⇒ 恒 undefined ⇒ 提前返回空(真机日志已验证)。
|
||||
|
||||
⇒ 直接从顶层取 `tips`。 */
|
||||
const parsed: Object = JSON.parse(res.data) as Object;
|
||||
const dataRec: Record<string, Object> = parsed as Record<string, Object>;
|
||||
const tipsVal: Object | undefined = dataRec['tips'] as Object | undefined;
|
||||
FbDiag.log('GEO', 'tips 类型=' + typeof tipsVal
|
||||
+ ' isArray=' + Array.isArray(tipsVal));
|
||||
if (tipsVal === undefined) {
|
||||
FbDiag.log('GEO', 'tips 为 undefined');
|
||||
return [];
|
||||
}
|
||||
|
||||
// ArkTS 不允许裸 any,逐条按结构读
|
||||
const arr: Object[] = tipsVal as Object[];
|
||||
FbDiag.log('GEO', 'tips 数组长度=' + arr.length);
|
||||
const out: FbGeoTip[] = [];
|
||||
for (const it of arr) {
|
||||
const r: Record<string, Object> = it as Record<string, Object>;
|
||||
const tip: FbGeoTip = {
|
||||
name: strOf(r['name']),
|
||||
detail: strOf(r['detail']),
|
||||
district: strOf(r['district']),
|
||||
address: strOf(r['address']),
|
||||
lng: strOf(r['lng']),
|
||||
lat: strOf(r['lat'])
|
||||
};
|
||||
if (tip.name === '' && tip.detail === '') {
|
||||
continue;
|
||||
}
|
||||
out.push(tip);
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
FbDiag.log('GEO', '服务端异常: ' + JSON.stringify(err));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Record 里安全取字符串(ArkTS 没有 any,必须显式判类型) */
|
||||
function strOf(v: Object | undefined): string {
|
||||
if (v === undefined || v === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
return v as string;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 数字转字符串(Map Kit 给 number;对外统一成字符串,与网页端一致) */
|
||||
function numberToStr(v: number): string {
|
||||
if (typeof v !== 'number' || isNaN(v)) {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db';
|
||||
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
|
||||
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
|
||||
*/
|
||||
export const FB_DB_VERSION: number = 8;
|
||||
export const FB_DB_VERSION: number = 9;
|
||||
|
||||
/**
|
||||
* 建表语句。
|
||||
@@ -355,6 +355,30 @@ export const FB_SCHEMA_SQL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`,
|
||||
// 同步时按 uid 去重/更新
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`,
|
||||
|
||||
// ---------- 日历镜像快照(v9 新增)----------
|
||||
//
|
||||
// 用户 2026-11-03:「把日历镜像到系统日历中,原因同步日历都已经说过了」。
|
||||
// 原因:本应用是 CalDAV 客户端,日程不进系统日历,
|
||||
// 导致小艺 / 桌面日历卡片 / 手表看到的是空的
|
||||
// (甚至给出"今天没有日程"的错误答案)。
|
||||
//
|
||||
// 🔴 这张表是**防回灌的地基**:镜像会把日程写进系统日历;
|
||||
// 下一轮若直接读系统日历当"用户改动",就会把我们刚写的当成用户改的
|
||||
// → 回写服务器 → 服务器变了 → 又镜像回去 …… 死循环。
|
||||
// 记下"上次写了什么",一轮三方比对就能分清谁改的。
|
||||
//
|
||||
// ⚠️ `bucket` 在 FamilyBook 里恒为 `family`(只有一套日程,
|
||||
// 不像 SyncCalendar 有多个 DAV 账户)。留这一列是为了将来按日历本拆。
|
||||
`CREATE TABLE IF NOT EXISTS mirror_snapshot (
|
||||
identifier TEXT PRIMARY KEY COMMENT '<bucket>|<uid>[@<发生时刻>]',
|
||||
bucket TEXT NOT NULL DEFAULT '',
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
sys_id INTEGER NOT NULL DEFAULT -1,
|
||||
fingerprint TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mirror_bucket ON mirror_snapshot (bucket)`,
|
||||
];
|
||||
|
||||
/** 建表语句分组说明(供日志/排查用) */
|
||||
@@ -383,6 +407,10 @@ export const FB_TABLE_GROUPS: FbTableGroup[] = [
|
||||
group: '日程',
|
||||
tables: ['cal_events']
|
||||
},
|
||||
{
|
||||
group: '系统日历镜像',
|
||||
tables: ['mirror_snapshot']
|
||||
},
|
||||
{
|
||||
group: '同步元数据(本地专有)',
|
||||
tables: ['sync_state', 'pending_ops', 'module_sync', 'attach_cache']
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FbDiag } from '../common/FbDiag';
|
||||
/**
|
||||
* 家书 FamilyBook · 旅程的**写操作**(鸿蒙端)
|
||||
*
|
||||
@@ -23,7 +24,7 @@
|
||||
*/
|
||||
|
||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||
import { fbUploadOne } from './FbUpload';
|
||||
import { fbUploadOne, fbUploadStage, FbStageUpload } from './FbUpload';
|
||||
import { FbTripDetail, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary } from '../model/Types';
|
||||
|
||||
/** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */
|
||||
@@ -65,6 +66,55 @@ export interface FbTripNodeDraft {
|
||||
/** 'plan' 计划 / 'actual' 实际 */
|
||||
category: string;
|
||||
detail: string;
|
||||
|
||||
/* ---------- 2026-11-05:对齐网页端表单补的字段 ---------- */
|
||||
|
||||
/**
|
||||
* 出发地地名(网页端 `from_place`)。
|
||||
*
|
||||
* 🔴 用户:「出发地、目的地要分开。现在地点这两个都放在一起,能对吗?」
|
||||
* 原来端上只有一个 `place`,出发地和到达地填不了。
|
||||
*/
|
||||
fromPlace: string;
|
||||
/** 到达地地名(网页端 `to_place`) */
|
||||
toPlace: string;
|
||||
/**
|
||||
* 出发地详细地址(网页端 `from_addr`)。
|
||||
* ⚠️ 端上没有独立的"详细地址"输入,暂用同名字段兜底
|
||||
* (网页端是「地名 + 高德详细地址」两份)。
|
||||
*/
|
||||
fromAddr: string;
|
||||
/** 到达地详细地址 */
|
||||
toAddr: string;
|
||||
/* #51:坐标(高德返回;空串 = 纯手打没选候选,不传) */
|
||||
fromLng: string;
|
||||
fromLat: string;
|
||||
toLng: string;
|
||||
toLat: string;
|
||||
/**
|
||||
* 执行状态:`planned` / `ongoing` / `done` / `cancelled`。
|
||||
*
|
||||
* 🔴 #47(2026-11-05 用户要求):
|
||||
* **表单不参与状态管理**。新建固定发 `'planned'`;
|
||||
* 编辑**发空串** ⇒ `updateTripNode()` 不把 status 放进 body
|
||||
* ⇒ 服务端沿用库里原值(用户通过卡片动作改的状态不会被编辑冲掉)。
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* 参与人 id 数组(网页端 `members[]`)。
|
||||
* ⚠️ 空数组 = 清空参与人;与"没传"不同 —— 服务端 `nodeData` 用 `$has()` 判定。
|
||||
*/
|
||||
members: number[];
|
||||
/**
|
||||
* 暂存文件令牌(先上传、保存时一起提交的照片)。
|
||||
*
|
||||
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数
|
||||
* 一起提交,后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
|
||||
*
|
||||
* 服务端拿到它:`Trip::saveNode()` 先建节点 → `StagedUpload::claim()` 挂图。
|
||||
* ⚠️ 空数组 = 这次没有新照片。
|
||||
*/
|
||||
stageTokens: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +247,20 @@ export function parseNodes(json: string): FbTripNode[] {
|
||||
nodeType: strOf(o, 'node_type'),
|
||||
detail: strOf(o, 'detail'),
|
||||
fromPlace: strOf(o, 'from_place'),
|
||||
/* #51:详细地址与坐标(服务端 SELECT * 已返回,原来端上没映射) */
|
||||
fromAddr: strOf(o, 'from_addr'),
|
||||
toAddr: strOf(o, 'to_addr'),
|
||||
fromLng: strOf(o, 'from_lng'),
|
||||
fromLat: strOf(o, 'from_lat'),
|
||||
toLng: strOf(o, 'to_lng'),
|
||||
toLat: strOf(o, 'to_lat'),
|
||||
/* 🔴 2026-11-05:原始时间值(服务端 SELECT * 已经返回,原来端上丢了)。
|
||||
用于编辑回填 —— `when` 不带年份,反推会跨年错位。 */
|
||||
startsAt: strOf(o, 'starts_at'),
|
||||
/* #48:真实开始/结束时间(服务端 SELECT * 已返回,原来端上没解析) */
|
||||
actualStartedAt: strOf(o, 'actual_started_at'),
|
||||
actualEndedAt: strOf(o, 'actual_ended_at'),
|
||||
endsAt: strOf(o, 'ends_at'),
|
||||
toPlace: strOf(o, 'to_place'),
|
||||
lineName: strOf(o, 'line_name'),
|
||||
carrier: strOf(o, 'carrier'),
|
||||
@@ -304,10 +368,241 @@ export async function addTripNode(token: string, tripId: number, d: FbTripNodeDr
|
||||
if (d.vehicleId > 0) {
|
||||
body['vehicle_id'] = d.vehicleId;
|
||||
}
|
||||
/* 2026-11-05:出发地 / 到达地 / 执行状态 / 参与人(对齐网页端) */
|
||||
body['from_place'] = d.fromPlace;
|
||||
body['to_place'] = d.toPlace;
|
||||
body['from_addr'] = d.fromAddr;
|
||||
body['to_addr'] = d.toAddr;
|
||||
/* #51:坐标(空串不传 —— 服务端 coordOrNull 会写 NULL,等于没坐标) */
|
||||
if (d.fromLng !== '' && d.fromLat !== '') {
|
||||
body['from_lng'] = d.fromLng;
|
||||
body['from_lat'] = d.fromLat;
|
||||
}
|
||||
if (d.toLng !== '' && d.toLat !== '') {
|
||||
body['to_lng'] = d.toLng;
|
||||
body['to_lat'] = d.toLat;
|
||||
}
|
||||
/* 🔴 #47:**编辑时不传 status**(`d.status === ''`)。
|
||||
用户点了「开始执行」把状态改成 ongoing,再回头编辑标题 ——
|
||||
如果这里传 'planned',状态就被打回计划中。
|
||||
服务端 `nodeData()` 用 `array_key_exists` 判定"这次传没传",
|
||||
不发这个键 ⇒ 沿用库里原值。status 为空表示"表单不参与状态管理"。 */
|
||||
if (d.status !== '') {
|
||||
body['status'] = d.status;
|
||||
}
|
||||
if (d.members.length > 0) {
|
||||
body['members'] = d.members;
|
||||
}
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给一段行程传附件(照片/视频)。
|
||||
*
|
||||
* 🔴 2026-11-05 新增。用户:「它不能这样传图片吗?网页端有什么功能,
|
||||
* 这个就应该有什么功能,应该一模一样啊。」
|
||||
*
|
||||
* 落点与网页端**完全相同**:`attachments` 表
|
||||
* `target_type = 'trip'`, `target_id = nodeId`
|
||||
* 服务端路由:`POST /api/v1/trips/<tripId>/nodes/attach?node_id=<nid>`
|
||||
*
|
||||
* @returns 空串 = 成功;否则是错误说明
|
||||
*/
|
||||
export async function addNodeAttach(token: string, tripId: number, nodeId: number,
|
||||
uri: string): Promise<string> {
|
||||
const path: string = '/api/v1/trips/' + tripId.toString()
|
||||
+ '/nodes/attach?node_id=' + nodeId.toString();
|
||||
return await fbUploadOne(path, 'file', uri, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删一张附件。
|
||||
*
|
||||
* 服务端路由:`DELETE /api/v1/trips/<tripId>/nodes/attach?node_id=&attachment_id=`
|
||||
* @returns 空串 = 成功;否则是错误说明
|
||||
*/
|
||||
export async function deleteNodeAttach(token: string, tripId: number, nodeId: number,
|
||||
attachId: number): Promise<string> {
|
||||
const query: Record<string, string> = {};
|
||||
query['node_id'] = nodeId.toString();
|
||||
query['attachment_id'] = attachId.toString();
|
||||
const opts: FbApiOptions = { method: 'DELETE', token: token, query: query };
|
||||
const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/attach',
|
||||
opts);
|
||||
return res.ok ? '' : (res.error !== '' ? res.error : '删除失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 改一段行程的执行状态。
|
||||
*
|
||||
* 🔴 #47(2026-11-05):用户要求状态由**动作**驱动,不再由编辑表单选:
|
||||
* planned(新建固定)→ ongoing(开始执行)→ done(已完成)/ cancelled(已取消)
|
||||
*
|
||||
* 服务端:`PATCH /api/v1/trips/<tripId>/nodes/status`,body `{node_id, status}`
|
||||
* ⚠️ 节点 id 走 body —— dispatch 只解析三段。
|
||||
*
|
||||
* @param status `planned` / `ongoing` / `done` / `cancelled`
|
||||
*/
|
||||
export async function setNodeStatus(token: string, tripId: number, nodeId: number,
|
||||
status: string): Promise<FbApiResult> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['node_id'] = nodeId;
|
||||
body['status'] = status;
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
|
||||
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/status', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点类型(#51)。
|
||||
*
|
||||
* 🔴 用户:「这个类型和网页端不一样啊,网页端比这个多得多呀,
|
||||
* 我们这上面的类型怎么这么少呢?」
|
||||
*
|
||||
* 实测库里 **9 种**,而端上硬编码 5 种(照抄了 `Trip::NODE_TYPES` 那个
|
||||
* **兜底常量**,真实数据在 `node_types` 表、用户可增删)。
|
||||
* ⇒ 从 `GET /api/v1/trips/<id>/node-types` 动态拉。
|
||||
*/
|
||||
export interface FbNodeTypeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export async function fetchNodeTypes(token: string, tripId: number): Promise<FbNodeTypeItem[]> {
|
||||
const opts: FbApiOptions = { method: 'GET', token: token };
|
||||
const res: FbApiResult = await fbApi(
|
||||
'/api/v1/trips/' + tripId.toString() + '/node-types', opts);
|
||||
if (!res.ok || res.data === '') {
|
||||
return [];
|
||||
}
|
||||
const out: FbNodeTypeItem[] = [];
|
||||
try {
|
||||
const env: Record<string, Object> = JSON.parse(res.data) as Record<string, Object>;
|
||||
const items: Object | undefined = env['items'];
|
||||
if (items !== undefined && Array.isArray(items)) {
|
||||
for (const it of items as Object[]) {
|
||||
const o: Record<string, Object> = it as Record<string, Object>;
|
||||
const nm: Object | undefined = o['name'];
|
||||
if (typeof nm !== 'string' || nm === '') {
|
||||
continue;
|
||||
}
|
||||
const row: FbNodeTypeItem = {
|
||||
id: typeof o['id'] === 'number' ? o['id'] as number : 0,
|
||||
name: nm as string,
|
||||
icon: typeof o['icon'] === 'string' ? o['icon'] as string : '📌'
|
||||
};
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个行程动作(#48)。
|
||||
*
|
||||
* 🔴 用户要求状态由**动作**驱动,且三个动作语义不同:
|
||||
* · `start` → status=ongoing,**记录真实开始时间**
|
||||
* · `done` → **stage 转成 actual**(变成实际行程),记录真实结束时间
|
||||
* · `cancel` → stage=skipped(保留数据,不是删除)
|
||||
*
|
||||
* ⚠️ `done` 之后这段就是实际行程了,**不再有状态**(用户:实际行程没状态)。
|
||||
* ⚠️ 与「删除」完全不同:删除走 `deleteTripNode`(软删)。
|
||||
*
|
||||
* 服务端:`PATCH /api/v1/trips/<id>/nodes/action`,body `{node_id, action}`
|
||||
*/
|
||||
export async function nodeAction(token: string, tripId: number, nodeId: number,
|
||||
act: string): Promise<FbApiResult> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['node_id'] = nodeId;
|
||||
body['action'] = act;
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
|
||||
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes/action', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存上传(转发 `FbUpload.fbUploadStage`)。
|
||||
*
|
||||
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交,
|
||||
* 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
|
||||
*
|
||||
* ⚠️ 不在这里重复写 multipart —— 全项目只有 `FbUpload.ets` 一份实现
|
||||
* (那个文件头写着"唯一的 multipart 实现",别再抄第二份)。
|
||||
*/
|
||||
export async function stageUpload(token: string, uri: string): Promise<FbStageUpload> {
|
||||
return await fbUploadStage(uri, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改一段行程。
|
||||
*
|
||||
* 🔴 2026-11-05 新增。用户:「鸿蒙端:现在行程还不能编辑,得尽快把这个功能做完。」
|
||||
*
|
||||
* 原来端上只有 `add` / `delete` —— 想改一个字就得删了重建,
|
||||
* 而 `trip_nodes.id` 上挂着成员、车辆、附件,删了就全丢。
|
||||
*
|
||||
* ## 寻址方式(与 `deleteTripNode` 一致)
|
||||
*
|
||||
* PATCH /api/v1/trips/<tripId>/nodes?node_id=<nodeId>
|
||||
*
|
||||
* ⚠️ 不用 `/trips/1/nodes/5`:`Api::dispatch()` 只解析三段,
|
||||
* 第 4 段会被丢掉(见 `deleteTripNode` 的注释)。
|
||||
* ⚠️ 同时把 `id` 放进 body:服务端读 body,
|
||||
* 两边任何一边改了口径另一边也还能工作。
|
||||
*
|
||||
* ## 空值语义(与新建不同)
|
||||
*
|
||||
* 新建时"没填"和"要清空"是一回事;**编辑时不是**。
|
||||
* 用户把车次删掉 → 必须传空串才能清掉。
|
||||
* ⇒ 这里**不做**"空就不传"的过滤,照直传。
|
||||
* 服务端 `nodeData()` 用 `$has()` 判定"这次有没有传这个字段",
|
||||
* 传了空串就写成 NULL(清空),没传才沿用旧值。
|
||||
*/
|
||||
export async function updateTripNode(token: string, tripId: number, nodeId: number,
|
||||
d: FbTripNodeDraft): Promise<FbApiResult> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['id'] = nodeId;
|
||||
body['title'] = d.title;
|
||||
body['place'] = d.place;
|
||||
body['detail'] = d.detail;
|
||||
body['starts_at'] = d.startsAt;
|
||||
body['ends_at'] = d.endsAt;
|
||||
body['node_type'] = d.nodeType;
|
||||
body['travel_mode'] = d.travelMode;
|
||||
body['category'] = d.category;
|
||||
/* ⚠️ 这两个**总是带**(含空串):
|
||||
编辑场景下"清空车次/取消选车"必须能生效,空就不传会导致永远清不掉。 */
|
||||
body['trip_no'] = d.tripNo;
|
||||
body['vehicle_id'] = d.vehicleId;
|
||||
/* 2026-11-05:出发地 / 到达地 / 执行状态 / 参与人(对齐网页端) */
|
||||
body['from_place'] = d.fromPlace;
|
||||
body['to_place'] = d.toPlace;
|
||||
body['from_addr'] = d.fromAddr;
|
||||
body['to_addr'] = d.toAddr;
|
||||
/* #51:坐标(空串不传 —— 服务端 coordOrNull 会写 NULL,等于没坐标) */
|
||||
if (d.fromLng !== '' && d.fromLat !== '') {
|
||||
body['from_lng'] = d.fromLng;
|
||||
body['from_lat'] = d.fromLat;
|
||||
}
|
||||
if (d.toLng !== '' && d.toLat !== '') {
|
||||
body['to_lng'] = d.toLng;
|
||||
body['to_lat'] = d.toLat;
|
||||
}
|
||||
body['status'] = d.status;
|
||||
/* ⚠️ 空数组也传:编辑时"把人全去掉"必须能生效
|
||||
(服务端 `members` 用 `Api::ints()` 读,空数组 = 清空) */
|
||||
body['members'] = d.members;
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
query['node_id'] = nodeId.toString();
|
||||
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body, query: query };
|
||||
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删一段行程。
|
||||
*
|
||||
@@ -350,12 +645,119 @@ function numOf(o: Record<string, Object>, k: string): number {
|
||||
}
|
||||
|
||||
/** 节点时间文案:`starts_at` 有就用它,否则用 `ends_at`;都没有给空串 */
|
||||
/**
|
||||
* 行程时间跨度文案。
|
||||
*
|
||||
* 🔴🔴 2026-11-05 修复(用户:「鸿蒙端的结束时间还是没有,只有开始时间」)。
|
||||
*
|
||||
* **原实现的 bug**:
|
||||
*
|
||||
* const a = strOf(o,'starts_at') !== '' ? strOf(o,'starts_at') : strOf(o,'ends_at');
|
||||
* return cnShort(a);
|
||||
*
|
||||
* 那行三元看着像"两个都认",其实只是"开始为空时拿结束顶替" ——
|
||||
* **两个都有时,结束时间被彻底丢弃**。
|
||||
*
|
||||
* 现在逐条对齐网页端 `Trip::nodeSpan($start, $end)` 的四种形态:
|
||||
* · 同一天、两头都有时分 → `2026年11月7日 16:50 – 21:41`
|
||||
* · 同一天、结束没时分 → `2026年11月7日 16:50 起`
|
||||
* · 同一天、两头都没时分 → `2026年11月7日`
|
||||
* · 跨天 → `2026年10月2日 07:30 – 10月3日 18:00`
|
||||
*
|
||||
* ⚠️ 必须与网页端**同一套文案**,否则同一段行程在两端显示不一样。
|
||||
*/
|
||||
function spanOf(o: Record<string, Object>): string {
|
||||
const a: string = strOf(o, 'starts_at') !== '' ? strOf(o, 'starts_at') : strOf(o, 'ends_at');
|
||||
if (a === '') {
|
||||
return '';
|
||||
const startRaw: string = strOf(o, 'starts_at');
|
||||
const endRaw: string = strOf(o, 'ends_at');
|
||||
return fbSpanOf(startRaw, endRaw);
|
||||
}
|
||||
|
||||
/**
|
||||
* `starts_at` + `ends_at` → 展示串(网页端 `Trip::nodeSpan` 的端上实现)。
|
||||
*
|
||||
* @param startRaw `YYYY-MM-DD HH:MM:SS`(空 = 日期待补)
|
||||
* @param endRaw `YYYY-MM-DD HH:MM:SS`(空 = 没设结束)
|
||||
*/
|
||||
export function fbSpanOf(startRaw: string, endRaw: string): string {
|
||||
if (startRaw === '') {
|
||||
return '日期待补';
|
||||
}
|
||||
return cnShort(a);
|
||||
const s: Date | null = fbParseIso(startRaw);
|
||||
if (s === null) {
|
||||
return '日期待补';
|
||||
}
|
||||
const sHasTime: boolean = !(s.getHours() === 0 && s.getMinutes() === 0);
|
||||
const sHead: string = fbCnDate(s) + (sHasTime ? ' ' + fbHm(s) : '');
|
||||
|
||||
if (endRaw === '') {
|
||||
return sHead;
|
||||
}
|
||||
const e: Date | null = fbParseIso(endRaw);
|
||||
if (e === null) {
|
||||
return sHead;
|
||||
}
|
||||
const eHasTime: boolean = !(e.getHours() === 0 && e.getMinutes() === 0);
|
||||
|
||||
/* 同一天 */
|
||||
if (fbSameDay(s, e)) {
|
||||
if (!eHasTime && !sHasTime) {
|
||||
return fbCnDate(s); // 两头都没时分 = 一整天
|
||||
}
|
||||
if (!eHasTime) {
|
||||
return sHead + ' 起'; // 结束没填时分 → 说"从几点开始"更准
|
||||
}
|
||||
return fbCnDate(s) + (sHasTime ? ' ' + fbHm(s) : '') + ' – ' + fbHm(e);
|
||||
}
|
||||
/* 跨天:结束那边写"月日"(不重复年份),与网页端一致 */
|
||||
return sHead + ' – ' + (e.getMonth() + 1).toString() + '月' + e.getDate().toString() + '日'
|
||||
+ (eHasTime ? ' ' + fbHm(e) : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析服务端的 `YYYY-MM-DD HH:MM:SS`。
|
||||
*
|
||||
* ⚠️ 不用 `new Date(str)`:各家 JS 引擎对空格分隔的解析行为不一致
|
||||
* (Safari/部分引擎会返回 Invalid Date)。手工拆更稳。
|
||||
*/
|
||||
function fbParseIso(raw: string): Date | null {
|
||||
const v: string = raw.trim();
|
||||
if (v.length < 10) {
|
||||
return null;
|
||||
}
|
||||
const y: number = parseInt(v.substring(0, 4), 10);
|
||||
const mo: number = parseInt(v.substring(5, 7), 10) - 1;
|
||||
const d: number = parseInt(v.substring(8, 10), 10);
|
||||
let h: number = 0;
|
||||
let mi: number = 0;
|
||||
if (v.length >= 16) {
|
||||
h = parseInt(v.substring(11, 13), 10);
|
||||
mi = parseInt(v.substring(14, 16), 10);
|
||||
}
|
||||
if (isNaN(y) || isNaN(mo) || isNaN(d)) {
|
||||
return null;
|
||||
}
|
||||
const out: Date = new Date(y, mo, d,
|
||||
isNaN(h) ? 0 : h, isNaN(mi) ? 0 : mi, 0, 0);
|
||||
return isNaN(out.getTime()) ? null : out;
|
||||
}
|
||||
|
||||
/** `2026年11月7日` */
|
||||
function fbCnDate(d: Date): string {
|
||||
return d.getFullYear().toString() + '年' + (d.getMonth() + 1).toString()
|
||||
+ '月' + d.getDate().toString() + '日';
|
||||
}
|
||||
|
||||
/** `16:05`(补零到两位) */
|
||||
function fbHm(d: Date): string {
|
||||
const h: string = d.getHours() < 10 ? '0' + d.getHours().toString() : d.getHours().toString();
|
||||
const m: string = d.getMinutes() < 10 ? '0' + d.getMinutes().toString() : d.getMinutes().toString();
|
||||
return h + ':' + m;
|
||||
}
|
||||
|
||||
function fbSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
/** `2026-10-20T09:30:00` → `10-20 09:30`(列表里一行够用) */
|
||||
@@ -458,6 +860,9 @@ function peopleOf(o: Record<string, Object>, k: string): FbTripNodePerson[] {
|
||||
function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
|
||||
const v: Object | undefined = o[k];
|
||||
const out: FbTripNodePhoto[] = [];
|
||||
/* DIAG-ATTACH:看原始输入 */
|
||||
FbDiag.log('ATTACH', 'attachsOf k=' + k + ' isArray=' + Array.isArray(v).toString()
|
||||
+ ' raw=' + (Array.isArray(v) ? (v as Object[]).length.toString() : '-'));
|
||||
if (!Array.isArray(v)) {
|
||||
return out;
|
||||
}
|
||||
@@ -479,6 +884,8 @@ function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
|
||||
size: numOf(a, 'size')
|
||||
});
|
||||
}
|
||||
/* DIAG-ATTACH:解析出几条(key 为空的会被跳过,所以可能少于 raw) */
|
||||
FbDiag.log('ATTACH', 'attachsOf 解析出 ' + out.length.toString() + ' 条');
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,73 @@ export async function fbUploadOne(path: string, field: string, uri: string,
|
||||
return r.ok ? '' : r.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存上传:把文件传到暂存区,返回**令牌**。
|
||||
*
|
||||
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交,
|
||||
* 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
|
||||
*
|
||||
* 这是「新增时也能挂照片」的关键:新增行程时节点还不存在,
|
||||
* 只能先把文件暂存起来拿 stoken,等保存表单时把 stoken 一起提交,
|
||||
* 服务端建完节点再 `claim` 上去。
|
||||
*
|
||||
* @param uri picker 给的文件 URI
|
||||
* @param token 访问令牌
|
||||
* @returns stoken 为空串 = 失败(看 error)
|
||||
*/
|
||||
export interface FbStageUpload {
|
||||
/** 暂存令牌(空 = 失败) */
|
||||
stoken: string;
|
||||
/** 原始文件名 */
|
||||
name: string;
|
||||
/** 是否图片(服务端按 MIME 嗅探) */
|
||||
isImage: boolean;
|
||||
/** 预览地址(相对路径) */
|
||||
url: string;
|
||||
/** 错误说明(成功时为空) */
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function fbUploadStage(uri: string, token: string): Promise<FbStageUpload> {
|
||||
const out: FbStageUpload = {
|
||||
stoken: '', name: '', isImage: false, url: '', error: ''
|
||||
};
|
||||
const r: FbUploadRaw = await fbUploadRaw('/api/v1/uploads/stage', 'file', uri, token);
|
||||
if (!r.ok) {
|
||||
out.error = r.error !== '' ? r.error : '上传失败';
|
||||
return out;
|
||||
}
|
||||
/* 服务端信封:{ok:true, data:{stoken,...}}(`fbApi` 层已拆过一层,
|
||||
但 multipart 这条路是自己解析的,要按原始信封读) */
|
||||
try {
|
||||
const env: Record<string, Object> = JSON.parse(r.body) as Record<string, Object>;
|
||||
let dataObj: Record<string, Object> = env;
|
||||
const nested: Object | undefined = env['data'];
|
||||
if (nested !== undefined) {
|
||||
dataObj = nested as Record<string, Object>;
|
||||
}
|
||||
const st: Object | undefined = dataObj['stoken'];
|
||||
if (typeof st === 'string' && st !== '') {
|
||||
out.stoken = st as string;
|
||||
}
|
||||
const nm: Object | undefined = dataObj['name'];
|
||||
if (typeof nm === 'string') {
|
||||
out.name = nm as string;
|
||||
}
|
||||
out.isImage = dataObj['is_image'] === true;
|
||||
const u: Object | undefined = dataObj['url'];
|
||||
if (typeof u === 'string') {
|
||||
out.url = u as string;
|
||||
}
|
||||
if (out.stoken === '') {
|
||||
out.error = '服务端没返回令牌';
|
||||
}
|
||||
} catch (err) {
|
||||
out.error = '响应解析失败';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片并**拿回响应载荷**(工单 #23 用)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user