Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbParse.ets
T
laoyang f020a671c0 v0.8.1 界面六条:删离线入口 / 标语统一 / 最近纪念日 / 图标真上色 / 真头像 / 日程四修
用户 2026-10-18 六条(全部已在真机逐条验证):

① 登录页「先不登录 / 离线使用」彻底删除
   用户:「把那些不登录、看离线数据之类的提示全部去掉。以后就不再拥有这样的
   功能了,把这些功能也全部删除。」
   ⇒ 两个按钮 + onSkip + allowSkip 参数 + Index 里的 skip 分支一起删净。
   这是产品决定(不是隐藏):该路径本身就是安全缺口,v58 上了整库加密后更要堵死。

② 标语统一为「一家人用岁月写成的一本书」
   用户:「这也是我们"家书"这个名字的由来。所以你不要再乱换这个标语了,
   只要是想出现标语或者副标题的地方,就用这样一句话。」
   · 端上 FB_DEFAULT_SLOGAN 是唯一定义处(登录页副标题 + 抽屉回落都用它);
   · 抽屉左上角原来是家庭名,现在显示**标语**;
   · 三层优先级(个人 slogan → 家庭 motto → 应用默认口号)**在服务端算**
     (Family::slogan,与网页端首页 heroSlogan 同一套),/login 与 /me 都返回。
   用户还说:「如果这个用户自己有标语……这个地方就是用户自己自定义的标语。」
   真机实测:登录者杨永全自己写了「修身,齐家」⇒ 抽屉就显示他那句。

③ 首页新增「最近纪念日」(放在近期任务之后、往年的今天之前)
   用户:「未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。
   以倒计时形式展示:距离哪个纪念日还有多少天,距离谁谁谁的生日还有多少天。」
   · /home.recent_days = Anniversary::upcoming(fam,pid,370) 取前 3 ——
     取满一年:窗口太小会让"刚过完生日"的家庭一个都取不到,模块直接空着;
   · 数据源 all() = 派生生日 + 自建纪念日,正好覆盖用户说的两样;
   · 倒计时:0 天显示「就是今天」(写「还有 0 天」读起来像已经过了)。

④ 悬浮栏图标也上色(用户:「除了下面的汉字,上面图标的颜色也变成跟汉字一样的颜色」)
   🔴🔴 真根因两层,都是"无报错失效":
   · 第一层:Image.fillColor() **只对 SVG 生效**,而本工程 30+ 图标**全是 PNG**
     ⇒ 全工程 24 处图标染色一直是空操作(文字走 fontColor 所以只有文字变了)。
   · 第二层(真机才现形):改用 colorFilter 后,矩阵写成了"系数乘 RGB"
     (R' = R_原 × 系数) —— 对白色图标成立,但本工程图标是**纯黑**,
     黑 × 任何系数 = 仍是黑 ⇒ 真机上图标像素依旧是 (0,0,0)。
   ⇒ 最终形态:`fbTint` 用 **alpha 当载体**(R' = (r/255) × A_原):
     系数是归一化约定(官方灰度示例系数和为 1,量纲确定);
     形状完全由原图 alpha 决定 ⇒ 黑/白图标都成立,描边抗锯齿自然过渡。
   真机像素实测:日程项图标与文字同为 (176,120,64)≈#B0763A;
   未选中项图标 (44,39,35) 深灰(不再是纯黑)。
   ⚠️ 全工程 24 处 fillColor 一并改掉;FbIconButton.tint 类型改成 string,
      让"误传 Resource"变成编译错误而不是又一个静默失效。

⑤ 顶栏:真实头像 + 拉开按钮间距
   用户:「右上角的头像应该是真实的……现在提醒的按钮距离头像的按钮太近了。」
   · /home 给 me_avatar、/login 给 avatar_url(avatar_url() 按线上存储驱动拼好);
   · 三颗按钮(同步 / 铃铛 / 头像)统一用 FbMetric.gapTopActions = 8。

⑥ 日程四处
   · 「日期下面有几个点」⇒ 月视图与周视图的圆点全部删除,
     连 FbDot / dots() / count / dotColor 一起清(不留死代码);
   · 「农历全部都是初一」⇒ 真根因是端上 lunarOf() **硬编码 return '初一'**,
     而服务端 lunarRange() 本来就每天给真实农历(title)⇒ 改取 title;
   · 「列表这个视图,今天的数据缺一条」⇒ 服务端 /calendar 的 from/to **默认只给本月**,
     而列表视图要显示 -7 ~ +60 天 ⇒ 端上显式传范围
     (loadModule 新增 pathOverride 参数);
   · 「点击了某一天之后并不明显,应该把这一天的颜色变深,而今天用一个较浅的颜色」
     ⇒ 照 SyncCalendar:选中 = 深色实底白字、今天 = 浅色底。
       真机实测:17 号(选中)bg=#FFB0763A,18 号(今天)bg=#FFF5F0EA。

另:FbKeyStore 补上一个真机抓到的 HUKS 坑 —— isKeyItemExist 在密钥不存在时
**会抛 12000011**(不是返回 false),首次运行必被误判成"本机不支持 HUKS"
⇒ 永久明文降级。改成把"不存在"当正常分支,且降级态每次启动重试一次
(把同一把 DEK 重新用 KEK 包起来,不换密钥以免重下全部附件)。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 328/0;
_fb_v59ui_falsify.py 19 项注入 23/0;_fb_home_api.py 60/0;
六条已在真机逐条取像素/布局树确认。
2026-10-18 20:14:40 +08:00

906 lines
30 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, 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;
}
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;
}
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;
}
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[];
}
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,
};
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 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',
};
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),
};
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',
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),
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),
unit: n(r['unit'] as number),
// ⚠️ 从 `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) {
const it: FbResourceItem = {
id: n(r.id),
name: s(r.title),
// 资源没有"类别"字段(服务端的 content 是自由文本),
// ⇒ 用「谁的」来分类更贴近实际用法(Web 端列表也是按归属分组)
kind: 'other',
ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name),
fileCount: 0,
shared: s(r.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) {
const st: string = s(r.status) !== '' ? s(r.status) : 'want';
const it: FbBookItem = {
id: n(r.id),
title: s(r.title),
author: s(r.author),
status: st,
rate: n(r.rating),
noteCount: n(r.note_count),
// 服务端 progress 记的是"读到第几页"还是"百分比"不确定
// ⇒ 只信 status:读完就是 100,否则按 progress 原样(0~100 之外夹紧)
progress: st === 'done' ? 100 : clamp(n(r.progress), 0, 100),
};
out.push(it);
}
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) {
out.push({
id: n(r.id),
title: s(r.title),
excerpt: firstTextOf(r.blocks),
tags: [],
updatedAt: datePart(s(r.updated_at)),
shared: n(r.is_shared) !== 0,
attachCount: 0,
});
}
return out;
}
/** 通知 → 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 '';
}