用户 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;
六条已在真机逐条取像素/布局树确认。
906 lines
30 KiB
Plaintext
906 lines
30 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, 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 '';
|
||
} |