Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbParse.ets
T
laoyang 25a0797a74 v79 端上:任务面板(首页/任务页共用)+ 任务模块功能补齐
用户原话:「鸿蒙端的功能,也需要同样适配,在首页上可以直接修改编辑任务,
另外任务模块的功能需要完善。」

端上原来只有"看列表 / 勾完成 / 新建改标题",网页 v78 那套「就地处理」
(改状态、挪分组、子任务、附件、留言、删除)一件都没有;首页点任务
弹的还是 v73 的只读卡。

一、新增 FbTaskPanel(首页与任务页共用同一组件)
  · 改状态三段(待办/进行中/已完成)+ 标题左侧状态圈
  · 挪看板 chips、勾/加子任务、加图片附件并删、发留言
  · 编辑 → 交给宿主开 FbTaskEditSheet;删除要**点两次**(端上没有 undo)
  · 每个动作成功后再 getTask() 重拉一次:一次动作会牵动子任务计数、
    附件张数、看板色,逐处同步 DOM 必然漏一处

二、退役 FbTaskDetailSheet(v73 只读卡,387 行)
  ⚠️ 它已无人挂载,而守卫还在逐条"通过" —— 等于给死代码盖章。

三、任务模块补齐
  · 卡片点击 → 面板(不再直接进编辑表单:误触一下就开始改标题,
    且子任务/附件/留言没地方放)
  · 「只看已完成」改真打 ?view=done:看板视图服务端就写死
    status <> 'done',在客户端筛永远是空
  · 看板列表改走 ?view=boards:原来从任务反推 ⇒ 空看板永远选不到
  · 编辑表单补分类/循环两排

四、顺带修掉一个自查发现的真 bug
  任务页里 sheetCount 有**两个** @Monitor 都监听 boardSheetOpen、都写同一个值
  ⇒ 关掉设置浮层时谁最后执行不确定,会把还开着的面板"顺便让位结束",
  底部悬浮栏浮上来盖住面板按钮。已并成一个取并集的 monitor。

服务端(另一仓):新增 GET /tasks?view=boards、POST/DELETE /tasks/<id>/attach,
详情补 board_name/board_color_calc,越权删别条任务的附件 → 404。

验证:静态守卫 1055/0(新增 v79 一组,含"全页只许一个 sheetCount 赋值点")、
反向验证 215/0(新增 15 条注入,逐条证明新断言会红)、
服务端 e2e 1558/0、_fb_v79_api.py 26/0、编译通过并已装机。
2026-10-22 16:40:44 +08:00

