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
+34
View File
@@ -58,6 +58,22 @@ export class FbAppState {
/** 是否宽屏(窗口短边 ≥ 600vp)—— 决定抽屉是「常驻侧栏」还是「滑出层」 */
@Trace wideLayout: boolean = false;
/**
* 🔴 v75(用户 2026-10-21):「点击一个旅程,应该进入到一个新的界面,
* 在里面列出所有的行程,就跟网页版的一样。」
*
* 旅程详情页要看的旅程 id(0 = 没有)。
*
* 为什么放全局状态而不是给 `FbTripsPage` 加一个 `@Local detailId`:
* 用户要的是"**进入一个新的界面**"——整页,不是同一页里换个视图。
* 整页才能自动拿到返回手势(`FB_BACK`)、顶栏标题、悬浮栏让位这三样。
* ⇒ 走 `activeModule = '__trip_detail'` 这条分发,与 `__account`
* (个人设置整页)完全同一套机制。
* ⚠️ 进详情页必须**同时**置 `activeModule` 与它,只置一个会掉进兜底分支
* 或者打开上一趟看过的旅程。见 `FbTripsPage.openDetail()`。
*/
@Trace tripDetailId: number = 0;
/** 当前会话(接口接上之前先用演示数据;`loggedIn=false` 时应跳登录页) */
@Trace session: FbSession = FB_DEMO_SESSION;
@@ -266,6 +282,24 @@ export class FbAppState {
return false;
}
/**
* 从「深层页」退回它上面那层(例:旅程详情删掉整趟后回列表)。
*
* 🔴 为什么不能直接写 `this.app.activeModule = 'trips'`(v75 差点这么干):
* `applyModule()` 除了换 key,还会做两件**看不见但缺了就出问题**的事 ——
* ① 收抽屉;② `resetScroll()` 复位滚动累积。
* 漏掉 ② 的后果是:回列表后**第一次轻滑就"立刻收起悬浮栏"**
* (其实用户什么都没做),v62 已经把这个坑记过一遍。
*
* ⚠️ 与 `backToPrevModule()` 的区别:那个会**弹历史**(用户按返回),
* 这个是**程序主动退回父页**(删除/保存后离开当前页),
* 它**不弹**历史 —— 因为详情页那一层本来就是用户点进来的,
* 父页仍在历史里,弹掉反而会让"再按一次返回"跳到更早的页。
*/
leaveTo(parentKey: string): void {
this.applyModule(parentKey);
}
/** 有浮层开着吗(悬浮栏据此让位) */
sheetOpen(): boolean {
return this.sheetCount > 0;
@@ -62,6 +62,17 @@ export struct FbTripNodeSheet {
@Param tripId: number = 0;
/** 模块主题色 */
@Param accent: string = '#3F7D5A';
/**
* 打开时预选的阶段:`'plan'` / `'actual'`。
*
* 🔴 v75(用户 2026-10-21):详情页右上角是**两颗**按钮
* 「+ 计划行程(蓝)」「+ 实际行程(绿)」(这是 v41 定下的硬要求),
* 点哪颗就该落在哪个阶段 —— 原来只有一颗「加一段行程」,
* 进来后阶段恒为「计划」,用户点"实际行程"还是要手动改一次。
* ⚠️ 浮层里那个「阶段」选择器**保留**:用户点错了还能改,
* 不能因为"入口已经指定了"就把选择器藏掉(那是把选择权拿走)。
*/
@Param startStage: string = 'plan';
@Event onClose: () => void = (): void => {};
/** 加成功后父页面重新拉节点 */
@@ -84,6 +95,8 @@ export struct FbTripNodeSheet {
aboutToAppear(): void {
// 默认今天(多数场景是"记刚刚发生的"或"排今天剩下的")
this.date = fbIsoOf(new Date());
/* 入口指定的阶段(详情页两颗按钮)—— 只认这两个值,别的一律按计划 */
this.category = this.startStage === 'actual' ? 'actual' : 'plan';
}
build() {
+5
View File
@@ -294,6 +294,11 @@ export struct FbTopBar {
if (k === '__account') {
return '个人设置';
}
/* 🔴 v75:旅程详情页顶栏还写「旅程」—— 它是旅程模块的**下一层**,
不是另一个模块。写成"旅程详情"会让标题在列表↔详情之间跳变。 */
if (k === '__trip_detail') {
return '旅程';
}
const m = fbModuleOf(k);
return m !== undefined ? m.name : '家书';
}
+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() + '日';
}
+57 -7
View File
@@ -134,14 +134,64 @@ export const FB_DEMO_TRIPS: FbTripItem[] = [
}
];
/** 一段旅程的行程(trips/show 时间轴;stage 分「计划 / 实际」) */
/**
* 一段旅程的行程(trips/show 时间轴;stage 分「计划 / 实际 / 未成行」)。
*
* ⚠️ v75:`FbTripNode` 从 8 个字段扩到 18 个(详情页要显示 说明/路线/车次/
* 车牌/参与人/照片),这份演示数据必须跟着补齐 —— 少一个字段就编译不过
* (ArkTS 的对象字面量要**结构完全匹配**)。
* ⚠️ 它目前只是"形状参考",没有任何页面在读(真数据一律来自接口)。
* 但别删:新写详情页/浮层时对着它就能知道一个节点长什么样。
*/
export const FB_DEMO_TRIP_NODES: FbTripNode[] = [
{ id: 1, title: '出发', when: '10-02 07:30', place: '家 → 青岛北站', stage: 'actual', status: 'done', mode: '自驾', expenseCent: 0 },
{ id: 2, title: '抵达民宿', when: '10-02 10:20', place: '崂山区·沙子口民宿', stage: 'actual', status: 'done', mode: '自驾', expenseCent: 68000 },
{ id: 3, title: '海边散步 + 午餐', when: '10-02 12:00', place: '仰口海水浴场', stage: 'actual', status: 'done', mode: '步行', expenseCent: 21800 },
{ id: 4, title: '太清宫', when: '10-02 15:00', place: '崂山太清景区', stage: 'actual', status: 'done', mode: '自驾', expenseCent: 27000 },
{ id: 5, title: '登山(巨峰)', when: '10-03 08:00', place: '崂山巨峰景区', stage: 'plan', status: 'done', mode: '自驾', expenseCent: 34000 },
{ id: 6, title: '返程', when: '10-03 17:00', place: '青岛 → 家', stage: 'plan', status: 'done', mode: '自驾', expenseCent: 0 }
{
id: 1, title: '出发', when: '10-02 07:30', place: '家 → 青岛北站',
stage: 'actual', status: 'done', mode: 'drive', expenseCent: 0,
nodeType: 'move', detail: '六点起床,路上买了包子当早饭。',
fromPlace: '家', toPlace: '青岛北站', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 2, title: '抵达民宿', when: '10-02 10:20', place: '崂山区·沙子口民宿',
stage: 'actual', status: 'done', mode: 'drive', expenseCent: 68000,
nodeType: 'stay', detail: '老板留了钥匙在门口花盆下,房间朝海。',
fromPlace: '青岛北站', toPlace: '沙子口民宿', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 3, title: '海边散步 + 午餐', when: '10-02 12:00', place: '仰口海水浴场',
stage: 'actual', status: 'done', mode: 'walk', expenseCent: 21800,
nodeType: 'play', detail: '在礁石上捡了几块小石头。',
fromPlace: '沙子口民宿', toPlace: '仰口海水浴场', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '', revisionN: 0,
members: [], attachs: []
},
{
id: 4, title: '太清宫', when: '10-02 15:00', place: '崂山太清景区',
stage: 'actual', status: 'done', mode: 'drive', expenseCent: 27000,
nodeType: 'play', detail: '',
fromPlace: '仰口', toPlace: '太清宫', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 5, title: '登山(巨峰)', when: '10-03 08:00', place: '崂山巨峰景区',
stage: 'plan', status: 'planned', mode: 'drive', expenseCent: 34000,
nodeType: 'play', detail: '带够水,山上买贵。',
fromPlace: '沙子口民宿', toPlace: '巨峰景区', lineName: '', carrier: '',
tripNo: '', vehiclePlate: '鲁B·12345', revisionN: 0,
members: [], attachs: []
},
{
id: 6, title: '返程', when: '10-03 17:00', place: '青岛 → 家',
stage: 'plan', status: 'planned', mode: 'train', expenseCent: 0,
nodeType: 'move', detail: '',
fromPlace: '青岛北站', toPlace: '家', lineName: '京沪高铁', carrier: '中国铁路',
tripNo: 'G1234', vehiclePlate: '', revisionN: 0,
members: [], attachs: []
}
];
// ============================================================
+7 -1
View File
@@ -95,7 +95,13 @@ export function fbModuleName(key: string): string {
/** 模块主题色(认不出回落到首页砖红) */
export function fbModuleColor(key: string): string {
const m = fbModuleOf(key);
/* 🔴 v75:旅程详情页是**旅程模块的下一层**,不是独立模块
⇒ 不给它单独定色,直接沿用旅程的绿。否则详情页的按钮/圆点会回落砖红,
同一模块里出现两种主题色(用户一眼就看出"这一页跟刚才不一样")。
⚠️ 这类"下一层页面"以后再出现(观影详情、任务详情…)都在这里加一行,
别在页面里各自写死颜色。 */
const k: string = key === '__trip_detail' ? 'trips' : key;
const m = fbModuleOf(k);
return m !== undefined ? m.color : '#A84A28';
}
+100 -7
View File
@@ -409,23 +409,116 @@ export interface FbTripItem {
attendeeCount: number;
}
/** 旅程里的一段行程(trips/show 的时间轴节点) */
/**
* 旅程里的一段行程(trips/show 的时间轴节点)。
*
* 🔴 v75(用户 2026-10-21):「点击一个旅程,应该进入到一个新的界面,
* 在里面列出所有的行程,就跟网页版的一样。」
* ⇒ 端上第一次有**真正的详情页**,节点要显示的字段从 8 个涨到十几个 ——
* 网页版 `templates/trips/show.php` 的每个 `.node-*` 块都要有对应来源。
*
* ⚠️ 字段口径以 `app/Trip.php` 的 `nodes()` 真实返回为准(不是猜的):
* `spent_cents`(不是 `expense_cent`,那个名字不存在)、`members`、
* `attachs`、`vehicle_plate`(nodes() 单独挂上去的)、`revision_n`。
*/
export interface FbTripNode {
id: number;
/** 段标题,如「出发」「抵达民宿」 */
title: string;
/** 时间文案 */
/** 时间文案(已按服务端 `nodeSpan` 的口径折成「10-20 09:30」这种短串) */
when: string;
/** 地点 */
/** 地点(`place`,没有则「出发地 → 到达地」) */
place: string;
/** 阶段:plan(计划)/ actual(实际) */
/** 阶段:plan(计划)/ actual(实际)/ skipped(未成行) */
stage: string;
/** 状态:todo / done / skip */
/** 状态:planned / ongoing / done / cancelled */
status: string;
/** 交通方式(trips/modes) */
/** 交通方式 key(walk / drive / train…),空串 = 没填 */
mode: string;
/** 花费(分) */
/** 这一段花了多少(**分**;来自 `spent_cents`,是花销表按 node_id 汇总的) */
expenseCent: number;
/**
* 节点类型(`node_type`)。
* ⚠️ 两种取值:老数据的英文 key(play/meal/move/stay/other),
* 或用户在「节点类型」里自定义的**中文名字**。取图标时两种都要认。
*/
nodeType: string;
/** 详细说明(网页版 `.node-detail`) */
detail: string;
/** 出发地 / 到达地(网页版 `.node-route-line`,各带导航链接) */
fromPlace: string;
toPlace: string;
/** 线路名 / 承运方(网页版 `.node-traffic` 的两个 `.ntag`) */
lineName: string;
carrier: string;
/** 车次 / 航班号(火车、飞机才有;跟在模式词后面) */
tripNo: string;
/** 自驾时的车牌号(服务端 `nodes()` 用一条 IN 查询挂上去的) */
vehiclePlate: string;
/** 已修正次数(>0 才显示「已修正 N 次」徽章) */
revisionN: number;
/** 这一段的参与人 */
members: FbTripNodePerson[];
/** 照片 / 附件(图片直接铺缩略图墙) */
attachs: FbTripNodePhoto[];
}
/** 行程节点里的一个人(服务端 `Trip::nodeMembersFor()` 的返回) */
export interface FbTripNodePerson {
id: number;
/** 本名 */
name: string;
/** 「我叫他什么」(服务端 `my_nickname`;空则用 name) */
nickname: string;
avatarColor: string;
/** 头像 key(裸 key,渲染时走 `fbImageSrc`) */
avatar: string;
}
/** 行程节点上的一个附件(图片/视频/文件) */
export interface FbTripNodePhoto {
id: number;
/** 存储相对 key(裸 key,渲染时走 `fbImageSrc`) */
key: string;
/** 原始文件名(非图片才有意义) */
name: string;
/** 1 = 图片,可直接铺缩略图 */
isImage: boolean;
}
/** 旅程详情页右上角的五项统计(服务端 `Trip::summary()`) */
export interface FbTripSummary {
/** 参与人(带「我叫他什么」) */
people: FbTripNodePerson[];
/** 去过的城市(去重) */
cities: string[];
/** 天数(含首尾;0 = 还没有行程) */
days: number;
/** 段数(只数计划 + 实际,未成行单独算) */
nodeCount: number;
/** 未成行的段数 */
skipped: number;
/** 总花销(分) */
cents: number;
}
/**
* 旅程详情(`GET /api/v1/trips/<id>` + `/summary` 合成)。
*
* ⚠️ 列表项的 `FbTripItem` 不够用:详情页要 `cities` 数组(不是拼好的串)、
* 要一天的文案(`activitySpan`),还要参与人名单。
*/
export interface FbTripDetail {
id: number;
title: string;
/** 一段时间文案(服务端 `activitySpan`:「2026年10月2日 – 2026年10月3日」) */
dateRange: string;
coverKey: string;
summary: string;
/** 是否公开(编辑表单要回填) */
isPublic: boolean;
cities: string[];
attendees: FbTripNodePerson[];
}
/**
+8
View File
@@ -16,6 +16,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbAppState, FB_WIDE_BREAKPOINT, fbAppState } from '../common/Store';
import { fbModuleColor } from '../model/Modules';
import { FbDrawerShell } from '../components/Drawer';
import { FbTopBar } from '../components/TopBar';
import { FB_GRIP, fbStartGripWatch } from '../common/FbGrip';
@@ -23,6 +24,8 @@ import { FbFloatDock, FB_DOCK_HIDE_Y } from '../components/FloatDock';
import { FbHomePage } from './home/FbHomePage';
import { FbMoviesPage } from './movies/FbMoviesPage';
import { FbTripsPage } from './trips/FbTripsPage';
/* 🔴 v75:旅程详情是**整页**(用户:「点击一个旅程,应该进入到一个新的界面」) */
import { FbTripDetailPage } from './trips/FbTripDetailPage';
import { FbCalendarPage } from './calendar/FbCalendarPage';
import { FbTasksPage } from './tasks/FbTasksPage';
import { FbPeoplePage } from './people/FbPeoplePage';
@@ -669,6 +672,11 @@ struct Index {
FbNotesPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === '__notifications') {
FbNotificationsPage({ app: this.app, topBar: this.topBarSlot })
/* 🔴 v75(用户 2026-10-21):旅程详情整页。
⚠️ 必须传 `accent`(旅程模块绿),否则详情页的按钮/圆点会回落砖红,
与列表页颜色不一致。 */
} else if (this.app.activeModule === '__trip_detail') {
FbTripDetailPage({ app: this.app, topBar: this.topBarSlot, accent: fbModuleColor('trips') })
} else if (this.app.activeModule === '__settings') {
FbSettingsPage({ app: this.app, topBar: this.topBarSlot })
} else if (this.app.activeModule === '__account') {
File diff suppressed because it is too large Load Diff
+171 -445
View File
@@ -1,24 +1,28 @@
/**
* 家书 FamilyBook · 旅程模块(鸿蒙端)
*
* 对应 Web 端 `templates/trips/index.php` + `trips/show.php`。
* 对应 Web 端 `templates/trips/index.php`(列表)+ `trips/show.php`(详情)。
*
* 术语照 Web 端**固定下来**的那套(用户明确要求):
* · **旅程** = 列表里的一条(容器,如「青岛·崂山 两日」);
* · **行程** = 旅程里面的一段一段(详情页时间轴上的节点)。
* · **行程** = 旅程里面的一段一段(详情页里的一组组节点)。
*
* 端上的形态:**上列表 / 下详情同屏**不现实(手机高度不够),
* 所以这页做「列表 + 展开选中项的行程摘要」;点某条旅程展开它的行程时间轴。
* 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。
* 🔴 v75(用户 2026-10-21)改了端上的形态:
* 「每一个旅程里面的行程才是重头戏……点击一个旅程,应该进入到**一个新的界面**,
* 在里面列出所有的行程,就跟网页版的一样。」
* ⇒ 本页只负责**列表**;点卡片进 `FbTripDetailPage`(整页,走
* `app.activeModule = '__trip_detail'`)。
* ⚠️ 原来那套「就地展开行程时间轴」(`expandedId` / `stageBlock` / `nodeRow`)
* 已整块删除 —— 卡片宽度放不下详情页要显示的内容(说明/路线/花销/参与人/照片)。
* 别把它加回来当"快捷查看":两套并存的代价是两份节点渲染要同时维护。
*/
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem, FbTripNode } from '../../model/Types';
import { fbMoney } from '../../model/DemoData';
import { FbTripItem } from '../../model/Types';
import { parseTrips } from '../../data/FbParse';
import { loadModule } from '../../data/FbModuleLoad';
import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
import { FbEmptyBox, FbIconButton, FbPrimaryButton } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
@@ -28,9 +32,7 @@ import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
/* v65:题图要走媒体代理(`Image()` 加不了自定义请求头,令牌得用 URL query 带过去) */
import { fbImageSrc } from '../../data/FbAttachQueue';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { fetchTripNodes } from '../../data/FbTripRepo';
import { FbTripNewSheet } from '../../components/FbTripNewSheet';
import { FbTripNodeSheet } from '../../components/FbTripNodeSheet';
/* v66:旅程三项设置(车辆 / 出行方式 / 节点类型) */
import { FbNodeTypeSheet, FbTravelModesSheet, FbVehicleSheet } from '../../components/FbTripsSettingsSheets';
@@ -60,20 +62,10 @@ export struct FbTripsPage {
@Local settingsOpen: boolean = false;
@Local trips: FbTripItem[] = [];
/**
* 行程节点。
*
* ⚠️ 接口 `/trips` 只给旅程**本身**(含 `node_count`),不给每一段。
* 所以这里保持空 —— 展开时才去拉 `/trips/<id>`(待实现)。
* 不假装有数据:宁可展开后显示"暂无行程段"。
*/
@Local nodes: FbTripNode[] = [];
/** 顶部提示(同步状态) */
@Local syncNote: string = '';
/** 模块主题色(旅程 = 绿) */
private accent: string = '#3F7D5A';
/** 当前展开的旅程 id(0 = 都没展开) */
@Local expandedId: number = 0;
/**
* 「新建旅程」浮层是否打开(v65)。
*
@@ -85,16 +77,8 @@ export struct FbTripsPage {
@Local editingId: number = 0;
@Local editingTitle: string = '';
@Local editingSummary: string = '';
/** 行程节点正在加载(展开时拉 `/trips/<id>/nodes`) */
@Local nodesLoading: boolean = false;
/** 节点加载失败的说明(空 = 没失败) */
@Local nodesNote: string = '';
/** 哪一趟旅程的节点是「当前这份 `nodes`」(避免 A 的节点画到 B 上) */
@Local nodesTripId: number = 0;
/** 题图加载失败集合(一张图坏了不该反复重试,会让列表一直闪) */
@Local coverFailedIds: number[] = [];
/** 「加一段行程」浮层是否打开,以及它属于哪一趟旅程(0 = 关着) */
@Local nodeSheetTripId: number = 0;
/**
* 🔴 v66(用户 2026-10-20):「旅程的……车辆管理、出行方式、节点设置,
@@ -124,10 +108,6 @@ export struct FbTripsPage {
private backOnce(): boolean {
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 */
if (this.nodeSheetTripId > 0) {
this.nodeSheetTripId = 0;
return true;
}
/* v66:三项设置浮层。⚠️ 内部还有"表单展开 / 待确认删除"两层,
按返回先由浮层自己处理(组件的遮罩 onClick 也会关)—— 这里只负责关整层。 */
if (this.vehiclesOpen) {
@@ -150,11 +130,8 @@ export struct FbTripsPage {
this.settingsOpen = false;
return true;
}
// 行程展开着时,返回先收起它(否则用户只能靠再点一次卡片)
if (this.expandedId !== 0) {
this.expandedId = 0;
return true;
}
/* 🔴 v75:原来这里还有「收起展开的行程」一层 —— 就地展开那套已删除
(点卡片改成进详情页了)。别留一个永远为假的判断:`expandedId` 已不存在。 */
return false;
}
@@ -197,12 +174,6 @@ export struct FbTripsPage {
fbAppState().sheetCount = this.newOpen ? 1 : 0;
}
/** 「加一段行程」浮层同样要让悬浮栏让位 */
@Monitor('nodeSheetTripId')
onNodeSheetChanged(): void {
fbAppState().sheetCount = this.nodeSheetTripId > 0 ? 1 : 0;
}
/**
* 三个设置浮层同样要让悬浮栏让位。
*
@@ -359,32 +330,12 @@ export struct FbTripsPage {
onSaved: (): void => {
this.newOpen = false;
// 保存后重新拉一遍列表(服务端的城市/时间是派生的,本地拼不出来)
this.expandedId = 0;
this.load();
}
})
.transition(fbSheetTransition())
}
// ---- 加一段行程浮层(v65)----
if (this.nodeSheetTripId > 0) {
FbTripNodeSheet({
tripId: this.nodeSheetTripId,
accent: this.accent,
onClose: (): void => {
this.nodeSheetTripId = 0;
},
onSaved: (): void => {
const id: number = this.nodeSheetTripId;
this.nodeSheetTripId = 0;
// 加完重拉节点(服务端会重算派生时间,列表也要跟着变)
this.load();
this.loadNodes(id);
}
})
.transition(fbSheetTransition())
}
// ---- 旅程三项设置浮层(v66)----
if (this.vehiclesOpen) {
FbVehicleSheet({
@@ -421,227 +372,184 @@ export struct FbTripsPage {
}
/**
* 一张旅程卡:**题图 + 标题 + 时间/城市/段数 + 简介 + 同行人**。
* 一张旅程卡:**题图在上(占满整宽)+ 标题与信息在下**。
*
* 🔴 v65(用户 2026-10-20):「旅程的列表也不太对呀,应该有,题图啊、标题啊、
* 然后城市啊,应该跟网页版的题图是类似的。」
* ⇒ 照 `templates/trips/_list_body.php` 的 `trip-card` 重做:
* 网页版是**横排**(左题图 132×96 + 右 `trip-main`)。
* 手机上按同样比例缩到 96×72(4:3),右边留足文字宽度。
* 🔴🔴 v75(用户 2026-10-21 原话):
* 「这个旅程的图片是比较重要的,所以我建议就是这个显示的时候啊,
* 图片在正上方,就是充满整个的这个列表的宽度。然后呢把这个标题
* 还有其他的一些信息放在下面。如果是图片放左边,信息放右边的话,
* 它总是被挤占,显示不全。这样的话就可以显示更多的信息。
* 想看其他的这个旅程的话,直接往下滑就可以了。」
*
* ⚠️ 题图比例:网页版列表卡是 132×96 ≈ 4:3,而**详情页**的 hero 是 16:9。
* 这里按列表卡的 4:3 走(与用户要的"跟网页版类似"一致)。
* ⇒ 两条都是**用户点名的**,别改回去:
* ① 题图**横跨整张卡**(`width('100%')` + 定比例),不再是左上角 96×72 小图;
* ② 信息**全部移到图下面**(原来在右边,被 96 宽的小图挤得只剩一半宽度,
* 标题折两行、简介被截、城市被省略号吃掉)。
*
* ⚠️ 题图比例用 **16:9**,不是网页版列表卡的 4:3:
* `app/ImageSlot.php` 里 `cover` 槽位是 **1920×1080(16:9)**,
* 上传时按 16:9 裁的。用 4:3 显示会把两边裁掉 —— 正是用户当年在网页版
* 报过的「题图比例不对,把两边裁掉了」。整宽 + 16:9 = 图**原样**呈现。
*
* ⚠️ 点卡片 = **进详情页**(不再就地展开)。行程才是重头戏,一屏展开放不下。
*/
@Builder
tripCard(t: FbTripItem) {
Column({ space: 0 }) {
// ---- 卡头:左题图 + 右内容(横排,同网页版)----
Row({ space: 12 }) {
// 题图:有图放真图,没图放占位(网页版是 🧳)
Stack({ alignContent: Alignment.Center }) {
if (t.coverKey !== '') {
Image(fbImageSrc(t.coverKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
// ⚠️ 不给 alt 图标:旅程封面是实景照,失败时给纯色底比给个
// "行李箱图标"更不刺眼(网页版失败时也是底色调)
.onError((): void => {
this.markCoverFailed(t.id);
})
}
// 有图但加载失败时,也别留一块透明 —— 统一给底色 + 图标
if (t.coverKey === '' || this.coverFailed(t.id)) {
Column({ space: 4 }) {
Image($r('app.media.fb_ic_trips'))
.width(22)
.height(22)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
}
// ---- 题图:占满整宽,16:9(与上传槽位比例一致)----
Stack({ alignContent: Alignment.Center }) {
if (t.coverKey !== '' && !this.coverFailed(t.id)) {
Image(fbImageSrc(t.coverKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1E7D6')
.objectFit(ImageFit.Cover)
// ⚠️ 不给 alt 图标:旅程封面是实景照,失败时给纯色底比给个
// "行李箱图标"更不刺眼(网页版失败时也是底色调)
.onError((): void => {
this.markCoverFailed(t.id);
})
}
// 没图 / 图坏了:统一给底色 + 图标(不能留一块透明)
if (t.coverKey === '' || this.coverFailed(t.id)) {
Column({ space: 6 }) {
Image($r('app.media.fb_ic_trips'))
.width(30)
.height(30)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint('#C9B8A4'))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#F1E7D6')
}
}
.width('100%')
.aspectRatio(16 / 9)
.clip(true)
// ---- 图下面的信息区(挤占问题消失,能放的都放进来)----
Column({ space: 6 }) {
// 标题
Text(t.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// 时间 · 城市(一行放得下了 —— 整宽之后不再被小图挤)
Row({ space: 10 }) {
if (t.dateRange !== '') {
Text(t.dateRange)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.maxLines(1)
}
if (t.city !== '') {
Text('📍 ' + t.city)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
} else {
Column().layoutWeight(1)
}
}
.width(96)
.height(72)
.borderRadius(FbMetric.radiusS)
.clip(true)
.flexShrink(0)
.width('100%')
.alignItems(VerticalAlign.Center)
// 右侧:标题 / meta / 简介
Column({ space: 4 }) {
Text(t.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(2)
// 段数 / 已完成 / 同行人数
Row({ space: 8 }) {
Text(t.nodeCount.toString() + ' 段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(t.nodeCount > 0 ? this.accent : FbColors.faint)
if (t.doneCount > 0) {
Text('已完成 ' + t.doneCount.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.attendeeCount > 0) {
Text('· ' + t.attendeeCount.toString() + ' 人同行')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
}
.width('100%')
// 简介:整宽之后可以给到 3 行(原来挤在右边只有 2 行)
if (t.summary !== '') {
Text(t.summary)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// meta 一行:时间 · 📍城市 · N 段行程
Row({ space: 10 }) {
if (t.dateRange !== '') {
Text(t.dateRange)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
if (t.city !== '') {
Text('📍 ' + t.city)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.layoutWeight(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
} else {
Column().layoutWeight(1)
}
}
.width('100%')
// 段数 + 同行人数(网页版是一排 meta,这里小屏分两行更清楚)
Row({ space: 8 }) {
Text(t.nodeCount.toString() + ' 段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(t.nodeCount > 0 ? this.accent : FbColors.faint)
if (t.doneCount > 0) {
Text('已完成 ' + t.doneCount.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.attendeeCount > 0) {
Text('· ' + t.attendeeCount.toString() + ' 人同行')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
}
.width('100%')
// 简介:网页版截 80 字,这里两行截断(手机一行更窄)
if (t.summary !== '') {
Text(t.summary)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.lineHeight(17)
}
.lineHeight(18)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.flexShrink(1)
// 卡尾:进详情 + 编辑
Row({ space: 12 }) {
/* 🔴 这颗「看行程」是**主要入口**,必须自己带 onClick(v66 真机踩过:
屏幕上写着"查看行程"却是纯文字,点它什么都不发生)。 */
Text('看行程')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.padding({ left: 6, right: 12, top: 8, bottom: 6 })
.onClick((): void => {
this.openDetail(t);
})
Column().layoutWeight(1)
Text('编辑')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.openEdit(t);
})
Image($r('app.media.fb_ic_forward'))
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding(FbMetric.cardPad)
.alignItems(VerticalAlign.Top)
.onClick((): void => {
// 点卡片主体 = 展开/收起行程(网页版是"进详情",手机上一屏放不下,
// 就地展开更顺手;编辑走下面那颗小按钮)
this.toggleExpand(t);
})
// ---- 卡尾操作条:编辑(网页版进详情页才有编辑,这里给个显式入口)----
Row({ space: 14 }) {
/* 🔴 这颗原来只是「字」,没有 `onClick` —— 屏幕上写着"查看行程"却点不动。
现在接上 `toggleExpand`(与卡片主体同一份逻辑),并加内边距把热区做大
(字号只有 fontTiny,纯文字热区太小,真机上不好点)。 */
Text(this.isExpanded(t.id) ? '收起行程' : '查看行程')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.padding({ left: 6, right: 10, top: 8, bottom: 8 })
.onClick((): void => {
this.toggleExpand(t);
})
Column().layoutWeight(1)
Text('编辑')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.openEdit(t);
})
Image($r('app.media.fb_ic_forward'))
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
.rotate({ angle: this.isExpanded(t.id) ? 90 : 0 })
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 12 })
.alignItems(VerticalAlign.Center)
// ---- 展开区:行程时间轴 ----
if (this.isExpanded(t.id)) {
Divider()
.color(FbColors.line2)
.margin({ left: FbMetric.cardPad, right: FbMetric.cardPad })
if (this.nodesLoading) {
Row({ space: 8 }) {
LoadingProgress()
.width(16)
.height(16)
.color(this.accent)
Text('正在取行程…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding(FbMetric.cardPad)
.alignItems(VerticalAlign.Center)
} else if (this.nodesNote !== '') {
/* 拉失败时**说实话**,不假装"还没排行程" ——
后者会让用户以为自己的行程丢了。 */
Column({ space: 6 }) {
Text(this.nodesNote)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
Text('点这里重试')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
.onClick((): void => {
this.loadNodes(t.id);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(FbMetric.cardPad)
} else if (this.nodes.length === 0) {
Column({ space: 8 }) {
Text('这个旅程还没有排行程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.width('100%')
this.addNodeBtn(t.id)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(FbMetric.cardPad)
} else {
Column({ space: 0 }) {
// 分两段列:「实际」(已发生的)在前,「计划」在后 —— 与 Web 端顺序一致
this.stageBlock('实际', 'actual')
this.stageBlock('计划', 'plan')
// 已有节点时,加号排在最后(像网页版时间轴的末尾)
this.addNodeBtn(t.id)
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: FbMetric.cardPad })
}
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.clip(true)
.alignItems(HorizontalAlign.Start)
.onClick((): void => {
/* 整卡可点(比只点那行小字好按得多)。⚠️ 卡里的「编辑」自己带 onClick,
点它会冒泡到这里吗?ArkUI 的 onClick 不冒泡到父容器(子组件消费掉),
所以「编辑」不会误进详情 —— 但两者的热区都在卡内,别把编辑挪出去。 */
this.openDetail(t);
})
}
/**
* 进入旅程详情页(**整页**,不是就地展开)。
*
* 🔴 必须**同时**置两个状态:
* `tripDetailId` —— 看哪一趟;
* `activeModule = '__trip_detail'` —— 走哪条分发。
* 只置前者 ⇒ 画面不动(还停在列表);只置后者 ⇒ 打开上一次看过的那趟。
* ⚠️ 用 `switchModule()` 而不是直接赋 `activeModule`:它会把"当前页"压进
* `moduleHistory`,返回手势才回得来(`Index.onBackPress` 靠它)。
*/
private openDetail(t: FbTripItem): void {
this.app.tripDetailId = t.id;
this.app.switchModule('__trip_detail');
}
/** 打开编辑浮层(预填当前值) */
@@ -652,76 +560,6 @@ export struct FbTripsPage {
this.newOpen = true;
}
/** 展开区底部那颗「+ 加一段行程」 */
@Builder
addNodeBtn(tripId: number) {
Row({ space: 6 }) {
Image($r('app.media.fb_ic_plus'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(this.accent))
Text('加一段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accent)
}
.padding({ left: 10, right: 12, top: 7, bottom: 7 })
.backgroundColor(fbAlpha(this.accent, 0.08))
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
.onClick((): void => {
this.nodeSheetTripId = tripId;
})
}
/**
* 拉某趟旅程的行程节点。
*
* 🔴 v65:这是端上原先**最大**的缺口 —— `nodes` 一直是空数组,
* 所以每趟旅程展开后永远显示"还没有排行程"。
* 用户原话:「现在这个旅程下面也没有这个行程啊」。
*
* ⚠️ 三个状态要分清(否则用户会以为数据丢了):
* ① 正在拉 → 转圈;② 拉失败 → 明说"没取到" + 可重试;
* ③ 拉成功但确实没有 → 才说"还没排行程"。
*/
private async loadNodes(tripId: number): Promise<void> {
if (this.nodesLoading) {
return;
}
this.nodesLoading = true;
this.nodesNote = '';
this.nodesTripId = tripId;
const rows: FbTripNode[] = await fetchTripNodes(FB_TOKEN_STORE.get(), tripId);
this.nodesLoading = false;
/* ⚠️ 用户可能在等待期间又点了别的旅程 ⇒ 只认"当前还展开着的那一趟",
否则 A 的节点会画到 B 的展开区里。 */
if (this.expandedId !== tripId) {
return;
}
this.nodes = rows;
/* 拉回来是空数组有两种可能:真的没有 / 请求失败。`fetchTripNodes` 失败也回 [],
这里无法区分 ⇒ 由下面 `node_count` 兜一道:
列表里说有 N 段、这里却是 0 ⇒ 那就是没取到,要明说。 */
if (rows.length === 0) {
const cur: FbTripItem | null = this.tripById(tripId);
if (cur !== null && cur.nodeCount > 0) {
this.nodesNote = '行程没取到(网络或权限问题),列表里显示有 '
+ cur.nodeCount.toString() + ' 段。';
}
}
}
private tripById(id: number): FbTripItem | null {
for (const t of this.trips) {
if (t.id === id) {
return t;
}
}
return null;
}
private markCoverFailed(id: number): void {
if (this.coverFailedIds.indexOf(id) < 0) {
this.coverFailedIds = this.coverFailedIds.concat([id]);
@@ -732,116 +570,4 @@ export struct FbTripsPage {
return this.coverFailedIds.indexOf(id) >= 0;
}
/** 一段阶段块(实际 / 计划)+ 它下面的节点时间轴 */
@Builder
stageBlock(title: string, stage: string) {
if (this.nodesOf(stage).length > 0) {
Column({ space: 0 }) {
Row({ space: 6 }) {
FbTag({
text: title,
tone: stage === 'actual' ? 'green' : 'blue'
})
Text(this.nodesOf(stage).length.toString() + ' 段')
.fontSize(10)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 10, bottom: 6 })
ForEach(this.nodesOf(stage), (n: FbTripNode) => {
this.nodeRow(n)
}, (n: FbTripNode): string => 'tn_' + stage + '_' + n.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
/** 一个行程节点:左侧圆点 + 内容块,内容块的左边框连成时间轴竖线 */
@Builder
nodeRow(n: FbTripNode) {
Row({ space: 8 }) {
// 圆点:顶对齐,与标题首行齐平
FbDot({
dia: 9,
fillC: n.status === 'done' ? this.accent : '#A8988A'
})
.margin({ top: 5 })
// 内容块:左边框 = 时间轴的竖线(节点之间无间距,边框首尾相接成为连续线)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(n.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(n.when)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
Text(n.place)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Row({ space: 6 }) {
if (n.mode.length > 0) {
FbTag({ text: n.mode, tone: 'blue' })
}
if (n.status === 'done') {
FbTag({ text: '已完成', tone: 'green' })
} else if (n.status === 'skip') {
FbTag({ text: '跳过', tone: 'muted' })
} else {
FbTag({ text: '待办', tone: 'muted' })
}
if (n.expenseCent > 0) {
Text(fbMoney(n.expenseCent))
.fontSize(10)
.fontColor(FbColors.muted)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, bottom: 16 })
.border({ width: { left: 1 }, color: FbColors.line })
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
private isExpanded(id: number): boolean {
return this.expandedId === id;
}
/**
* 展开 / 收起某个旅程的行程时间轴。
*
* 🔴 v66 真机验收补漏(2026-10-20):原来这段逻辑**只写在卡片主体的 onClick 里**,
* 而卡尾那颗「查看行程 / 收起行程」是一颗**没有任何 onClick 的纯 Text**
* ⇒ 屏幕上明明写着「查看行程」,点它却什么都不发生(真机验收 C2/C3/C4 就是这么红的)。
* 这类"看得见、点不动"的入口是本项目的红线,必须两处都能点。
* ⇒ 抽成一个方法,卡片主体与卡尾文字共用,行为不会漂。
*/
private toggleExpand(trip: FbTripItem): void {
const was: boolean = this.isExpanded(trip.id);
this.expandedId = was ? 0 : trip.id;
if (!was) {
this.loadNodes(trip.id);
}
}
private nodesOf(stage: string): FbTripNode[] {
return this.nodes.filter((n: FbTripNode): boolean => n.stage === stage);
}
}
}