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
+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);
}
}
}