v65/v66:六个空壳模块接线 + 桌面服务卡片
用户 2026-10-20:
「做几个和 syncCalendar 差不多的卡片。另外其他的功能也逐步完成……旅程的
新建旅程啊,车辆管理、出行方式、节点设置……资源也点不进去……任务也点不
进去,也没法完成……纪念日、新增纪念日……财务的显示也有问题……笔记现在
也点不进去,记笔记也记不了。」
v65 —— 六个模块(任务 / 资源 / 纪念日 / 笔记 / 账本 / 旅程)原本是**同一套
空壳模板**复制出来的:`FbPrimaryButton` 没有 `onTap`、`ListItem` 没有
`onClick`、设置菜单的 `action` 全是空串。这一版把它们全部接上真实接口:
· 任务:新建 / 编辑(`FbTaskEditSheet` + `FbTaskRepo`)、列表可点、勾完成
(走 `/tasks/<id>/status` —— 服务端 `PATCH` **显式忽略** status,走 Patch
会得到"看起来成功的假成功")、按看板筛选
· 资源:新增 / 查看 / 改 / 删(`FbResourceSheet` + `FbResourceRepo`);
过滤维度从**并不存在的**「证件 / 资料」改成按权限与归属
(`resources` 表没有 category 列,那个筛选一直是筛空的)
· 纪念日:新增 / 编辑(`FbDayEditSheet` + `FbDayRepo`);派生生日
(id=0、来自名册)只给提示不给编辑 —— 点开 `/days/0` 必然 404
· 笔记:记一条 / 打开 / 改(`FbNoteSheet` + `FbNoteRepo`),正文按 blocks 存
· 账本:记一笔(`FbLedgerEntrySheet` + `FbLedgerRepo`);修掉财务显示自相
矛盾 —— 资产/负债改回**服务端 summary**(原来端上按金额正负号重算,与
服务端"负债取绝对值"的口径打架,会算出 净资产 ≠ 资产 − 负债)
· 旅程:题图卡片(封面 + 标题 + 城市)+ 行程 + 新建旅程 + 新建节点
服务端同时修掉两处**静默数据丢失**(网页端因提交格式凑巧没暴露,只有走
API 的客户端会中招):
· 新建笔记传的是 `body`,而 `Note::create()` 只读 `blocks` ⇒ 新笔记只有标题
· 改笔记时 `blocks` 恒为空,而 `Note::update()` 是整组替换 ⇒ 只改标题就把
正文全删光;另修 `annivPeopleOf()`(`find()` 的 SQL 不含 people,原来那个
兜底恒为空 ⇒ 只改纪念日标题会清空关联人)
v66 —— 桌面服务卡片(用户选了「桌面服务卡片」这一项):
· `FbFormAbility`(FormExtensionAbility)+ `form_config.json` 三块尺寸:
2×2 今日概览 / 2×4 左右分栏 / 4×4 今日全景
· `FbCardData` 只打 `/home` 一个接口(与网页首页同源),数据**扁平化**后
由 `formBindingData` 推给卡片;断网时用上次成功的缓存并注明"离线"
· 卡片是后台进程 ⇒ 请求带 `authProbe: true`:401 **不能**触发全局"踢回
登录页"(那会顺手把正在用应用的人踢下线,v62 那次事故同一个坑)
· 点卡片 → `postCardAction(router, fbModule)` → `EntryAbility` 读 Want 参数
直接落到对应模块;补了 `onNewWant`(没有它,第二次点卡片"什么也没发生")
· 4×4 右上角 ↻ 走 `postAction(message)` → `onFormEvent` 重取数再推回
验证:`hvigorw assembleHap` BUILD SUCCESSFUL;打包后的 hap 里确认
`FbFormAbility`(type=form)与 `$profile:form_config` 都在,卡片代码进了
`ets/widgets.abc`。静态守卫 730 条全绿,反向验证 110 条注入全部能让守卫变红。
⚠️ 设备仍断连(`hdc list targets` 为空),真机尚未验收。
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 桌面服务卡片数据(鸿蒙端)
|
||||
*
|
||||
* 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」
|
||||
* ⇒ 参照 `SyncCalendar` 的 `CardDataService`:卡片**不直连数据库**,
|
||||
* 而是把"卡片要显示的那几十个字段"先算成一份**扁平的**数据对象
|
||||
* (每个字段都是 string / number),再由 `formBindingData` 推给卡片渲染。
|
||||
*
|
||||
* 为什么不让卡片自己去请求接口:
|
||||
* ① 卡片的 ArkTS 是**受限子集**(不能随便 await、不能拿 Context 去读 preferences),
|
||||
* 它只认 `LocalStorage` 里那几个标量;
|
||||
* ② 逻辑放在这里 ⇒ 2×2 / 2×4 / 4×4 三种尺寸共用**同一份计算**,
|
||||
* 不会出现"大卡说 5 条日程、小卡说 3 条"这种自相矛盾。
|
||||
*
|
||||
* 🔴 数据来源只用 **`GET /api/v1/home` 一个接口**:
|
||||
* 它一次给齐 日期/星期/农历/日子标记/今日日程/统计/最近纪念日,
|
||||
* 与网页版首页**同源**(服务端 `home()` 的注释写明了这一点)。
|
||||
* 多打两个接口(calendar + days)只会让卡片刷新变慢、且口径可能与首页不一致。
|
||||
*
|
||||
* ⚠️ 令牌与服务器地址:
|
||||
* 卡片进程(FormExtensionAbility)与主应用**共享同一个沙箱**,
|
||||
* 所以直接复用 `FbTokenStore` / `FbServerStore` 的 preferences 即可,
|
||||
* **不要在卡片里另存一份**(那样用户在应用里"退出登录",卡片还带着老令牌)。
|
||||
*/
|
||||
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { FB_SERVER_STORE } from './FbServerStore';
|
||||
import { FB_TOKEN_STORE } from './FbTokenStore';
|
||||
import { fbApi, FbApiResult } from './FbHttp';
|
||||
import { parseHome } from './FbParse';
|
||||
import { FbCalEvent, FbHomeData, FbMark, FbRecentDay } from '../model/Types';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbCard';
|
||||
|
||||
/** 卡片上的一条日程(扁平化:卡片只认标量) */
|
||||
export class FbCardEvent {
|
||||
/** ForEach 的 key(**必须唯一**,否则同名的两条只渲染第一条) */
|
||||
key: string = '';
|
||||
/** `09:00` / `全天` */
|
||||
time: string = '';
|
||||
title: string = '';
|
||||
/** 日历本色标(直接当左侧色条用) */
|
||||
color: string = '#A84A28';
|
||||
}
|
||||
|
||||
/** 卡片上的一条纪念日倒计时 */
|
||||
export class FbCardDay {
|
||||
key: string = '';
|
||||
title: string = '';
|
||||
/** 还有几天(0 = 就是今天) */
|
||||
days: number = 0;
|
||||
/** 7 天内 ⇒ 卡片上用品牌色强调 */
|
||||
soon: boolean = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片整体数据。
|
||||
*
|
||||
* ⚠️ **字段名就是 `LocalStorage` 的键名**(卡片页 `@LocalStorageProp('xxx')`),
|
||||
* 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",不会报错。
|
||||
*/
|
||||
export class FbCardData {
|
||||
/**
|
||||
* 状态:`ok` 正常 / `login` 未登录 / `noserver` 没填服务器 /
|
||||
* `offline` 连不上(此时若有缓存则显示缓存) / `loading` 刚添加还没拉到。
|
||||
*/
|
||||
state: string = 'loading';
|
||||
/** `10 月 20 日` */
|
||||
todayLabel: string = '';
|
||||
/** `星期二` */
|
||||
weekday: string = '';
|
||||
/** `九月初一`(空 = 算不出,卡片不画) */
|
||||
lunarShort: string = '';
|
||||
/** 日子标记拼成一行:`寒露 · 重阳节` */
|
||||
marks: string = '';
|
||||
/** 今日日程条数 */
|
||||
eventCount: number = 0;
|
||||
/** 在做 / 待办 / 逾期(三个数字与首页概述卡同源) */
|
||||
doing: number = 0;
|
||||
todo: number = 0;
|
||||
overdue: number = 0;
|
||||
/** `FbCardEvent[]` 的 JSON(卡片侧 `JSON.parse` 成数组再 ForEach) */
|
||||
eventsJson: string = '[]';
|
||||
/** `FbCardDay[]` 的 JSON */
|
||||
daysJson: string = '[]';
|
||||
/** 一行小字提示(未登录 / 离线 / 出错时给用户一个说法) */
|
||||
hint: string = '';
|
||||
/** `15:40 更新` */
|
||||
updatedAt: string = '';
|
||||
}
|
||||
|
||||
/** 两位补零 */
|
||||
function pad2(v: number): string {
|
||||
return v < 10 ? '0' + v.toString() : v.toString();
|
||||
}
|
||||
|
||||
/** 当前时刻 `HH:mm`(卡片右上角"更新时间") */
|
||||
function clockText(): string {
|
||||
const d: Date = new Date();
|
||||
return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||
}
|
||||
|
||||
/** 把接口载荷铺进卡片数据 */
|
||||
function applyHome(home: FbHomeData, out: FbCardData): void {
|
||||
out.todayLabel = home.todayLabel;
|
||||
out.weekday = home.weekday;
|
||||
out.lunarShort = home.lunarShort;
|
||||
out.eventCount = home.todayEvents.length;
|
||||
out.doing = home.stat.doing;
|
||||
out.todo = home.stat.todo;
|
||||
out.overdue = home.stat.overdue;
|
||||
// 日子标记:最多两个(卡片上那一行放三个就会折行,反而看不清)
|
||||
let marks: string = '';
|
||||
let n: number = 0;
|
||||
for (const mk of home.marks as FbMark[]) {
|
||||
if (n >= 2) {
|
||||
break;
|
||||
}
|
||||
marks += (marks === '' ? '' : ' · ') + mk.word;
|
||||
n++;
|
||||
}
|
||||
out.marks = marks;
|
||||
// 今日日程:最多 6 条(4×4 也只放得下 5~6 行)
|
||||
const events: FbCardEvent[] = [];
|
||||
let idx: number = 0;
|
||||
for (const e of home.todayEvents as FbCalEvent[]) {
|
||||
if (idx >= 6) {
|
||||
break;
|
||||
}
|
||||
const it: FbCardEvent = new FbCardEvent();
|
||||
it.key = 'e' + idx.toString();
|
||||
it.time = e.allDay || e.time === '' ? '全天' : e.time;
|
||||
it.title = e.title === '' ? '(无标题)' : e.title;
|
||||
it.color = e.color !== '' ? e.color : '#A84A28';
|
||||
events.push(it);
|
||||
idx++;
|
||||
}
|
||||
out.eventsJson = JSON.stringify(events);
|
||||
// 最近纪念日:最多 3 条(服务端已按"最近的在前"排好,**不要再排**)
|
||||
const days: FbCardDay[] = [];
|
||||
let di: number = 0;
|
||||
for (const rd of home.recentDays as FbRecentDay[]) {
|
||||
if (di >= 3) {
|
||||
break;
|
||||
}
|
||||
const it: FbCardDay = new FbCardDay();
|
||||
it.key = 'd' + di.toString();
|
||||
it.title = rd.title;
|
||||
it.days = rd.days;
|
||||
it.soon = rd.days <= 7;
|
||||
days.push(it);
|
||||
di++;
|
||||
}
|
||||
out.daysJson = JSON.stringify(days);
|
||||
}
|
||||
|
||||
/** 缓存用的 preferences(卡片断网时显示上次成功的数据) */
|
||||
const CACHE_STORE: string = 'fb_card';
|
||||
const CACHE_KEY: string = 'home_json';
|
||||
|
||||
async function saveCache(ctx: common.Context, payload: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
||||
await store.put(CACHE_KEY, payload);
|
||||
// ⚠️ 必须 flush:卡片进程随时可能被回收,不 flush 等于没写
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e: BusinessError = err as BusinessError;
|
||||
hilog.warn(DOMAIN, TAG, '卡片缓存写入失败:%{public}s', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCache(ctx: common.Context): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE);
|
||||
const v: preferences.ValueType = await store.get(CACHE_KEY, '');
|
||||
return typeof v === 'string' ? v : '';
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装一份卡片数据(异步)。
|
||||
*
|
||||
* ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。
|
||||
* 所有失败都翻译成 `state` + `hint`,由卡片自己画一句人话。
|
||||
*/
|
||||
export async function fbCardBuild(ctx: common.Context): Promise<FbCardData> {
|
||||
const out: FbCardData = new FbCardData();
|
||||
out.updatedAt = clockText();
|
||||
try {
|
||||
await FB_SERVER_STORE.init(ctx);
|
||||
await FB_TOKEN_STORE.init(ctx);
|
||||
} catch (err) {
|
||||
hilog.warn(DOMAIN, TAG, '卡片环境预加载失败:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
if (FB_SERVER_STORE.get() === '') {
|
||||
out.state = 'noserver';
|
||||
out.hint = '先在「家书」里填写服务器地址';
|
||||
return out;
|
||||
}
|
||||
const token: string = FB_TOKEN_STORE.get();
|
||||
if (token === '') {
|
||||
out.state = 'login';
|
||||
out.hint = '先在「家书」里登录';
|
||||
return out;
|
||||
}
|
||||
/* 🔴 `authProbe: true`:卡片是**后台**刷新,401 时**绝不能**触发全局"踢回登录页"
|
||||
(那个回调会把令牌清掉 —— 用户在应用里用得好好的,桌面卡片一刷新把他踢下线,
|
||||
这是最说不清的 bug)。这里自己处理"登录失效"的提示即可。
|
||||
⚠️ 代价:`authProbe` 会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */
|
||||
const r: FbApiResult = await fbApi('/api/v1/home', { token: token, authProbe: true });
|
||||
if (r.ok) {
|
||||
applyHome(parseHome(r.data), out);
|
||||
out.state = 'ok';
|
||||
out.hint = '';
|
||||
await saveCache(ctx, r.data);
|
||||
return out;
|
||||
}
|
||||
const cached: string = await loadCache(ctx);
|
||||
if (cached !== '') {
|
||||
applyHome(parseHome(cached), out);
|
||||
out.state = 'offline';
|
||||
out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据';
|
||||
return out;
|
||||
}
|
||||
out.state = 'offline';
|
||||
out.hint = r.status === 401 ? '登录已失效,请重新登录'
|
||||
: (r.error !== '' ? r.error : '连不上服务器');
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 供卡片页判断"这条日程是不是第一行"之类的**纯函数**已经不需要了 ——
|
||||
* 卡片直接按数组顺序画。这里只留一个给测试/守卫用的口径函数:
|
||||
* 卡片上**最多**显示几条日程(大小卡共用同一个上限,避免"两卡数字不一致")。
|
||||
*/
|
||||
export function fbCardMaxEvents(): number {
|
||||
return 6;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 纪念日的**写操作**(鸿蒙端)
|
||||
*
|
||||
* 对应服务端 `app/ApiHandler.php` 的 `days()`:
|
||||
* POST /api/v1/days 新建
|
||||
* PATCH /api/v1/days/<id> 改
|
||||
* DELETE /api/v1/days/<id> 删
|
||||
*
|
||||
* 字段契约(服务端 `annivData()`):
|
||||
* title / repeat_type(solar|lunar|once)/ month / day / is_leap / kind / note / person_ids[]
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「纪念日、新增纪念日、设置相关的功能。」
|
||||
* ⇒ 端上「添加」按钮**没有 `onTap`**、列表行**没有 `onClick`**,一件都没接。
|
||||
*
|
||||
* ⚠️⚠️ **存储契约里没有"年"**:纪念日只存 `month` + `day`
|
||||
* (见 `app/Schema.php` 的 `anniversaries` 表)。所以端上不能给"选个日期"就完事 ——
|
||||
* 年份会被丢掉。界面上只挑「几月几号」,别让用户以为存了具体某一年。
|
||||
*
|
||||
* ⚠️⚠️ **`person_ids` 必须每次都给**(哪怕是空数组):
|
||||
* 服务端在请求里**没有** `person_ids` 这个键时会去库里补(v65 修的数据丢失),
|
||||
* 但"补"是兜底不是契约。端上既然知道当前选的是谁,就照实发,别依赖兜底。
|
||||
*/
|
||||
|
||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||
|
||||
/** 新建/编辑纪念日提交的字段 */
|
||||
export interface FbDayDraft {
|
||||
title: string;
|
||||
/** `solar` / `lunar` / `once` */
|
||||
repeatType: string;
|
||||
/** 1-12 */
|
||||
month: number;
|
||||
/** 1-31 */
|
||||
day: number;
|
||||
/** 分类名(自由文本,服务端不校验;空串 = 不分类) */
|
||||
kind: string;
|
||||
note: string;
|
||||
/** 关联人(0 个 = 全家的事) */
|
||||
personIds: number[];
|
||||
}
|
||||
|
||||
/** 把 draft 摊成请求体(`arkts-no-untyped-obj-literals` 要求逐键赋值) */
|
||||
function bodyOf(d: FbDayDraft): Record<string, Object> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = d.title;
|
||||
body['repeat_type'] = d.repeatType;
|
||||
body['month'] = d.month;
|
||||
body['day'] = d.day;
|
||||
body['kind'] = d.kind;
|
||||
body['note'] = d.note;
|
||||
body['person_ids'] = d.personIds;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 新建纪念日 */
|
||||
export async function createDay(token: string, d: FbDayDraft): Promise<FbApiResult> {
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: bodyOf(d) };
|
||||
return await fbApi('/api/v1/days', opts);
|
||||
}
|
||||
|
||||
/** 改纪念日 */
|
||||
export async function updateDay(token: string, id: number, d: FbDayDraft): Promise<FbApiResult> {
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: bodyOf(d) };
|
||||
return await fbApi('/api/v1/days/' + id.toString(), opts);
|
||||
}
|
||||
|
||||
/** 删纪念日 */
|
||||
export async function deleteDay(token: string, id: number): Promise<FbApiResult> {
|
||||
const opts: FbApiOptions = { method: 'DELETE', token: token };
|
||||
return await fbApi('/api/v1/days/' + id.toString(), opts);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 账本的**写操作**(鸿蒙端)
|
||||
*
|
||||
* 对应服务端 `app/ApiHandler.php` 的 `ledger()`:
|
||||
* POST /api/v1/ledger 新建账本(name)
|
||||
* POST /api/v1/ledger/<id>/entries 记一笔(kind / amount / title / note / person_id)
|
||||
* DELETE /api/v1/ledger/<id> 删账本
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「还有这个财务的显示也有问题。」
|
||||
* ⇒ 端上「记一笔」按钮**没有 `onTap`**、账本切换**不重拉明细**、
|
||||
* 资产/负债是**端上自己按正负号重新加**的(与净资产口径不一致)。见 `FbLedgerPage`。
|
||||
*
|
||||
* ⚠️ **金额传「元」的字符串**:服务端 `Ledger::toCents()` 负责转分
|
||||
* (App 与网页表单同一口径)。这里**不要**先转分再传 —— 会双重换算,差 100 倍。
|
||||
*
|
||||
* ⚠️ **没有"改一笔"和"删一笔"的接口**(`Api::` 路由只解析到第 3 段,
|
||||
* `/ledger/<id>/entries/<eid>` 的第 4 段会被丢掉)。所以端上只做"记一笔",
|
||||
* 改了金额请去网页端操作 —— 这一条要显式写在界面上,别让用户以为存不上。
|
||||
*/
|
||||
|
||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||
|
||||
/** 记一笔 */
|
||||
export async function addLedgerEntry(token: string, ledgerId: number, kind: string,
|
||||
amountYuan: string, title: string, note: string): Promise<FbApiResult> {
|
||||
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
|
||||
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
|
||||
const body: Record<string, Object> = {};
|
||||
body['kind'] = kind;
|
||||
body['amount'] = amountYuan;
|
||||
body['title'] = title;
|
||||
body['note'] = note;
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
return await fbApi('/api/v1/ledger/' + ledgerId.toString() + '/entries', opts);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 笔记的**写操作**(鸿蒙端)
|
||||
*
|
||||
* 对应服务端 `app/ApiHandler.php` 的 `notes()`:
|
||||
* POST /api/v1/notes 新建(title / body 或 blocks / tags)
|
||||
* PATCH /api/v1/notes/<id> 改
|
||||
* DELETE /api/v1/notes/<id> 删(**只限作者**)
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「笔记现在也点不进去,记笔记也记不了。」
|
||||
* ⇒ 端上「记一条」按钮没有 `onTap`、卡片没有 `onClick`。
|
||||
*
|
||||
* ⚠️⚠️ 正文的键是 **`body`(纯文本)**,不是 `blocks`:
|
||||
* `Note` 那边真正的存储是 `note_blocks` 若干块,服务端 `notes()` 收到 `body`
|
||||
* 会包成一个 `text` 块(见 `ApiHandler::noteBlocksIn` 的说明)。
|
||||
* v65 之前接口传的是 `body` 但 `Note` 只读 `blocks` —— 正文被静默丢掉;
|
||||
* **现在两边对齐了**:端上传 `body`,服务端转成块。
|
||||
* ⇒ 端上不要自己去拼 `blocks`(那是网页图文混排的形态,手机上先支持纯文本)。
|
||||
*
|
||||
* ⚠️ `PATCH` 时若**不传** `body`/`blocks`,服务端会沿用库里的块(不会清空)。
|
||||
* 这里仍然每次都传 `body`:用户看到的就是那个文本框,保存即整段替换。
|
||||
*/
|
||||
|
||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||
|
||||
/** 新建笔记 */
|
||||
export async function createNote(token: string, title: string, body: string,
|
||||
tags: string[]): Promise<FbApiResult> {
|
||||
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
|
||||
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
|
||||
const payload: Record<string, Object> = {};
|
||||
payload['title'] = title;
|
||||
payload['body'] = body;
|
||||
payload['tags'] = tags;
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: payload };
|
||||
return await fbApi('/api/v1/notes', opts);
|
||||
}
|
||||
|
||||
/** 改笔记(标题 / 正文 / 标签) */
|
||||
export async function updateNote(token: string, id: number, title: string, body: string,
|
||||
tags: string[]): Promise<FbApiResult> {
|
||||
const payload: Record<string, Object> = {};
|
||||
payload['title'] = title;
|
||||
payload['body'] = body;
|
||||
payload['tags'] = tags;
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: payload };
|
||||
return await fbApi('/api/v1/notes/' + id.toString(), opts);
|
||||
}
|
||||
|
||||
/** 删笔记(服务端只允许作者删) */
|
||||
export async function deleteNote(token: string, id: number): Promise<FbApiResult> {
|
||||
const opts: FbApiOptions = { method: 'DELETE', token: token };
|
||||
return await fbApi('/api/v1/notes/' + id.toString(), opts);
|
||||
}
|
||||
@@ -123,6 +123,10 @@ interface RawDay {
|
||||
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 {
|
||||
@@ -201,6 +205,10 @@ 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 {
|
||||
@@ -262,6 +270,12 @@ export function parseTrips(json: string): FbTripItem[] {
|
||||
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(' · '),
|
||||
summary: s(r.summary),
|
||||
attendeeCount: n(r.attendee_count),
|
||||
};
|
||||
out.push(it);
|
||||
}
|
||||
@@ -280,6 +294,7 @@ export function parseDays(json: string): FbAnniversaryItem[] {
|
||||
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),
|
||||
@@ -288,6 +303,15 @@ export function parseDays(json: string): FbAnniversaryItem[] {
|
||||
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);
|
||||
}
|
||||
@@ -339,6 +363,10 @@ function taskRow(t: RawTask, boardName: string, boardColor: string): FbTaskItem
|
||||
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;
|
||||
}
|
||||
@@ -455,15 +483,18 @@ 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),
|
||||
// 资源没有"类别"字段(服务端的 content 是自由文本),
|
||||
// ⇒ 用「谁的」来分类更贴近实际用法(Web 端列表也是按归属分组)
|
||||
kind: 'other',
|
||||
ownerName: s(r.owner_nick) !== '' ? s(r.owner_nick) : s(r.owner_name),
|
||||
fileCount: 0,
|
||||
shared: s(r.perm) !== 'owner',
|
||||
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);
|
||||
@@ -502,19 +533,52 @@ export function parseNotes(json: string): FbNoteItem[] {
|
||||
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: firstTextOf(r.blocks),
|
||||
tags: [],
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 资源的**写操作**(鸿蒙端)
|
||||
*
|
||||
* 对应服务端 `app/ApiHandler.php` 的 `resources()`:
|
||||
* POST /api/v1/resources 新建(title / content)
|
||||
* PATCH /api/v1/resources/<id> 改(title / content / category)
|
||||
* DELETE /api/v1/resources/<id> 删
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「资源也是啊,新增加资源,资源也点不进去。
|
||||
* 设置也没有任何的东西。」
|
||||
* ⇒ 端上「添加」按钮**没有 `onTap`**、列表项**没有 `onClick`** —— 两件都没接。
|
||||
*
|
||||
* ⚠️⚠️ **服务端只认 title / content 两个字段**(其它字段会被静默忽略):
|
||||
* · `Resource::create()` 的 INSERT 只有 `title` / `content`;
|
||||
* · `resources` 表**没有 category 列**(见 `app/Schema.php`),
|
||||
* 接口里那个 `category` 参数是历史遗留,写下去不会落库;
|
||||
* · 归属人(`owner_id`)由接口固定成**当前登录的人**,没法从端上指定;
|
||||
* · **附件与共享没有 API**(`Attachment::list/save` 只有网页在用)。
|
||||
* ⇒ 端上就只做「标题 + 正文」,别在界面上摆出家能选归属人/传附件的假入口 ——
|
||||
* 用户填了、保存了、却没落库,比没做更糟。
|
||||
*/
|
||||
|
||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||
|
||||
/** 新建资源(归属人固定是自己,见文件头) */
|
||||
export async function createResource(token: string, title: string, content: string): Promise<FbApiResult> {
|
||||
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
|
||||
`Record<string, Object> = {}` 逐键赋值(见 `FbTripRepo` 同一说明)。 */
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = title;
|
||||
body['content'] = content;
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
return await fbApi('/api/v1/resources', opts);
|
||||
}
|
||||
|
||||
/** 改资源(标题 / 正文) */
|
||||
export async function updateResource(token: string, id: number, title: string,
|
||||
content: string): Promise<FbApiResult> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = title;
|
||||
body['content'] = content;
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
|
||||
return await fbApi('/api/v1/resources/' + id.toString(), opts);
|
||||
}
|
||||
|
||||
/** 删资源 */
|
||||
export async function deleteResource(token: string, id: number): Promise<FbApiResult> {
|
||||
const opts: FbApiOptions = { method: 'DELETE', token: token };
|
||||
return await fbApi('/api/v1/resources/' + id.toString(), opts);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 任务的**写操作**(鸿蒙端)
|
||||
*
|
||||
* 对应服务端 `app/ApiHandler.php` 的 `tasks()`:
|
||||
* POST /api/v1/tasks 新建(title / note / due_at / board_id / status)
|
||||
* PATCH /api/v1/tasks/<id> 改(标题/备注/截止/看板/负责人/分类)
|
||||
* POST /api/v1/tasks/<id>/status 改状态(todo / doing / done)
|
||||
* DELETE /api/v1/tasks/<id> 删
|
||||
*
|
||||
* 🔴 用户 2026-10-20:「任务也是一样啊,任务也点不进去,也没法那个完成,
|
||||
* 新建任务、设置这些东西都这都没有。」
|
||||
* ⇒ 端上原来这三件事**一件都没接**(按钮没 onTap、列表项没 onClick)。
|
||||
*
|
||||
* ⚠️ **状态只走 `/status` 子动作**:服务端 `PATCH /tasks/<id>` 里
|
||||
* `'status' => (string)$t['status']` 是**原样回填**,显式忽略请求里的 status
|
||||
* (见 ApiHandler 那行注释「状态只走 /status 子动作」)。
|
||||
* 所以端上 toggle 完成必须打 `/status`,打 PATCH 会"看起来成功但状态没变"。
|
||||
*/
|
||||
|
||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||
|
||||
/** 新建/编辑任务提交的字段 */
|
||||
export interface FbTaskDraft {
|
||||
title: string;
|
||||
note: string;
|
||||
/** `YYYY-MM-DD HH:MM`;空串 = 没有期限 */
|
||||
dueAt: string;
|
||||
/** 看板 id(0 = 不归指定看板 → 服务端落到「其他」那一列) */
|
||||
boardId: number;
|
||||
/** todo / doing / done(只在**新建**时生效;改状态走 `setTaskStatus`) */
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** 新建任务 */
|
||||
export async function createTask(token: string, d: FbTaskDraft): Promise<FbApiResult> {
|
||||
/* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals`:请求体必须
|
||||
`Record<string, Object> = {}` 逐键赋值(见 FbTripRepo 同一说明)。 */
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = d.title;
|
||||
body['note'] = d.note;
|
||||
body['due_at'] = d.dueAt;
|
||||
body['board_id'] = d.boardId;
|
||||
body['status'] = d.status;
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
return await fbApi('/api/v1/tasks', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改任务(标题 / 备注 / 截止 / 看板)。
|
||||
*
|
||||
* ⚠️ **不带 status**:服务端 PATCH 会忽略它(见文件头说明),
|
||||
* 带上只会让人以为"改成功了",实际状态没动。
|
||||
*/
|
||||
export async function updateTask(token: string, id: number, d: FbTaskDraft): Promise<FbApiResult> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = d.title;
|
||||
body['note'] = d.note;
|
||||
body['due_at'] = d.dueAt;
|
||||
body['board_id'] = d.boardId;
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
|
||||
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
|
||||
}
|
||||
|
||||
/** 改状态(勾完成 / 取消完成 / 标记进行中) */
|
||||
export async function setTaskStatus(token: string, id: number, status: string): Promise<FbApiResult> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['status'] = status;
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
return await fbApi('/api/v1/tasks/' + id.toString() + '/status', opts);
|
||||
}
|
||||
|
||||
/** 删任务 */
|
||||
export async function deleteTask(token: string, id: number): Promise<FbApiResult> {
|
||||
const opts: FbApiOptions = { method: 'DELETE', token: token };
|
||||
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 旅程的**写操作**(鸿蒙端)
|
||||
*
|
||||
* 与 `FbMovieRepo` 的分工不同:那边要管本地库事务(观影数据量大、要离线看),
|
||||
* 这边**直接走 API**、成功后让页面重新 `loadModule()` 拉一遍。
|
||||
* 为什么这样更合适:
|
||||
* · 旅程的量级小(几十趟),重新拉一次的代价可以忽略;
|
||||
* · 本地库这边只有一份"列表缓存",没有节点表 —— 自己维护一套写库
|
||||
* 容易和同步下来的数据打架(本地改了、下次同步又被覆盖)。
|
||||
*
|
||||
* 服务端契约(`app/ApiHandler.php` 的 `trips()`,与网页端同一套):
|
||||
* POST /api/v1/trips 新建(title / note / summary / is_public)
|
||||
* PATCH /api/v1/trips/<id> 改
|
||||
* DELETE /api/v1/trips/<id> 删
|
||||
* GET /api/v1/trips/<id>/nodes 行程节点
|
||||
* POST /api/v1/trips/<id>/nodes 加一段行程
|
||||
*
|
||||
* ⚠️ **城市与起止时间不在这里传**:服务端 `Trip::create()` 显式忽略它们,
|
||||
* 一律从行程节点的 `from_city` / `to_city` / `starts_at` **派生**
|
||||
* (见 `Trip::derived()`)。端上跟着这个口径走 —— 新建旅程只填
|
||||
* 名字 / 简介 / 是否公开,城市与时间等加了行程自然就有了。
|
||||
*/
|
||||
|
||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||
import { FbTripNode } from '../model/Types';
|
||||
|
||||
/** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */
|
||||
export interface FbTripDraft {
|
||||
title: string;
|
||||
/** 简介(网页版是 textarea#t-sum,列表卡截 80 字显示) */
|
||||
summary: string;
|
||||
/** 是否公开:不公开的旅程**不出现在列表里**(服务端按 is_public=1 过滤) */
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/** 新增一段行程时提交的字段(与网页版 node_form.php 的核心几项对齐) */
|
||||
export interface FbTripNodeDraft {
|
||||
title: string;
|
||||
place: string;
|
||||
/** ISO 本地时间串(`2026-10-20T09:30`),空串 = 不填 */
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
/** 节点类型 key(见 `Trip::typeWord`;默认 other) */
|
||||
nodeType: string;
|
||||
/** 交通方式 key(见 `Trip::TRAVEL_MODES`;空串 = 未填) */
|
||||
travelMode: string;
|
||||
/** 'plan' 计划 / 'actual' 实际 */
|
||||
category: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建旅程。
|
||||
*
|
||||
* @returns 成功时 `ok=true`,`data` 是新旅程的 JSON
|
||||
*/
|
||||
export async function createTrip(token: string, d: FbTripDraft): Promise<FbApiResult> {
|
||||
/* ⚠️ 请求体必须用 `Record<string, Object> = {}` **逐键赋值**,不能写字面量 ——
|
||||
ArkTS 的 `arkts-no-untyped-obj-literals` 只认显式 class/interface,
|
||||
`Record<…>` 不算(与 `FbMovieRepo.saveMovie` 同一写法)。 */
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = d.title;
|
||||
body['summary'] = d.summary;
|
||||
// ⚠️ 布尔直接发:服务端 `Api::bool('is_public')` 认 true/false。
|
||||
body['is_public'] = d.isPublic;
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
return await fbApi('/api/v1/trips', opts);
|
||||
}
|
||||
|
||||
/** 改旅程(标题 / 简介 / 是否公开) */
|
||||
export async function updateTrip(token: string, id: number, d: FbTripDraft): Promise<FbApiResult> {
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = d.title;
|
||||
body['summary'] = d.summary;
|
||||
body['is_public'] = d.isPublic;
|
||||
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
|
||||
return await fbApi('/api/v1/trips/' + id.toString(), opts);
|
||||
}
|
||||
|
||||
/** 删除旅程(连带它的行程节点由服务端处理) */
|
||||
export async function deleteTrip(token: string, id: number): Promise<FbApiResult> {
|
||||
const opts: FbApiOptions = { method: 'DELETE', token: token };
|
||||
return await fbApi('/api/v1/trips/' + id.toString(), opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉某趟旅程的**行程节点**。
|
||||
*
|
||||
* 🔴 这是端上原先最大的缺口:`FbTripsPage.nodes` 一直是空数组,
|
||||
* 所以每趟旅程展开后永远显示"这个旅程还没有排行程"——
|
||||
* 用户看到的"旅程下面没有行程"就是这一条。
|
||||
*
|
||||
* 服务端返回的每条节点字段比 `FbTripNode` 多(含 detail / from_city /
|
||||
* to_city / travel_mode / members…),这里只取列表要显示的几项。
|
||||
*/
|
||||
export async function fetchTripNodes(token: string, tripId: number): Promise<FbTripNode[]> {
|
||||
const opts: FbApiOptions = { method: 'GET', token: token };
|
||||
const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts);
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
return parseNodes(res.data);
|
||||
}
|
||||
|
||||
/** 解析节点数组(容错:字段缺失给空串,不让一条脏数据把整趟旅程弄没) */
|
||||
export function parseNodes(json: string): FbTripNode[] {
|
||||
if (json === '') {
|
||||
return [];
|
||||
}
|
||||
let raw: Object;
|
||||
try {
|
||||
raw = JSON.parse(json) as Object;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const rows: Object[] = raw as Object[];
|
||||
const out: FbTripNode[] = [];
|
||||
for (const r of rows) {
|
||||
const o: Record<string, Object> = r as Record<string, Object>;
|
||||
out.push({
|
||||
id: numOf(o, 'id'),
|
||||
title: strOf(o, 'title'),
|
||||
when: spanOf(o),
|
||||
place: placeOf(o),
|
||||
// ⚠️ 服务端节点的主字段是 `category`(plan/actual),
|
||||
// 而 `stage` 只在某些查询里出现 ⇒ 两个都看,别只看一个。
|
||||
stage: strOf(o, 'stage') !== '' ? strOf(o, 'stage') : strOf(o, 'category'),
|
||||
status: strOf(o, 'status'),
|
||||
mode: strOf(o, 'travel_mode'),
|
||||
expenseCent: numOf(o, 'expense_cent')
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 加一段行程 */
|
||||
export async function addTripNode(token: string, tripId: number, d: FbTripNodeDraft): Promise<FbApiResult> {
|
||||
/* 同上:逐键赋值(ArkTS 不允许 `Record` 的字面量初始化) */
|
||||
const body: Record<string, Object> = {};
|
||||
body['title'] = d.title;
|
||||
body['place'] = d.place;
|
||||
body['detail'] = d.detail;
|
||||
body['starts_at'] = d.startsAt;
|
||||
body['ends_at'] = d.endsAt;
|
||||
body['node_type'] = d.nodeType;
|
||||
body['travel_mode'] = d.travelMode;
|
||||
body['category'] = d.category;
|
||||
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
|
||||
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删一段行程。
|
||||
*
|
||||
* ⚠️ 走 **query** 传节点 id,不走 `/trips/1/nodes/5`:
|
||||
* API 路由只解析到「资源/ id / 子动作」三段(见 `app/Api.php` 的
|
||||
* `$sub` 取值),第 4 段会被**丢掉** ⇒ 写成路径形式服务端收不到。
|
||||
*/
|
||||
export async function deleteTripNode(token: string, tripId: number, nodeId: number): Promise<FbApiResult> {
|
||||
const query: Record<string, string> = {};
|
||||
query['node_id'] = nodeId.toString();
|
||||
const opts: FbApiOptions = { method: 'DELETE', token: token, query: query };
|
||||
return await fbApi('/api/v1/trips/' + tripId.toString() + '/nodes', opts);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 解析小工具(ArkTS 要求显式类型,别用 any)
|
||||
// ============================================================
|
||||
|
||||
function strOf(o: Record<string, Object>, k: string): string {
|
||||
const v: Object | undefined = o[k];
|
||||
if (typeof v === 'string') {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
return v.toString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function numOf(o: Record<string, Object>, k: string): number {
|
||||
const v: Object | undefined = o[k];
|
||||
if (typeof v === 'number') {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const n: number = Number.parseFloat(v);
|
||||
return Number.isNaN(n) ? 0 : n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 节点时间文案:`starts_at` 有就用它,否则用 `ends_at`;都没有给空串 */
|
||||
function spanOf(o: Record<string, Object>): string {
|
||||
const a: string = strOf(o, 'starts_at') !== '' ? strOf(o, 'starts_at') : strOf(o, 'ends_at');
|
||||
if (a === '') {
|
||||
return '';
|
||||
}
|
||||
return cnShort(a);
|
||||
}
|
||||
|
||||
/** `2026-10-20T09:30:00` → `10-20 09:30`(列表里一行够用) */
|
||||
function cnShort(iso: string): string {
|
||||
const t: string = iso.replace('T', ' ');
|
||||
if (t.length < 16) {
|
||||
return t;
|
||||
}
|
||||
const md: string = t.substring(5, 10);
|
||||
const hm: string = t.substring(11, 16);
|
||||
return md + ' ' + hm;
|
||||
}
|
||||
|
||||
/** 地点:优先 `place`,没有就拼「出发地 → 到达地」 */
|
||||
function placeOf(o: Record<string, Object>): string {
|
||||
const p: string = strOf(o, 'place');
|
||||
if (p !== '') {
|
||||
return p;
|
||||
}
|
||||
const from: string = strOf(o, 'from_city');
|
||||
const to: string = strOf(o, 'to_city');
|
||||
if (from !== '' && to !== '') {
|
||||
return from + ' → ' + to;
|
||||
}
|
||||
return from !== '' ? from : to;
|
||||
}
|
||||
Reference in New Issue
Block a user