/** * 家书 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 ' .../api/v1/<模块>` */ import { FbAccount, FbAnniversaryItem, FbBookItem, FbBookNoteItem, FbCalEvent, FbHomeData, FbKinChip, FbMark, FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbLedgerPoint, FbNoteItem, FbNoteBlock, FbNotificationItem, FbResourceItem, FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay, FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt, FbTaskShare, } 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(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/`)才有那几块 ---- */ 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[]; shares?: RawTaskShare[]; } /** 任务共享行(工单 #26;服务端 `Task::shares()`:s.person_id/perm + p.name + 昵称) */ interface RawTaskShare { person_id?: number; perm?: string | null; name?: string | null; my_nickname?: string | null; } /** `?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 RawLedgerPoint { date?: string; net?: number; asset?: number; debt?: 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(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(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(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(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 | null = asObject(json); if (root === null) { return []; } const boards: RawTaskBoard[] = arr(root['boards'] as RawTaskBoard[]); const out: FbTaskItem[] = []; for (const bd of boards) { const tasks: RawTask[] = arr(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/ 的 summary 补) */ export function parseLedger(json: string): FbLedgerBook[] { const rows: RawLedgerBook[] = asArray(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//entries`(本里的账目 + 汇总)。 * 净资产(`summary.net`)只在这第二级里有 —— 第一级不返回。 */ export function parseLedgerEntries(json: string): FbLedgerDetail { const root: Record | null = asObject(json); const empty: FbLedgerDetail = { entries: [], netCent: 0, assetCent: 0, debtCent: 0 }; if (root === null) { return empty; } const rows: RawLedgerEntry[] = arr(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; } /** 财务折线时序 → FbLedgerPoint[](工单 #24;`GET /api/v1/ledger//timeline`) */ export function parseLedgerTimeline(json: string): FbLedgerPoint[] { const root: Record | null = asObject(json); if (root === null) { return []; } const rows: RawLedgerPoint[] = (root['items'] !== undefined && root['items'] !== null) ? arr(root['items'] as RawLedgerPoint[]) : []; const out: FbLedgerPoint[] = []; for (const r of rows) { out.push({ date: s(r.date), netCent: n(r.net), assetCent: n(r.asset), debtCent: n(r.debt), }); } return out; } /** id 数组的容错转换(服务端可能给 null) */ function idList(v: number[] | null | undefined): number[] { const xs: number[] = arr(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(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(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(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 | null = asObject(json); if (root === null) { return []; } const rows: RawNote[] = arr(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 blocks: FbNoteBlock[] = noteBlocksOf(r.blocks); const tags: string[] = []; for (const t of arr(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, blocks: blocks, mine: n(r.is_mine) !== 0, }); } return out; } /** * `note_blocks` → 端上块数组(工单 #23:图文混排要原始块,别只留一张拼好的文本)。 * * ⚠️ 只保留白名单里的 `text` / `image`(服务端 `Note::BLOCK_KINDS`), * 其它一律归一成 text 并交给 noteBodyOf 语义处理;这里只吐给展示/编辑用。 */ function noteBlocksOf(blocks: RawNoteBlock[] | undefined): FbNoteBlock[] { const rows: RawNoteBlock[] = arr(blocks); const out: FbNoteBlock[] = []; for (const bk of rows) { const kind: string = s(bk.kind); if (kind !== 'text' && kind !== 'image') { continue; } out.push({ kind: kind, content: s(bk.content), position: n(bk.position), }); } return out; } /** * `note_blocks` → 一段纯文本(**只拼 text 块**,图片块跳过)。 * * 🔴 工单 #23:原来图片块被记成「[图片]」占位字,正文框显示它、保存还会把 * 它当正文写回去。现在图片块由 `FbNoteItem.blocks` 单独承载,正文只留纯文本。 * 这样:① 显示真图不靠占位字;② 保存时用 `blocks` 传法,图片不会被覆盖丢。 */ function noteBodyOf(blocks: RawNoteBlock[] | undefined): string { const rows: RawNoteBlock[] = arr(blocks); const parts: string[] = []; for (const b of rows) { const kind: string = s(b.kind); if (kind === 'image') { continue; } const txt: string = s(b.content).trim(); if (txt !== '') { parts.push(txt); } } return parts.join('\n\n'); } /** * 图片块 content → 纯净附件 key(剥掉「`|宽度`」后缀)。 * * 与 Web 端 `Note::imageKey` 同语义:图片块 content 可能是 `key` 或 `key|50`, * 拼图片 URL 时必须取 `|` 之前的 key,否则带后缀的那张图永远加载不出来。 */ export function noteImageKey(content: string): string { const c: string = content.trim(); const i: number = c.indexOf('|'); return i >= 0 ? c.substring(0, i).trim() : c; } /** 通知 → FbNotificationItem[] */ export function parseNotifications(json: string): FbNotificationItem[] { const root: Record | null = asObject(json); if (root === null) { return []; } const rows: RawNotification[] = arr(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 | 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(root['today_events'] as RawCalEvent[]); const rawTasks: RawTask[] = arr(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(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 | 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 | 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(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(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(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 | 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(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(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 | 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(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(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(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); } // 共享给谁(工单 #26;服务端 Task::find 已带 shares) const shares: FbTaskShare[] = []; for (const sh of arr(d.shares)) { const nick: string = s(sh.my_nickname); shares.push({ personId: n(sh.person_id), name: nick !== '' ? nick : s(sh.name), perm: s(sh.perm) !== '' ? s(sh.perm) : 'write', }); } 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, shares: shares, }; 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(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)['data']; if (Array.isArray(data)) { return data as T[]; } return []; } catch (e) { return []; } } /** * 把一个**已经是对象**的值转成 `Record`(不是对象则 null)。 * * ⚠️ 与 `asObject()` 的区别:那个吃的是**整份 JSON 字符串**、还要往下钻 `data` 一层; * 这个吃的是"已经从根对象里取出来的某个字段值"(如 `root['weather']`)。 * ArkTS 里 `as` 是编译期的,运行时 `null`/数组/标量 都能骗过它 —— * 所以必须自己判一遍类型,别直接 `v as Record<...>`(那样标量上取键会拿到 undefined, * 然后被当成"字段是空的",症状是"接口明明给了却显示为空")。 */ function asObjectOf(v: Object | null | undefined): Record | null { if (v === null || v === undefined) { return null; } if (typeof v !== 'object' || Array.isArray(v)) { return null; } return v as Record; } function asObject(json: string): Record | 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; const data = o['data']; if (data !== undefined && data !== null && typeof data === 'object' && !Array.isArray(data)) { return data as Record; } 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(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 ''; }