v75 旅程重构:列表页 + 详情整页,卡片改「图在上、信息在下」

用户 2026-10-21:
「每一个旅程里面的行程才是重头戏……点击一个旅程,应该进入到一个新的界面,
  在里面列出所有的行程,就跟网页版的一样。
  这个旅程的图片是比较重要的……图片在正上方,就是充满整个的这个列表的宽度。
  然后呢把这个标题还有其他的一些信息放在下面。如果是图片放左边,信息放右边的话,
  它总是被挤占,显示不全。……想看其他的这个旅程的话,直接往下滑就可以了。」

- 新建 `pages/trips/FbTripDetailPage.ets`(照 `trips/show.php`):题图 16:9 占满整宽、
  五项统计、行程按 计划/实际/未成行 三组(空组不显示)、页头两行动作区
  (+计划=蓝 / +实际=绿 / 编辑=黄 / 删除=红 / 设置)
- `FbTripsPage` 846→573 行:删掉就地展开全套,`tripCard` 改「题图在上、信息全在下面」
  卡尾「看行程」自己带 onClick,整卡也可点
- `Types.ets` `FbTripNode` 8→18 字段 + 四个新类型;`FbTripRepo` 新增
  `fetchTripDetail` / `fetchTripSummary` 与四个解析辅助
- 修真 bug:`parseNodes` 读 `expense_cent`,服务端写的是 `spent_cents`
  ⇒ 每段「💰 这一段花了」从来没显示过
- `Store.ets` 新增 `@Trace tripDetailId` 与 `leaveTo(parentKey)`:
  删完整趟回列表要走它(内部 `applyModule` 会 `resetScroll`),
  直接赋 `activeModule` 会让回列表后轻轻一滑就收起悬浮栏
- 接线:`Index` 分发 / `TopBar` 标题「旅程」/ `Modules.fbModuleColor` 归到 trips 色
- `FbTripNodeSheet` 新增 `@Param startStage` 预选阶段(选择器保留,点错还能改)