1210 lines
40 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,
FbMark,
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem,
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt,
} 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[];
}
/* ---- v79:任务详情(`GET /tasks/<id>`)才有那几块 ---- */
interface RawTaskKid {
id?: number; title?: string; status?: string;
}
interface RawTaskComment {
id?: number; person_id?: number; content?: string; created_at?: string;
name?: string | null; my_nickname?: string | null; avatar_color?: string | null;
}
interface RawTaskAttach {
id?: number; key?: string; name?: string | null; mime?: string | null;
size?: number; is_image?: number | null;
}
/** 任务详情的原始行:任务本体 + 详情独占的几列 + 三块子集合 */
interface RawTaskDetail extends RawTask {
assignee_id?: number | null;
/** 我对负责人的称呼(优先于 `assignee_name`,与网页 `person_name()` 同口径) */
assignee_nick?: string | null;
creator_name?: string | null;
creator_nick?: string | null;
repeat_rule?: string | null;
parent_title?: string | null;
children?: RawTaskKid[];
comments?: RawTaskComment[];
attachs?: RawTaskAttach[];
}
/** `?view=boards` 的看板元数据(v79) */
interface RawBoardMeta {
id?: number; name?: string; color?: string; open?: number;
}
interface RawCatMeta {
id?: number; name?: string;
}
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[] = cityListOf(r);
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(' · '),
cities: cities,
summary: s(r.summary),
attendeeCount: n(r.attendee_count),
};
out.push(it);
}
return out;
}
/**
* 城市的**列表**(卡片上一颗颗 chip 换行用)。
*
* 🔴 v76(用户 2026-10-22):「如果一行显不下的话,应该显示在下边一行,
* 把所有的城市全列出来。」
* ⇒ 端上必须要数组,不能只有一个 `city` 整串 —— 整串只能截断。
*
* ⚠️ 服务端 `Trip::peek()` 同时给 `cities`(数组,`Trip::cities()` 的原值)
* 与 `city`(`implode(' · ', $cities)`)。**优先用数组**;
* 万一将来某条接口只给整串(老版本 / 单条接口),按同一个分隔符拆回来,
* 这样"显示不全"这个毛病不会再从别的入口漏进来。
*/
function cityListOf(r: RawTrip): string[] {
const arr0: string[] = arr<string>(r.cities);
const out: string[] = [];
for (const c of arr0) {
const t: string = c.trim();
if (t !== '' && out.indexOf(t) < 0) {
out.push(t);
}
}
if (out.length > 0) {
return out;
}
const whole: string = s(r.city);
if (whole === '') {
return out;
}
for (const p of whole.split('·')) {
const t: string = p.trim();
if (t !== '' && out.indexOf(t) < 0) {
out.push(t);
}
}
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;
}
/** 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;
}
/**
* 扁平的任务列表 JSON(`?view=done` / `?view=status`)→ `FbTaskItem[]`。
*
* ⚠️ 与 `parseTasks`(看板视图)**不能互换**:那个认 `{boards:[...]}`,
* 这个认 `[...]`。缓存键也必须分开(`tasks` vs `tasks_done`),
* 否则两个形状会互相覆盖,表现是"切一次已完成,看板全空了"。
*/
export function parseTaskList(json: string): FbTaskItem[] {
const rows: RawTask[] = asArray<RawTask>(json);
return parseTaskRows(rows);
}
/** 一批任务行 → FbTaskItem[](首页与任务页共用同一段形状转换) */
export function parseTaskRows(rows: RawTask[]): FbTaskItem[] {
const out: FbTaskItem[] = [];
for (const t of rows) {
out.push(taskRow(t, '', ''));
}
return out;
}
/**
* `?view=boards` → 看板 + 分类(v79)。
*
* 🔴 端上原来是从已加载任务里**反推**看板的(`FbTasksPage.boardOpts`),
* 那样只能看到"有任务的看板" —— 用户刚建好、还没放任务的看板在端上
* 永远选不到(表现为"网页能建,端上挪不过去")。
*/
export function parseTaskMeta(json: string): FbTaskMeta {
const root: Record<string, Object> | null = asObject(json);
const boards: FbBoardOpt[] = [];
const cats: FbCatOpt[] = [];
if (root === null) {
const empty: FbTaskMeta = { boards: boards, cats: cats };
return empty;
}
for (const b of arr<RawBoardMeta>(root['boards'] as RawBoardMeta[])) {
const id: number = n(b.id);
const name: string = s(b.name);
if (name === '') {
continue;
}
const one: FbBoardOpt = { id: id, name: name, color: s(b.color) };
boards.push(one);
}
for (const c of arr<RawCatMeta>(root['categories'] as RawCatMeta[])) {
const name: string = s(c.name);
if (name === '') {
continue;
}
const one: FbCatOpt = { id: n(c.id), name: name };
cats.push(one);
}
const meta: FbTaskMeta = { boards: boards, cats: cats };
return meta;
}
/**
* 一条任务的详情 → `FbTaskDetail`(v79 面板用)。
*
* ⚠️ `base` 走**同一个** `taskRow()`(与首页/任务页列表同源)——
* 三处各写一份转换,迟早出现"同一张卡在两个页面显示不一样"。
*/
export function parseTaskDetail(json: string): FbTaskDetail | null {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return null;
}
const d: RawTaskDetail = root as RawTaskDetail;
if (n(d.id) <= 0) {
return null;
}
const base: FbTaskItem = taskRow(d, s(d.board_name), s(d.board_color_calc));
const kids: FbTaskKid[] = [];
for (const k of arr<RawTaskKid>(d.children)) {
if (n(k.id) <= 0) {
continue;
}
const one: FbTaskKid = {
id: n(k.id),
title: s(k.title),
status: s(k.status) !== '' ? s(k.status) : 'todo',
};
kids.push(one);
}
const comments: FbTaskComment[] = [];
for (const c of arr<RawTaskComment>(d.comments)) {
const nick: string = s(c.my_nickname);
const one: FbTaskComment = {
id: n(c.id),
content: s(c.content),
name: nick !== '' ? nick : s(c.name),
color: s(c.avatar_color),
createdAt: s(c.created_at),
};
comments.push(one);
}
const attachs: FbTaskAttach[] = [];
for (const a of arr<RawTaskAttach>(d.attachs)) {
const name: string = s(a.name);
const mime: string = s(a.mime);
const isImg: boolean = n(a.is_image) === 1 || mime.indexOf('image/') === 0;
const one: FbTaskAttach = {
id: n(a.id),
name: name !== '' ? name : '附件',
kind: attachKindOf(mime, name, isImg),
isImage: isImg,
key: s(a.key),
sizeWord: sizeWordOf(n(a.size)),
};
attachs.push(one);
}
const creatorNick: string = s(d.creator_nick);
const detail: FbTaskDetail = {
base: base,
assigneeId: n(d.assignee_id),
categoryId: n(d.category_id),
category: s(d.category_name),
repeat: s(d.repeat_rule),
parentTitle: s(d.parent_title),
creator: creatorNick !== '' ? creatorNick : s(d.creator_name),
kids: kids,
comments: comments,
attachs: attachs,
};
return detail;
}
/** 附件是「哪一类」—— 决定画缩略图还是画一个图标 + 后缀 */
function attachKindOf(mime: string, name: string, isImg: boolean): string {
if (isImg) {
return 'image';
}
const dot: number = name.lastIndexOf('.');
const ext: string = dot >= 0 ? name.substring(dot + 1).toLowerCase() : '';
if (mime === 'application/pdf' || ext === 'pdf') {
return 'pdf';
}
if (ext === 'doc' || ext === 'docx') {
return 'doc';
}
if (ext === 'xls' || ext === 'xlsx' || ext === 'csv') {
return 'xls';
}
if (ext === 'ppt' || ext === 'pptx') {
return 'ppt';
}
if (ext === 'txt' || ext === 'md') {
return 'text';
}
return 'file';
}
/** 附件大小(给人看的那个串) */
function sizeWordOf(bytes: number): string {
if (bytes <= 0) {
return '';
}
if (bytes < 1024) {
return bytes.toString() + ' B';
}
if (bytes < 1024 * 1024) {
return (Math.round(bytes / 102.4) / 10).toString() + ' KB';
}
return (Math.round(bytes / 104857.6) / 10).toString() + ' MB';
}
// ============================================================
// 内部工具
// ============================================================
/** 解析成数组(失败给空数组) */
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 '';
}