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:
@@ -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() + '日';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user