守卫 931/0,反向验证 164/0,编译 BUILD SUCCESSFUL,真机待设备连上后跑 _fb_v75_verify.py
This commit is contained in:
2026-10-22 00:07:16 +08:00
parent 6e7cdaf5fc
commit 3e2904cc3d
10 changed files with 1862 additions and 462 deletions
+224 -2
View File
@@ -22,7 +22,7 @@
*/
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
import { FbTripNode } from '../model/Types';
import { FbTripDetail, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary } from '../model/Types';
/** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */
export interface FbTripDraft {
@@ -130,12 +130,102 @@ export function parseNodes(json: string): FbTripNode[] {
stage: strOf(o, 'stage') !== '' ? strOf(o, 'stage') : strOf(o, 'category'),
status: strOf(o, 'status'),
mode: strOf(o, 'travel_mode'),
expenseCent: numOf(o, 'expense_cent')
/* 🔴 v75 修的真 bug:这里原来读的是 `expense_cent` —— **服务端根本没有这个字段**。
`Trip::nodes()` 是 `\App\Expense::attachSums($rows)` 把结果写成 **`spent_cents`**
(见 `app/Expense.php` 的 attachSums)。字段名不对 ⇒ 恒 0
⇒ 屏幕上每一段的「这一段花了 ¥xxx」永远不显示,而列表总数却有值 ——
用户看不出原因,只会觉得"我记的花销丢了"。
⚠️ 别写"两个都认"的兜底:多一个不存在的键只会让下次排查更难。 */
expenseCent: numOf(o, 'spent_cents'),
nodeType: strOf(o, 'node_type'),
detail: strOf(o, 'detail'),
fromPlace: strOf(o, 'from_place'),
toPlace: strOf(o, 'to_place'),
lineName: strOf(o, 'line_name'),
carrier: strOf(o, 'carrier'),
tripNo: strOf(o, 'trip_no'),
vehiclePlate: strOf(o, 'vehicle_plate'),
revisionN: numOf(o, 'revision_n'),
members: peopleOf(o, 'members'),
attachs: attachsOf(o, 'attachs')
});
}
return out;
}
/**
* 拉一趟旅程的**本体**(`GET /api/v1/trips/<id>`)。
*
* 与列表项的区别:这里带 `cities` 数组、`activitySpan` 折好的时间串、
* 完整参与人名单、`is_public` —— 详情页头部那一整块都读它。
*
* @returns 失败返回 `null`(页面据此显示"没取到,可重试",不假装空白)
*/
export async function fetchTripDetail(token: string, tripId: number): Promise<FbTripDetail | null> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString(), opts);
if (!res.ok) {
return null;
}
let raw: Object;
try {
raw = JSON.parse(res.data) as Object;
} catch (err) {
return null;
}
if (raw === null || typeof raw !== 'object') {
return null;
}
const o: Record<string, Object> = raw as Record<string, Object>;
const cities: string[] = strListOf(o, 'cities');
return {
id: numOf(o, 'id'),
title: strOf(o, 'title'),
dateRange: activitySpanOf(o),
coverKey: strOf(o, 'cover_key'),
summary: strOf(o, 'summary'),
// 服务端给的是 0/1 或 true/false,`numOf` 认前者
isPublic: boolOf(o, 'is_public'),
cities: cities,
attendees: peopleOf(o, 'attendees')
};
}
/**
* 拉五项统计(`GET /api/v1/trips/<id>/summary`)。
*
* 🔴 五项**全部由服务端从行程里算**(`Trip::summary()`)——
* 端上绝不能自己用 nodes 求和:
* ① 天数要按"最早开始 / 最晚结束"跨天算,不是数条数;
* ② 城市要 union 出发地到达地去重;
* ③ 花销在**花销表**里(可能在别的月份,节点上不一定看得到)。
*/
export async function fetchTripSummary(token: string, tripId: number): Promise<FbTripSummary | null> {
const opts: FbApiOptions = { method: 'GET', token: token };
const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString() + '/summary', opts);
if (!res.ok) {
return null;
}
let raw: Object;
try {
raw = JSON.parse(res.data) as Object;
} catch (err) {
return null;
}
if (raw === null || typeof raw !== 'object') {
return null;
}
const o: Record<string, Object> = raw as Record<string, Object>;
return {
people: peopleOf(o, 'people'),
cities: strListOf(o, 'cities'),
days: numOf(o, 'days'),
nodeCount: numOf(o, 'nodes'),
skipped: numOf(o, 'skipped'),
cents: numOf(o, 'cents')
};
}
/** 加一段行程 */
export async function addTripNode(token: string, tripId: number, d: FbTripNodeDraft): Promise<FbApiResult> {
/* 同上:逐键赋值(ArkTS 不允许 `Record` 的字面量初始化) */
@@ -226,3 +316,135 @@ function placeOf(o: Record<string, Object>): string {
}
return from !== '' ? from : to;
}
/**
* 布尔(服务端用 0/1,也可能给 true/false)。
*
* ⚠️ 不能复用 `numOf(...) !== 0`:`is_public` 缺字段时 `numOf` 给 0
* ⇒ 会把"没这个字段"误判成"不公开"。缺字段按**公开**算
* (与 `Trip::all()` 里 `COALESCE(d.is_public, 1)` 同一口径)。
*/
function boolOf(o: Record<string, Object>, k: string): boolean {
const v: Object | undefined = o[k];
if (typeof v === 'boolean') {
return v;
}
if (typeof v === 'number') {
return v !== 0;
}
if (typeof v === 'string') {
return v === '1' || v === 'true';
}
return true;
}
/** 字符串数组(`cities`) */
function strListOf(o: Record<string, Object>, k: string): string[] {
const v: Object | undefined = o[k];
const out: string[] = [];
if (!Array.isArray(v)) {
return out;
}
for (const it of v as Object[]) {
if (typeof it === 'string' && it !== '') {
out.push(it);
} else if (typeof it === 'number') {
out.push(it.toString());
}
}
return out;
}
/**
* 一组人(`members` / `attendees` / `people` 三处形状一样)。
*
* 服务端字段:`id` / `name` / `avatar` / `avatar_color` / `my_nickname`。
*/
function peopleOf(o: Record<string, Object>, k: string): FbTripNodePerson[] {
const v: Object | undefined = o[k];
const out: FbTripNodePerson[] = [];
if (!Array.isArray(v)) {
return out;
}
for (const it of v as Object[]) {
const p: Record<string, Object> = it as Record<string, Object>;
const id: number = numOf(p, 'id');
if (id <= 0) {
continue;
}
out.push({
id: id,
name: strOf(p, 'name'),
nickname: strOf(p, 'my_nickname'),
avatarColor: strOf(p, 'avatar_color'),
avatar: strOf(p, 'avatar')
});
}
return out;
}
/**
* 附件数组(服务端 `attachments` 行:`id` / `key` / `name` / `is_image` …)。
*
* ⚠️ 详情页只铺**图片**的缩略图(视频/文件点了没播放器,端上先不做),
* 但这里仍然全量解析 —— 过滤留给渲染层(数据层不替 UI 做决定)。
*/
function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
const v: Object | undefined = o[k];
const out: FbTripNodePhoto[] = [];
if (!Array.isArray(v)) {
return out;
}
for (const it of v as Object[]) {
const a: Record<string, Object> = it as Record<string, Object>;
const key: string = strOf(a, 'key');
if (key === '') {
continue;
}
out.push({
id: numOf(a, 'id'),
key: key,
name: strOf(a, 'name'),
isImage: numOf(a, 'is_image') === 1
});
}
return out;
}
/**
* 旅程的**一段时间文案**(`Trip::activitySpan()` 的端上等价物)。
*
* 🔴 为什么不在端上用 `starts_at` 自己拼:
* 服务端对"一段行程都没有"这种情形给的是「**还没有旅程**」,
* 而不是 `formatSpan()` 那句「日期待补」——两者语义完全不同:
* 前者是"这趟旅程还没排内容",后者是"这一段没填日期"。
* 端上自己拼就会把这句话丢掉,让用户以为时间坏了。
* ⚠️ 服务端详情接口的 `starts_at` 已经是**派生值**(从节点算的),可直接用。
*/
function activitySpanOf(o: Record<string, Object>): string {
const a: string = strOf(o, 'starts_at');
if (a === '') {
return '还没有旅程';
}
const s: string = cnLong(a);
const e: string = strOf(o, 'ends_at');
if (e === '') {
return s + ' 起';
}
return s + ' – ' + cnLong(e);
}
/** `2026-10-20 09:30:00` → `2026年10月20日`(与 Web 端 `Y年n月j日` 同口径) */
function cnLong(v: string): string {
const t: string = v.replace('T', ' ');
if (t.length < 10) {
return t;
}
const y: number = Number.parseInt(t.substring(0, 4), 10);
const m: number = Number.parseInt(t.substring(5, 7), 10);
const d: number = Number.parseInt(t.substring(8, 10), 10);
if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) {
return t.substring(0, 10);
}
return y.toString() + '年' + m.toString() + '月' + d.toString() + '日';
}