用户原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」 改法:不再全 Canvas 手绘(只有彩色圆点 + 首字,中文又平又硬、做不出圆角阴影), 改成「Canvas 只画线 + 原生 Column/Text/FbAvatar 绝对定位」。 不能改用 Path 组件:Path.commands 单位是 px,而 .position() 是 vp,混用会缩 1/3; Canvas 绘制坐标系本身是 vp,与 .position() 自洽。 布局算法抽成纯函数 common/KinLayout.ets(可单独验证,不再和"画"揉在一起): - 横向单位是「夫妻单元」而不是「人」,夫妻并排紧邻 - 子女挂在夫妻单元中点下方(family-chart 语义) - 迭代松弛 24 轮容纳冲突约束(家谱不是树:姻亲的"挂父母下方"与"夫妻并排"互相打架) - 全程确定性(排序带 minId 兜底),避免每次重画卡片抖位置 真机抓到并修掉两个真 bug: 1. 户号 0 被当成"没分户":服务端 unit_of 从 0 编号,端上判 > 0 ⇒ 我与妻子被拆两户(真机 Δx=680px)。修:布局层 >= 0,解析层缺失给 -1, 不把任何数值当哨兵。 2. 线没连对(用户原话「主要是线没连对」):buildBloodEdges 用 gen 数值当行号, minGen=-1 ⇒ 长辈线从 y=-148 起(屏幕外),整组线串到上一代卡片。 修:新增 rowIndex(gens, gen),用走标换算而不是数值。 验证: - 离线布局 29/0(含户号 0、脏数据一户三人、镜像常量与端上源码一致) - 反向验证 11/0(9 处注入,含"户号 0 当没分户"与"用 gen 数值当行号"两条真 bug 复现) - 真机几何 15/0、真实数据+屏幕对照 9/0(反解密度 3.125x,残差 0.23/0.32px; 12 个血缘线端点全部贴在卡片上下沿) - ArkTS 静态守卫 905/0
1022 lines
35 KiB
Plaintext
1022 lines
35 KiB
Plaintext
/**
|
||
* 家书 FamilyBook · 接口数据解析(鸿蒙端)
|
||
*
|
||
* 把服务端 `GET /api/v1/<模块>` 返回的 JSON 转成端上的展示类型(`model/Types`)。
|
||
*
|
||
* 🔴 为什么单独一个文件、而且要**照着真实响应写**:
|
||
* 服务端返回的是**数据库行**(几十个字段、命名是 SQL 风格:
|
||
* `starts_at` / `entry_count` / `is_public` / `deleted_at`…),
|
||
* 而端上 UI 要的是**为显示准备过的形状**(`dateRange` / `daysLeft` / `netCent`…)。
|
||
* 这层转换必须在端上做(服务端接口是给"通用 API 用户"的,
|
||
* 不该为了 App 的展示需要去改它)。
|
||
*
|
||
* ⚠️ 每个解析函数都**只依赖实测字段**,没实测过的字段不写。
|
||
* 想看形状就跑:`curl -H 'Authorization: Bearer <token>' .../api/v1/<模块>`
|
||
*/
|
||
|
||
import {
|
||
FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip,
|
||
FbKinGraph, FbKinPerson, FbMark,
|
||
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem,
|
||
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
|
||
} from '../model/Types';
|
||
|
||
// ============================================================
|
||
// 小工具
|
||
// ============================================================
|
||
|
||
/** 安全转字符串(null/undefined → '') */
|
||
export function s(v: string | null | undefined): string {
|
||
return v !== null && v !== undefined ? v : '';
|
||
}
|
||
|
||
/** 安全转数字 */
|
||
export function n(v: number | null | undefined): number {
|
||
return v !== null && v !== undefined ? v : 0;
|
||
}
|
||
|
||
/** 安全取布尔(服务端用 0/1 表示) */
|
||
export function b(v: number | boolean | null | undefined): boolean {
|
||
if (typeof v === 'boolean') {
|
||
return v;
|
||
}
|
||
return n(v as number) !== 0;
|
||
}
|
||
|
||
/** 取数组(不是数组就返回空数组) */
|
||
export function arr<T>(v: T[] | null | undefined): T[] {
|
||
return Array.isArray(v) ? v : [];
|
||
}
|
||
|
||
/** 从 'YYYY-MM-DD HH:mm:ss' 里取日期部分 */
|
||
export function datePart(v: string): string {
|
||
const t: string = s(v);
|
||
const i: number = t.indexOf(' ');
|
||
return i > 0 ? t.substring(0, i) : t;
|
||
}
|
||
|
||
/** 从 'YYYY-MM-DD HH:mm:ss' 里取时间部分 'HH:mm' */
|
||
export function timePart(v: string): string {
|
||
const t: string = s(v);
|
||
const i: number = t.indexOf(' ');
|
||
if (i <= 0) {
|
||
return '';
|
||
}
|
||
return t.substring(i + 1, i + 6);
|
||
}
|
||
|
||
/** 'YYYY-MM-DD' → 'MM-DD' */
|
||
export function monthDay(v: string): string {
|
||
const t: string = datePart(v);
|
||
return t.length >= 10 ? t.substring(5, 10) : t;
|
||
}
|
||
|
||
/** 把金额(分)转成中文金额文案 */
|
||
export function money(cent: number): string {
|
||
const yuan: number = cent / 100;
|
||
const abs: number = Math.abs(yuan);
|
||
const sign: string = yuan < 0 ? '-' : '';
|
||
if (abs >= 10000) {
|
||
return sign + (abs / 10000).toFixed(2) + ' 万';
|
||
}
|
||
return sign + abs.toFixed(2);
|
||
}
|
||
|
||
/**
|
||
* 造一个人物 chip。
|
||
*
|
||
* ⚠️ `FbKinChip` 的字段是 `{id, shown, kinship, avatarColor, isAdmin}` ——
|
||
* **没有** `name` / `avatar`。之前按"想当然"写会直接编译不过。
|
||
* ⇒ 映射前先确认目标类型的真实字段。
|
||
*/
|
||
export function chip(shown: string, color: string, id: number = 0): FbKinChip {
|
||
const c: FbKinChip = {
|
||
id: id,
|
||
shown: s(shown),
|
||
kinship: '',
|
||
avatarColor: s(color) !== '' ? s(color) : '#A84A28',
|
||
isAdmin: false,
|
||
};
|
||
return c;
|
||
}
|
||
|
||
// ============================================================
|
||
// 原始行类型(只列用到的字段)
|
||
// ============================================================
|
||
|
||
interface RawCalEvent {
|
||
cal?: number; color?: string; calname?: string; title?: string;
|
||
date?: string; time?: string; end_time?: string; all_day?: boolean | number;
|
||
kind?: string; ref_id?: number; location?: string; note?: string;
|
||
repeat?: string; occ?: string;
|
||
}
|
||
|
||
interface RawTrip {
|
||
id?: number; title?: string; starts_at?: string; ends_at?: string;
|
||
location?: string | null; note?: string | null; cover_key?: string | null;
|
||
summary?: string | null; is_public?: number; attendee_count?: number;
|
||
node_count?: number; cities?: string[]; city?: string;
|
||
}
|
||
|
||
interface RawDay {
|
||
id?: number; title?: string; kind?: string; repeat_type?: string;
|
||
month?: number; day?: number; is_leap?: number;
|
||
person_id?: number | null; person_name?: string | null;
|
||
source?: string; born?: string | null;
|
||
/** v65:关联人 id 数组(服务端列表/单条都会带) */
|
||
person_ids?: number[] | null;
|
||
/** v65:备注 */
|
||
note?: string | null;
|
||
}
|
||
|
||
interface RawTask {
|
||
id?: number; title?: string; status?: string; board_id?: number; category_id?: number | null;
|
||
note?: string | null; due_at?: string | null; done_at?: string | null;
|
||
assignee_id?: number | null; assignee_name?: string | null;
|
||
avatar?: string | null; avatar_color?: string | null;
|
||
category_name?: string | null; child_total?: number; child_done?: number;
|
||
comment_count?: number; attach_count?: number;
|
||
/** 看板名(`byStatus` 会 JOIN 出来;首页任务列表要用) */
|
||
board_name?: string | null;
|
||
/**
|
||
* 看板**派生色**(服务端算好的,v54 新增)。
|
||
*
|
||
* 🔴 用户 2026-10-08 报过:「首页近期任务,任务的文字和前面的圆点,
|
||
* 颜色并没有跟看板颜色一致」。
|
||
* ⚠️ 不能读 `board_color` —— 看板没设色时它是 `null`,
|
||
* 要走按名字派生的那套(同一个看板名永远同一个色,用户才记得住)。
|
||
*/
|
||
board_color_calc?: string | null;
|
||
}
|
||
|
||
/** 首页日子标记的原始行(`{word, kind}`) */
|
||
interface RawMark {
|
||
word?: string; kind?: string;
|
||
}
|
||
|
||
/** 往年今天的原始行(服务端 `Home::memories()` 给的那几列) */
|
||
interface RawMemory {
|
||
id?: number; title?: string; starts_at?: string; kind?: string;
|
||
years_ago?: number; n?: number;
|
||
}
|
||
|
||
/** `recent_days` 的原始行(服务端 `Anniversary::upcoming()` 的输出形状) */
|
||
interface RawRecentDay {
|
||
id?: number; date?: string; title?: string; kind?: string;
|
||
person_id?: number; source?: string; days?: number;
|
||
age?: number | null; nth?: number | null;
|
||
}
|
||
|
||
interface RawTaskBoard {
|
||
id?: number; name?: string; color?: string; tasks?: RawTask[];
|
||
}
|
||
|
||
interface RawLedgerBook {
|
||
id?: number; name?: string; my_perm?: string | null; entry_count?: number;
|
||
share_perm?: string | null;
|
||
/** v68:服务端补的"是不是我建的"(`my_perm` 只有 read/write,分不出主人) */
|
||
is_owner?: boolean | number | null;
|
||
}
|
||
|
||
interface RawLedgerEntry {
|
||
id?: number; ledger_id?: number; kind?: string; title?: string;
|
||
amount_cents?: number; note?: string | null;
|
||
person_id?: number | null; person_name?: string | null;
|
||
person_nick?: string | null; avatar?: string | null; avatar_color?: string | null;
|
||
updated_at?: string; created_at?: string;
|
||
}
|
||
|
||
interface RawLedgerSummary {
|
||
asset?: number; debt?: number; net?: number; count?: number;
|
||
}
|
||
|
||
interface RawResource {
|
||
id?: number; title?: string; content?: string; owner_id?: number;
|
||
owner_name?: string | null; avatar?: string | null; avatar_color?: string | null;
|
||
owner_nick?: string | null; perm?: string | null;
|
||
created_at?: string; updated_at?: string;
|
||
}
|
||
|
||
interface RawBook {
|
||
id?: number; title?: string; author?: string | null; status?: string | null;
|
||
rating?: number | null; progress?: number | null; note_count?: number | null;
|
||
cover_key?: string | null; category?: string | null; publisher?: string | null;
|
||
/** 共几章(0 = 没解析过章节)。`progress` 是"读到第几章",没有它就没法算百分比 */
|
||
chapter_count?: number | null;
|
||
}
|
||
|
||
interface RawBookNote {
|
||
id?: number; kind?: string | null; content?: string | null;
|
||
chapter?: string | null; loc?: string | null;
|
||
person_name?: string | null; my_nickname?: string | null;
|
||
created_at?: string | null;
|
||
}
|
||
|
||
interface RawNote {
|
||
id?: number; title?: string; is_shared?: number; updated_at?: string;
|
||
author_name?: string | null; avatar?: string | null; avatar_color?: string | null;
|
||
is_mine?: number; blocks?: RawNoteBlock[];
|
||
/** v65:列表里服务端已经算好的摘要 */
|
||
excerpt?: string | null;
|
||
/** v65:标签数组(`Note::attachChildren` 挂上的) */
|
||
tags?: string[] | null;
|
||
}
|
||
|
||
interface RawNoteBlock {
|
||
id?: number; kind?: string; content?: string; position?: number;
|
||
}
|
||
|
||
interface RawNotification {
|
||
id?: number; title?: string; body?: string; created_at?: string;
|
||
read_at?: string | null; actor_name?: string | null;
|
||
avatar?: string | null; avatar_color?: string | null;
|
||
}
|
||
|
||
// ============================================================
|
||
// 各模块解析
|
||
// ============================================================
|
||
|
||
/** 日历条目 → FbCalEvent */
|
||
export function parseCalendar(json: string): FbCalEvent[] {
|
||
return parseCalRows(asArray<RawCalEvent>(json));
|
||
}
|
||
|
||
/** 日历行的实际转换(`parseHome` 也要用,所以单独一个数组版) */
|
||
function parseCalRows(rows: RawCalEvent[]): FbCalEvent[] {
|
||
const out: FbCalEvent[] = [];
|
||
for (const r of rows) {
|
||
const e: FbCalEvent = {
|
||
id: n(r.ref_id),
|
||
// 🔴 v52:补上真实日历本 id / 结束时间 / 类型 / 备注
|
||
// (接口本来就给了,旧解析漏掉 → 新建日程没法指定往哪本写)
|
||
calId: n(r.cal),
|
||
title: s(r.title),
|
||
date: s(r.date),
|
||
time: s(r.time),
|
||
endTime: s(r.end_time),
|
||
calName: s(r.calname),
|
||
color: s(r.color) !== '' ? s(r.color) : '#A84A28',
|
||
allDay: b(r.all_day),
|
||
place: s(r.location),
|
||
note: s(r.note),
|
||
kind: s(r.kind)
|
||
};
|
||
out.push(e);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 旅程列表 → FbTripItem */
|
||
export function parseTrips(json: string): FbTripItem[] {
|
||
const rows: RawTrip[] = asArray<RawTrip>(json);
|
||
const out: FbTripItem[] = [];
|
||
for (const r of rows) {
|
||
const cities: string[] = arr<string>(r.cities);
|
||
const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at));
|
||
const it: FbTripItem = {
|
||
id: n(r.id),
|
||
title: s(r.title),
|
||
// 副标题:城市 + 天数(与 Web 端 trips 列表口径一致)
|
||
sub: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
|
||
dateRange: dateRangeText(s(r.starts_at), s(r.ends_at), dayCount),
|
||
nodeCount: n(r.node_count),
|
||
doneCount: 0,
|
||
/* 题图 / 城市 / 简介 / 同行人数 —— 对齐网页版列表卡(`templates/trips/_list_body.php`)。
|
||
⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */
|
||
coverKey: s(r.cover_key),
|
||
city: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
|
||
summary: s(r.summary),
|
||
attendeeCount: n(r.attendee_count),
|
||
};
|
||
out.push(it);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 纪念日 → FbAnniversaryItem */
|
||
export function parseDays(json: string): FbAnniversaryItem[] {
|
||
const rows: RawDay[] = asArray<RawDay>(json);
|
||
const out: FbAnniversaryItem[] = [];
|
||
const today: Date = new Date();
|
||
for (const r of rows) {
|
||
const m: number = n(r.month);
|
||
const d: number = n(r.day);
|
||
const people: FbKinChip[] = [];
|
||
if (n(r.person_id) > 0) {
|
||
people.push(chip(s(r.person_name), '#A84A28', n(r.person_id)));
|
||
}
|
||
const src: string = s(r.source) !== '' ? s(r.source) : 'custom';
|
||
const it: FbAnniversaryItem = {
|
||
id: n(r.id),
|
||
title: s(r.title),
|
||
date: pad2(m) + '-' + pad2(d),
|
||
daysLeft: daysUntil(today, m, d),
|
||
kind: kindCn(s(r.kind)),
|
||
people: people,
|
||
yearly: s(r.repeat_type) !== 'once',
|
||
source: src,
|
||
month: m,
|
||
day: d,
|
||
repeatType: s(r.repeat_type) !== '' ? s(r.repeat_type) : 'solar',
|
||
/* ⚠️ 服务端 `kind` 是**自由文本**(用户在设置里能自己加分类名)
|
||
⇒ 这个原始值要留着回填,别只留映射后的类别码。 */
|
||
rawKind: s(r.kind),
|
||
note: s(r.note),
|
||
personIds: idList(r.person_ids),
|
||
};
|
||
out.push(it);
|
||
}
|
||
// 按"距今天数"升序(最近的在前)——纪念日列表页的默认排序
|
||
out.sort((a: FbAnniversaryItem, c: FbAnniversaryItem): number => a.daysLeft - c.daysLeft);
|
||
return out;
|
||
}
|
||
|
||
/** 任务(看板)→ 平铺的 FbTaskItem[] */
|
||
export function parseTasks(json: string): FbTaskItem[] {
|
||
const root: Record<string, Object> | null = asObject(json);
|
||
if (root === null) {
|
||
return [];
|
||
}
|
||
const boards: RawTaskBoard[] = arr<RawTaskBoard>(root['boards'] as RawTaskBoard[]);
|
||
const out: FbTaskItem[] = [];
|
||
for (const bd of boards) {
|
||
const tasks: RawTask[] = arr<RawTask>(bd.tasks);
|
||
for (const t of tasks) {
|
||
out.push(taskRow(t, s(bd.name), s(bd.color)));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 一行任务 → `FbTaskItem`(看板视图与首页列表**共用**)。
|
||
*
|
||
* 🔴 抽出来的理由:首页的 `/home` 直接给**扁平的任务数组**(它跨看板按状态取),
|
||
* 而任务页给的是"按看板分组"。两处各自写一遍转换 ⇒ 改一处漏一处
|
||
* (本项目已经吃过好几轮这个亏)。
|
||
*
|
||
* @param boardName 看板名(首页那批的看板名在行里的 `board_name`,由本函数内部兜底取)
|
||
* @param boardColor 看板色原文(可能为空 ⇒ 用派生色)
|
||
*/
|
||
function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem {
|
||
const due: string = s(t.due_at);
|
||
// 看板色优先级:行里算好的派生色 > 入参(看板视图给的)> 兜底
|
||
const calc: string = s(t.board_color_calc);
|
||
const bc: string = calc !== '' ? calc : (boardColor !== '' ? boardColor : '#A8574F');
|
||
const it: FbTaskItem = {
|
||
id: n(t.id),
|
||
title: s(t.title),
|
||
status: s(t.status) !== '' ? s(t.status) : 'todo',
|
||
board: boardName !== '' ? boardName : s(t.board_name),
|
||
boardColor: bc,
|
||
dueText: due !== '' ? datePart(due) : '',
|
||
overdue: due !== '' && s(t.status) !== 'done' && isBeforeToday(due),
|
||
assignee: s(t.assignee_name),
|
||
childDone: n(t.child_done),
|
||
childTotal: n(t.child_total),
|
||
/* v65:新建/编辑/搬看板要用到的三个**原始**字段(原来只派生了显示文案) */
|
||
boardId: n(t.board_id),
|
||
note: s(t.note),
|
||
dueAt: due,
|
||
};
|
||
return it;
|
||
}
|
||
|
||
/** 账本列表 → FbLedgerBook[](netCent 由 /ledger/<id> 的 summary 补) */
|
||
export function parseLedger(json: string): FbLedgerBook[] {
|
||
const rows: RawLedgerBook[] = asArray<RawLedgerBook>(json);
|
||
const out: FbLedgerBook[] = [];
|
||
for (const r of rows) {
|
||
const bk: FbLedgerBook = {
|
||
id: n(r.id),
|
||
name: s(r.name),
|
||
netCent: 0, // 概览接口不返回净资产,进详情页时再取 summary
|
||
myPerm: s(r.my_perm) !== '' ? s(r.my_perm) : 'read',
|
||
isOwner: b(r.is_owner),
|
||
entryCount: n(r.entry_count),
|
||
};
|
||
out.push(bk);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 账本明细 → { 账目, 净资产 }。
|
||
*
|
||
* ⚠️ 明细要走**两级接口**:`/ledger`(有哪些本)→ `/ledger/<id>/entries`(本里的账目 + 汇总)。
|
||
* 净资产(`summary.net`)只在这第二级里有 —— 第一级不返回。
|
||
*/
|
||
export function parseLedgerEntries(json: string): FbLedgerDetail {
|
||
const root: Record<string, Object> | null = asObject(json);
|
||
const empty: FbLedgerDetail = { entries: [], netCent: 0, assetCent: 0, debtCent: 0 };
|
||
if (root === null) {
|
||
return empty;
|
||
}
|
||
const rows: RawLedgerEntry[] = arr<RawLedgerEntry>(root['items'] as RawLedgerEntry[]);
|
||
const sm: RawLedgerSummary = (root['summary'] !== undefined && root['summary'] !== null)
|
||
? root['summary'] as RawLedgerSummary : {};
|
||
const entries: FbLedgerEntry[] = [];
|
||
for (const r of rows) {
|
||
entries.push({
|
||
id: n(r.id),
|
||
name: s(r.title),
|
||
kind: s(r.kind),
|
||
amountCent: n(r.amount_cents),
|
||
// 「挂名的人」优先用昵称(我习惯叫他什么),没有就用本名
|
||
ownerName: s(r.person_nick) !== '' ? s(r.person_nick) : s(r.person_name),
|
||
note: s(r.note),
|
||
updatedAt: datePart(s(r.updated_at)),
|
||
});
|
||
}
|
||
const d: FbLedgerDetail = {
|
||
entries: entries,
|
||
netCent: n(sm.net),
|
||
assetCent: n(sm.asset),
|
||
debtCent: n(sm.debt),
|
||
};
|
||
return d;
|
||
}
|
||
|
||
/**
|
||
* 家谱 → FbKinGraph。
|
||
*
|
||
* ⚠️ 服务端给的关系是**数组**(`parents`/`children`/`spouses`/`siblings`
|
||
* 全是 id 数组),端上直接用 —— 不重新推导。
|
||
* ⚠️ 亲属称谓(`label`)也**由服务端算**(`Kinship::label`),
|
||
* 端上**不重算**:那依赖整个家族图谱,两边算法不一致就会出现
|
||
* "App 说堂兄、网页说表兄"。
|
||
*/
|
||
export function parseKin(json: string): FbKinGraph {
|
||
const root: Record<string, Object> | null = asObject(json);
|
||
const empty: FbKinGraph = { meId: 0, people: [], total: 0 };
|
||
if (root === null) {
|
||
return empty;
|
||
}
|
||
const rows: Record<string, Object>[] =
|
||
arr<Record<string, Object>>(root['people'] as Record<string, Object>[]);
|
||
const people: FbKinPerson[] = [];
|
||
for (const r of rows) {
|
||
people.push({
|
||
id: n(r['id'] as number),
|
||
name: s(r['name'] as string),
|
||
shown: s(r['shown'] as string) !== '' ? s(r['shown'] as string) : s(r['name'] as string),
|
||
label: s(r['label'] as string),
|
||
gen: n(r['gen'] as number),
|
||
avatarColor: s(r['avatar_color'] as string),
|
||
// v73:家谱卡片要显示头像(服务端一直返回,以前画圆点没解析)
|
||
avatar: s(r['avatar'] as string),
|
||
/* 🔴 v73:**不能**用 `n()` —— 它把 null/undefined 折成 0,
|
||
而服务端的户号 `unit_of` 是**从 0 开始**编号的(户 0 是真实的一户)。
|
||
⇒ 用 `n()` 会让"字段缺失"与"第 0 户"变成同一个值,
|
||
布局层就无法区分,线上实测表现为「我与妻子被拆成两户」。
|
||
这里统一成 -1 表示"没有户号",布局层只判 `>= 0`。
|
||
⚠️ 别改回 `n(...)`,也别在布局层用 `> 0`。 */
|
||
unit: r['unit'] !== null && r['unit'] !== undefined ? n(r['unit'] as number) : -1,
|
||
// ⚠️ 从 `Record<string, Object>` 里取值必须**显式断言**成目标类型 ——
|
||
// ArkTS 不会自动把 `Object` 收窄成 `number[]`(编译期报
|
||
// "Argument of type 'Object' is not assignable to 'number[]'")。
|
||
parents: idList(r['parents'] as number[]),
|
||
children: idList(r['children'] as number[]),
|
||
spouses: idList(r['spouses'] as number[]),
|
||
siblings: idList(r['siblings'] as number[]),
|
||
});
|
||
}
|
||
const g: FbKinGraph = {
|
||
meId: n(root['meId'] as number),
|
||
people: people,
|
||
total: n(root['total'] as number),
|
||
};
|
||
return g;
|
||
}
|
||
|
||
/** id 数组的容错转换(服务端可能给 null) */
|
||
function idList(v: number[] | null | undefined): number[] {
|
||
const xs: number[] = arr<number>(v);
|
||
const out: number[] = [];
|
||
for (const x of xs) {
|
||
out.push(n(x));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 资源 → FbResourceItem[] */
|
||
export function parseResources(json: string): FbResourceItem[] {
|
||
const rows: RawResource[] = asArray<RawResource>(json);
|
||
const out: FbResourceItem[] = [];
|
||
for (const r of rows) {
|
||
/* ⚠️ 服务端 `resources` 表**没有 category 列**(见 `app/Schema.php`)——
|
||
只有 title / content / owner_id。所以端上按「归属人」分组/筛选,
|
||
不按"证件/资料"那种并不存在的类别(v65 之前那两个胶囊永远筛出空列表)。 */
|
||
const perm: string = s(r.perm);
|
||
const it: FbResourceItem = {
|
||
id: n(r.id),
|
||
name: s(r.title),
|
||
ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name),
|
||
ownerId: n(r.owner_id),
|
||
perm: perm !== '' ? perm : 'read',
|
||
content: s(r.content),
|
||
shared: perm !== 'owner',
|
||
updatedAt: datePart(s(r.updated_at)),
|
||
};
|
||
out.push(it);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 藏书 → FbBookItem[] */
|
||
export function parseBooks(json: string): FbBookItem[] {
|
||
const rows: RawBook[] = asArray<RawBook>(json);
|
||
const out: FbBookItem[] = [];
|
||
for (const r of rows) {
|
||
/* 🔴🔴 v67:回落值必须是 **`wish`**(服务端枚举),原来写的 `want` 服务端不认
|
||
⇒ `Book::status()` 把不认识的静默改成 `wish`,而端上分组只认 `want`
|
||
⇒ "想读"的书在书架里一本都看不见。 */
|
||
const st: string = s(r.status) !== '' ? s(r.status) : 'wish';
|
||
const chapters: number = n(r.chapter_count);
|
||
const cur: number = n(r.progress);
|
||
const it: FbBookItem = {
|
||
id: n(r.id),
|
||
title: s(r.title),
|
||
author: s(r.author),
|
||
status: st,
|
||
/* 🔴 服务端 `rating` 是 0–10(`Book::rating()` 校验 `0 <= n <= 10`),
|
||
端上 `FbRating` 的 max 是 5 ⇒ 必须折半,否则 8 分显示成 `8.0/5`。 */
|
||
rate: clamp(n(r.rating) / 2, 0, 5),
|
||
noteCount: n(r.note_count),
|
||
/* 🔴 `progress` 是**章序号**不是百分比(`Book::setProgress` 注释:读到第几章)
|
||
⇒ 有 `chapter_count` 才能换算;没解析过章节(=0)就只能是 0,
|
||
不要拿章序号冒充百分比(读到第 30 章 ≠ 30%)。 */
|
||
progress: st === 'done' ? 100
|
||
: (chapters > 0 ? clamp(cur * 100 / chapters, 0, 100) : 0),
|
||
curChapter: cur,
|
||
chapters: chapters,
|
||
};
|
||
out.push(it);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 读书笔记 → FbBookNoteItem[] */
|
||
export function parseBookNotes(json: string): FbBookNoteItem[] {
|
||
const rows: RawBookNote[] = asArray<RawBookNote>(json);
|
||
const out: FbBookNoteItem[] = [];
|
||
for (const r of rows) {
|
||
const nick: string = s(r.my_nickname);
|
||
out.push({
|
||
id: n(r.id),
|
||
kind: s(r.kind) !== '' ? s(r.kind) : 'note',
|
||
content: s(r.content),
|
||
chapter: s(r.chapter),
|
||
loc: s(r.loc),
|
||
/* 我称呼这个人的名字优先(`nicknames` 二元组),没有再退回他的本名 */
|
||
personName: nick !== '' ? nick : s(r.person_name),
|
||
createdAt: s(r.created_at).substring(0, 10),
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 笔记 → FbNoteItem[] */
|
||
export function parseNotes(json: string): FbNoteItem[] {
|
||
const root: Record<string, Object> | null = asObject(json);
|
||
if (root === null) {
|
||
return [];
|
||
}
|
||
const rows: RawNote[] = arr<RawNote>(root['items'] as RawNote[]);
|
||
const out: FbNoteItem[] = [];
|
||
for (const r of rows) {
|
||
/* ⚠️ 服务端列表已带 `blocks` / `tags` / `excerpt`(`Note::attachChildren`),
|
||
这里把它们都取出来 —— 原来只拿 blocks 算了个摘要,正文和标签全丢了。 */
|
||
const body: string = noteBodyOf(r.blocks);
|
||
const tags: string[] = [];
|
||
for (const t of arr<string>(r.tags)) {
|
||
tags.push(s(t));
|
||
}
|
||
const exc: string = s(r.excerpt);
|
||
out.push({
|
||
id: n(r.id),
|
||
title: s(r.title),
|
||
excerpt: exc !== '' ? exc : body,
|
||
tags: tags,
|
||
updatedAt: datePart(s(r.updated_at)),
|
||
shared: n(r.is_shared) !== 0,
|
||
attachCount: 0,
|
||
body: body,
|
||
mine: n(r.is_mine) !== 0,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* `note_blocks` → 一段纯文本。
|
||
*
|
||
* ⚠️ 图片块的内容是**附件 key**(不是正文),拼进来会变成一串乱码 key
|
||
* ⇒ 记成「[图片]」,用户一眼知道那里有张图。
|
||
*/
|
||
function noteBodyOf(blocks: RawNoteBlock[] | undefined): string {
|
||
const rows: RawNoteBlock[] = arr<RawNoteBlock>(blocks);
|
||
const parts: string[] = [];
|
||
for (const b of rows) {
|
||
const kind: string = s(b.kind);
|
||
if (kind === 'image') {
|
||
parts.push('[图片]');
|
||
continue;
|
||
}
|
||
const txt: string = s(b.content).trim();
|
||
if (txt !== '') {
|
||
parts.push(txt);
|
||
}
|
||
}
|
||
return parts.join('\n\n');
|
||
}
|
||
|
||
/** 通知 → FbNotificationItem[] */
|
||
export function parseNotifications(json: string): FbNotificationItem[] {
|
||
const root: Record<string, Object> | null = asObject(json);
|
||
if (root === null) {
|
||
return [];
|
||
}
|
||
const rows: RawNotification[] = arr<RawNotification>(root['items'] as RawNotification[]);
|
||
const out: FbNotificationItem[] = [];
|
||
for (const r of rows) {
|
||
out.push({
|
||
id: n(r.id),
|
||
title: s(r.title),
|
||
body: s(r.body),
|
||
timeText: relativeTime(s(r.created_at)),
|
||
unread: s(r.read_at) === '',
|
||
from: chip(s(r.actor_name), s(r.avatar_color)),
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 首页 → FbHomeData */
|
||
/**
|
||
* 首页 → FbHomeData(v54:服务端补齐了农历/标记/天气/周统计)。
|
||
*
|
||
* ⚠️ **不允许**再回落到端上的演示常量 —— 用户 2026-10-16 报过
|
||
* 「寒露、重阳节、儿子的生日,这些都是乱写的」,
|
||
* 根因就是那些字段接口没给、端上拿 DemoData 顶着。
|
||
* 接口没给时应当是**空**(页面就不画那一块),而不是编一个出来。
|
||
*/
|
||
export function parseHome(json: string): FbHomeData {
|
||
const root: Record<string, Object> | null = asObject(json);
|
||
const empty: FbHomeData = {
|
||
greeting: '', date: '', weekday: '', todayLabel: '', lunarShort: '', lunarLine: '',
|
||
marks: [], weather: null,
|
||
stat: { todayEvents: 0, doing: 0, todo: 0, overdue: 0 },
|
||
memories: [], recentDays: [], meAvatar: '',
|
||
todayEvents: [], tasks: [], unread: 0,
|
||
};
|
||
if (root === null) {
|
||
return empty;
|
||
}
|
||
// ⚠️ ArkTS **不能**从 `unknown[]` 反推泛型(arkts-no-inferred-generic-params)
|
||
// ⇒ 一律显式写类型参数,别靠推断。
|
||
// ⚠️ 这里直接把原始数组喂给 parseCalendar 的"数组版",
|
||
// 比 `JSON.stringify` 再 parse 一次更快、也更不容易出错。
|
||
const rawEvents: RawCalEvent[] = arr<RawCalEvent>(root['today_events'] as RawCalEvent[]);
|
||
const rawTasks: RawTask[] = arr<RawTask>(root['tasks'] as RawTask[]);
|
||
const d: FbHomeData = {
|
||
greeting: s(root['greeting'] as string),
|
||
date: s(root['date'] as string),
|
||
weekday: s(root['weekday'] as string),
|
||
todayLabel: s(root['today_label'] as string),
|
||
lunarShort: s(root['lunar_short'] as string),
|
||
lunarLine: s(root['lunar_line'] as string),
|
||
marks: parseMarks(root['marks']),
|
||
weather: parseWeather(root['weather']),
|
||
stat: parseStat(root['stat']),
|
||
memories: parseMemories(root['memories']),
|
||
recentDays: parseRecentDays(root['recent_days']),
|
||
meAvatar: s(root['me_avatar'] as string),
|
||
todayEvents: parseCalRows(rawEvents),
|
||
tasks: parseTaskRows(rawTasks),
|
||
unread: n(root['unread'] as number),
|
||
};
|
||
return d;
|
||
}
|
||
|
||
/** 日子标记 `[{word, kind}]`(坏行直接丢,不让它污染页面) */
|
||
export function parseMarks(v: Object | null | undefined): FbMark[] {
|
||
const rows: RawMark[] = arr<RawMark>(v as RawMark[]);
|
||
const out: FbMark[] = [];
|
||
for (const r of rows) {
|
||
const word: string = s(r.word);
|
||
if (word === '') {
|
||
continue;
|
||
}
|
||
const mk: FbMark = { word: word, kind: s(r.kind) !== '' ? s(r.kind) : 'anniv' };
|
||
out.push(mk);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 天气:服务端没配地点时给 `null` ⇒ 端上**整块不画**(与网页版一致)。
|
||
* ⚠️ `temp` 服务端已经带单位(`21°C`),原样承接,端上不再拼。
|
||
*/
|
||
export function parseWeather(v: Object | null | undefined): FbWeather | null {
|
||
if (v === null || v === undefined) {
|
||
return null;
|
||
}
|
||
const o: Record<string, Object> | null = asObjectOf(v);
|
||
if (o === null) {
|
||
return null;
|
||
}
|
||
const w: FbWeather = {
|
||
place: s(o['place'] as string),
|
||
temp: s(o['temp'] as string),
|
||
text: s(o['text'] as string),
|
||
icon: s(o['icon'] as string),
|
||
tone: s(o['tone'] as string) !== '' ? s(o['tone'] as string) : 'cloud',
|
||
wind: s(o['wind'] as string),
|
||
wdir: s(o['wdir'] as string),
|
||
tmax: s(o['tmax'] as string),
|
||
tmin: s(o['tmin'] as string),
|
||
};
|
||
// 温度都没有 ⇒ 当没取到(别画一个空壳)
|
||
return w.temp !== '' ? w : null;
|
||
}
|
||
|
||
/** 概述卡底部的四个数字(与网页版 `hb-stats` 同源) */
|
||
export function parseStat(v: Object | null | undefined): FbStat {
|
||
const o: Record<string, Object> | null = asObjectOf(v);
|
||
if (o === null) {
|
||
return { todayEvents: 0, doing: 0, todo: 0, overdue: 0 };
|
||
}
|
||
return {
|
||
todayEvents: n(o['today_events'] as number),
|
||
doing: n(o['doing'] as number),
|
||
todo: n(o['todo'] as number),
|
||
overdue: n(o['overdue'] as number),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 往年的今天 → `FbMemoryItem[]`(与电脑端 `Home::memories()` 同一份数据)。
|
||
*
|
||
* ⚠️ 字段是 SQL 风格(`starts_at` / `years_ago`),端上要驼峰 —— 所以有这一层。
|
||
*/
|
||
/**
|
||
* 🔴 v59(用户 2026-10-18):`recent_days` → 首页「最近纪念日」。
|
||
*
|
||
* ⚠️ `age` / `nth` 在服务端是 **null 或数字**(不是 0)——`n()` 会把 null 变成 0,
|
||
* 而"没有周岁"和"0 岁"是两件事(0 岁的宝宝确实存在)⇒ 这里显式保留 -1
|
||
* 表示"没有这个信息",端上据此**不显示**那一段。
|
||
* ⚠️ **不再排序**:服务端已按"最近的在前"排好,端上再排一遍就会出现
|
||
* "两端顺序不一致"那类问题(本项目在往年的今天上踩过一次)。
|
||
*/
|
||
export function parseRecentDays(v: Object | null | undefined): FbRecentDay[] {
|
||
const rows: RawRecentDay[] = arr<RawRecentDay>(v as RawRecentDay[]);
|
||
const out: FbRecentDay[] = [];
|
||
for (const r of rows) {
|
||
const title: string = s(r.title);
|
||
if (title === '' || s(r.date) === '') {
|
||
continue;
|
||
}
|
||
const it: FbRecentDay = {
|
||
id: n(r.id),
|
||
date: s(r.date),
|
||
title: title,
|
||
kind: s(r.kind),
|
||
personId: n(r.person_id),
|
||
source: s(r.source) !== '' ? s(r.source) : 'custom',
|
||
days: n(r.days),
|
||
age: r.age !== null && r.age !== undefined ? n(r.age) : -1,
|
||
nth: r.nth !== null && r.nth !== undefined ? n(r.nth) : -1,
|
||
};
|
||
out.push(it);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function parseMemories(v: Object | null | undefined): FbMemoryItem[] {
|
||
const rows: RawMemory[] = arr<RawMemory>(v as RawMemory[]);
|
||
const out: FbMemoryItem[] = [];
|
||
for (const r of rows) {
|
||
const title: string = s(r.title);
|
||
if (title === '') {
|
||
continue;
|
||
}
|
||
const it: FbMemoryItem = {
|
||
id: n(r.id),
|
||
title: title,
|
||
startsAt: s(r.starts_at),
|
||
kind: s(r.kind) !== '' ? s(r.kind) : 'movie',
|
||
yearsAgo: n(r.years_ago),
|
||
};
|
||
out.push(it);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 一批任务行 → FbTaskItem[](首页与任务页共用同一段形状转换) */
|
||
export function parseTaskRows(rows: RawTask[]): FbTaskItem[] {
|
||
const out: FbTaskItem[] = [];
|
||
for (const t of rows) {
|
||
out.push(taskRow(t, '', ''));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ============================================================
|
||
// 内部工具
|
||
// ============================================================
|
||
|
||
/** 解析成数组(失败给空数组) */
|
||
function asArray<T>(json: string): T[] {
|
||
if (json === '') {
|
||
return [];
|
||
}
|
||
try {
|
||
const v: Object = JSON.parse(json) as Object;
|
||
// 服务端信封是 {ok, data};但仓库已经把 data 传出来,这里两种都认
|
||
if (Array.isArray(v)) {
|
||
return v as T[];
|
||
}
|
||
const data = (v as Record<string, Object>)['data'];
|
||
if (Array.isArray(data)) {
|
||
return data as T[];
|
||
}
|
||
return [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把一个**已经是对象**的值转成 `Record<string, Object>`(不是对象则 null)。
|
||
*
|
||
* ⚠️ 与 `asObject()` 的区别:那个吃的是**整份 JSON 字符串**、还要往下钻 `data` 一层;
|
||
* 这个吃的是"已经从根对象里取出来的某个字段值"(如 `root['weather']`)。
|
||
* ArkTS 里 `as` 是编译期的,运行时 `null`/数组/标量 都能骗过它 ——
|
||
* 所以必须自己判一遍类型,别直接 `v as Record<...>`(那样标量上取键会拿到 undefined,
|
||
* 然后被当成"字段是空的",症状是"接口明明给了却显示为空")。
|
||
*/
|
||
function asObjectOf(v: Object | null | undefined): Record<string, Object> | null {
|
||
if (v === null || v === undefined) {
|
||
return null;
|
||
}
|
||
if (typeof v !== 'object' || Array.isArray(v)) {
|
||
return null;
|
||
}
|
||
return v as Record<string, Object>;
|
||
}
|
||
|
||
function asObject(json: string): Record<string, Object> | null {
|
||
if (json === '') {
|
||
return null;
|
||
}
|
||
try {
|
||
const v: Object = JSON.parse(json) as Object;
|
||
if (v === null || !Array.isArray(v) && typeof v === 'object') {
|
||
const o = v as Record<string, Object>;
|
||
const data = o['data'];
|
||
if (data !== undefined && data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
||
return data as Record<string, Object>;
|
||
}
|
||
return o;
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 两位补零 */
|
||
function pad2(v: number): string {
|
||
return v < 10 ? '0' + v.toString() : v.toString();
|
||
}
|
||
|
||
/** 夹紧 */
|
||
function clamp(v: number, lo: number, hi: number): number {
|
||
if (v < lo) {
|
||
return lo;
|
||
}
|
||
if (v > hi) {
|
||
return hi;
|
||
}
|
||
return v;
|
||
}
|
||
|
||
/** 两个时间之间差几天(同一天算 1 天) */
|
||
function daySpan(a: string, c: string): number {
|
||
const da: string = datePart(a);
|
||
const dc: string = datePart(c);
|
||
if (da === '' || dc === '') {
|
||
return 0;
|
||
}
|
||
const t1: number = Date.parse(da + 'T00:00:00');
|
||
const t2: number = Date.parse(dc + 'T00:00:00');
|
||
if (isNaN(t1) || isNaN(t2)) {
|
||
return 0;
|
||
}
|
||
return Math.max(1, Math.round((t2 - t1) / 86400000) + 1);
|
||
}
|
||
|
||
/** 时间范围文案:同一天显示 'M月D日';跨天显示 'M月D日 - M月D日' */
|
||
function dateRangeText(a: string, c: string, days: number): string {
|
||
const da: string = datePart(a);
|
||
const dc: string = datePart(c);
|
||
if (da === '') {
|
||
return '';
|
||
}
|
||
const ta: string = cnDate(da);
|
||
if (dc === '' || da === dc) {
|
||
return ta + (days > 1 ? ' · ' + days.toString() + ' 天' : '');
|
||
}
|
||
return ta + ' - ' + cnDate(dc) + ' · ' + days.toString() + ' 天';
|
||
}
|
||
|
||
/** 'YYYY-MM-DD' → 'M月D日' */
|
||
function cnDate(v: string): string {
|
||
if (v.length < 10) {
|
||
return v;
|
||
}
|
||
const m: number = parseInt(v.substring(5, 7), 10);
|
||
const d: number = parseInt(v.substring(8, 10), 10);
|
||
return m.toString() + '月' + d.toString() + '日';
|
||
}
|
||
|
||
/** 距下一个 (month, day) 还有几天(0 = 今天) */
|
||
function daysUntil(today: Date, month: number, day: number): number {
|
||
if (month <= 0 || day <= 0) {
|
||
return 9999;
|
||
}
|
||
const y: number = today.getFullYear();
|
||
let target: number = Date.UTC(y, month - 1, day);
|
||
const base: number = Date.UTC(y, today.getMonth(), today.getDate());
|
||
if (target < base) {
|
||
target = Date.UTC(y + 1, month - 1, day); // 今年已过 ⇒ 看明年
|
||
}
|
||
return Math.round((target - base) / 86400000);
|
||
}
|
||
|
||
/** 是否早于今天 */
|
||
function isBeforeToday(v: string): boolean {
|
||
const d: string = datePart(v);
|
||
if (d === '') {
|
||
return false;
|
||
}
|
||
const now: Date = new Date();
|
||
const today: string = now.getFullYear().toString() + '-'
|
||
+ pad2(now.getMonth() + 1) + '-' + pad2(now.getDate());
|
||
return d < today;
|
||
}
|
||
|
||
/** 相对时间文案 */
|
||
function relativeTime(v: string): string {
|
||
const t: number = Date.parse(s(v).replace(' ', 'T'));
|
||
if (isNaN(t)) {
|
||
return datePart(s(v));
|
||
}
|
||
const diff: number = Date.now() - t;
|
||
const min: number = Math.floor(diff / 60000);
|
||
if (min < 1) {
|
||
return '刚刚';
|
||
}
|
||
if (min < 60) {
|
||
return min.toString() + ' 分钟前';
|
||
}
|
||
const hr: number = Math.floor(min / 60);
|
||
if (hr < 24) {
|
||
return hr.toString() + ' 小时前';
|
||
}
|
||
const day: number = Math.floor(hr / 24);
|
||
if (day < 30) {
|
||
return day.toString() + ' 天前';
|
||
}
|
||
return datePart(s(v));
|
||
}
|
||
|
||
/** 纪念日类别中文 */
|
||
function kindCn(kind: string): string {
|
||
if (kind === '生日') {
|
||
return 'birthday';
|
||
}
|
||
if (kind === '节日') {
|
||
return 'holiday';
|
||
}
|
||
if (kind === '节气') {
|
||
return 'solar_term';
|
||
}
|
||
if (kind === '纪念日') {
|
||
return 'anniversary';
|
||
}
|
||
return kind;
|
||
}
|
||
|
||
/** 笔记第一条文本块的正文 */
|
||
function firstTextOf(blocks?: RawNoteBlock[]): string {
|
||
const bs: RawNoteBlock[] = arr<RawNoteBlock>(blocks);
|
||
for (const bl of bs) {
|
||
const c: string = s(bl.content).trim();
|
||
if (c !== '') {
|
||
return c.length > 60 ? c.substring(0, 60) + '…' : c;
|
||
}
|
||
}
|
||
return '';
|
||
} |