Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbParse.ets
T
laoyang 13f716c936 v73:家谱自绘重做(Canvas 画线 + 原生卡片),去掉列表只留图谱
用户原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」

改法:不再全 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
2026-10-21 22:18:43 +08:00

1022 lines
35 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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 '';
}