v0.7.0 接上真实服务器:登录 / 设备会话 / 全量同步与各模块真实数据
从「演示数据壳子」走到「真的能连家里的服务器用」。
登录(用户 2026-10-17「完整的登录功能」)
· 首次启动先填服务器地址 → /api/v1/ping 自报门户(认得出"这地址上真是家书")
· 手机号或用户名 + 密码 → 拿令牌;失败提示统一,不泄露账号是否存在
· FbDeviceStore:首次启动生成并持久化设备 UUID(三方应用拿不到 UDID),
设备名取机型(marketName),并上报 platform / app_version
设备会话(v57,用户 2026-10-17)
· 一把令牌 = 一台设备的一条会话;每次启动打一次 /me(有网就验)
· 两种锁分开:need_login(401/被踢 → 清令牌)与 offline_expired
(离线超 24h → 只锁界面、不清数据、联网自动恢复)——
混成一种就会违背用户要的"一直联网就不用每天输密码"
· 运行期任何请求 401 → 网络层全局回调立刻锁定跳登录页(FbHttp 的 authProbe
让登录/校验类请求跳过,避免"两个地方同时跳登录页")
· 退出登录:先清本地令牌(主防线),再尽力通知服务端
· 设置页显示版本号与「这台设备」
数据层
· FbDb / FbSchema / FbParse / 各 Repo(日程 / 名册 / 观影 / 模块)+ FbSyncAll
· 首页改为服务端 /home(农历/节气/纪念日/生日/天气/统计/往年的今天)
· 日程页用 FbDayEventPanel,与网页端显示方式/红线/并排三处一致
验证:ArkTS 静态守卫 231/0;hvigor 编译 BUILD SUCCESSFUL(entry-default-signed.hap)。
This commit is contained in:
@@ -0,0 +1,864 @@
|
||||
/**
|
||||
* 家书 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,
|
||||
} 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;
|
||||
}
|
||||
|
||||
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: [],
|
||||
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']),
|
||||
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`),端上要驼峰 —— 所以有这一层。
|
||||
*/
|
||||
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 '';
|
||||
}
|
||||
Reference in New Issue
Block a user