From 3e2904cc3dbcabc1c22f774480cdab96b1aae19c Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 22 Oct 2026 00:07:16 +0800 Subject: [PATCH] =?UTF-8?q?v75=20=E6=97=85=E7=A8=8B=E9=87=8D=E6=9E=84?= =?UTF-8?q?=EF=BC=9A=E5=88=97=E8=A1=A8=E9=A1=B5=20+=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=95=B4=E9=A1=B5=EF=BC=8C=E5=8D=A1=E7=89=87=E6=94=B9=E3=80=8C?= =?UTF-8?q?=E5=9B=BE=E5=9C=A8=E4=B8=8A=E3=80=81=E4=BF=A1=E6=81=AF=E5=9C=A8?= =?UTF-8?q?=E4=B8=8B=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 --- entry/src/main/ets/common/Store.ets | 34 + .../main/ets/components/FbTripNodeSheet.ets | 13 + entry/src/main/ets/components/TopBar.ets | 5 + entry/src/main/ets/data/FbTripRepo.ets | 226 ++- entry/src/main/ets/model/DemoData.ets | 64 +- entry/src/main/ets/model/Modules.ets | 8 +- entry/src/main/ets/model/Types.ets | 107 +- entry/src/main/ets/pages/Index.ets | 8 + .../main/ets/pages/trips/FbTripDetailPage.ets | 1243 +++++++++++++++++ .../src/main/ets/pages/trips/FbTripsPage.ets | 616 +++----- 10 files changed, 1862 insertions(+), 462 deletions(-) create mode 100644 entry/src/main/ets/pages/trips/FbTripDetailPage.ets diff --git a/entry/src/main/ets/common/Store.ets b/entry/src/main/ets/common/Store.ets index 3036df9..8372a25 100644 --- a/entry/src/main/ets/common/Store.ets +++ b/entry/src/main/ets/common/Store.ets @@ -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; diff --git a/entry/src/main/ets/components/FbTripNodeSheet.ets b/entry/src/main/ets/components/FbTripNodeSheet.ets index 21d05b6..57f5aa2 100644 --- a/entry/src/main/ets/components/FbTripNodeSheet.ets +++ b/entry/src/main/ets/components/FbTripNodeSheet.ets @@ -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() { diff --git a/entry/src/main/ets/components/TopBar.ets b/entry/src/main/ets/components/TopBar.ets index 3260eee..c9947e4 100644 --- a/entry/src/main/ets/components/TopBar.ets +++ b/entry/src/main/ets/components/TopBar.ets @@ -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 : '家书'; } diff --git a/entry/src/main/ets/data/FbTripRepo.ets b/entry/src/main/ets/data/FbTripRepo.ets index 6b5ce95..2144a16 100644 --- a/entry/src/main/ets/data/FbTripRepo.ets +++ b/entry/src/main/ets/data/FbTripRepo.ets @@ -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/`)。 + * + * 与列表项的区别:这里带 `cities` 数组、`activitySpan` 折好的时间串、 + * 完整参与人名单、`is_public` —— 详情页头部那一整块都读它。 + * + * @returns 失败返回 `null`(页面据此显示"没取到,可重试",不假装空白) + */ +export async function fetchTripDetail(token: string, tripId: number): Promise { + 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 = raw as Record; + 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//summary`)。 + * + * 🔴 五项**全部由服务端从行程里算**(`Trip::summary()`)—— + * 端上绝不能自己用 nodes 求和: + * ① 天数要按"最早开始 / 最晚结束"跨天算,不是数条数; + * ② 城市要 union 出发地到达地去重; + * ③ 花销在**花销表**里(可能在别的月份,节点上不一定看得到)。 + */ +export async function fetchTripSummary(token: string, tripId: number): Promise { + 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 = raw as Record; + 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 { /* 同上:逐键赋值(ArkTS 不允许 `Record` 的字面量初始化) */ @@ -226,3 +316,135 @@ function placeOf(o: Record): 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, 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, 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, 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 = it as Record; + 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, 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 = it as Record; + 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 { + 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() + '日'; +} diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index 1899284..dbc7e19 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -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: [] + } ]; // ============================================================ diff --git a/entry/src/main/ets/model/Modules.ets b/entry/src/main/ets/model/Modules.ets index 89824cc..30fd0bf 100644 --- a/entry/src/main/ets/model/Modules.ets +++ b/entry/src/main/ets/model/Modules.ets @@ -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'; } diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index 56109b3..f8475ca 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -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/` + `/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[]; } /** diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 4df27c3..9a2facc 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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') { diff --git a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets new file mode 100644 index 0000000..9fb1976 --- /dev/null +++ b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets @@ -0,0 +1,1243 @@ +/** + * 家书 FamilyBook · 旅程详情页(鸿蒙端) + * + * 🔴🔴 v75(用户 2026-10-21 原话): + * 「旅程这个需要重新修改。一个就是说每一个旅程里面的行程才是重头戏, + * 因为行程里面有很多的记录,还有很多的照片等等。所以点击一个旅程, + * 应该进入到一个新的界面,在里面列出所有的行程,就跟网页版的一样。」 + * + * ⇒ 这是端上**第一个**真正的旅程详情页。原来是把行程「就地展开」在列表卡里 + * (`expandedId` + `stageBlock`),受卡片宽度和展开高度限制,只能塞 + * 标题 / 时间 / 地点 / 三个小标签 —— 网页版 `templates/trips/show.php` + * 里那些「详细说明 / 出发地→到达地 / 车次 / 车牌 / 花销 / 参与人 / 照片」 + * 一样都放不下。用户说得很明确:**行程才是重头戏**。 + * + * ⚠️ 为什么是「整页」而不是浮层: + * 与 `FbAccountPage` 同一个理由 —— 用户说的是"进入到一个新的界面"。 + * 浮层(`bindSheet`/自绘 Stack)在半屏高度里滚十几段行程很难受, + * 而且返回手势、顶栏标题、悬浮栏让位三样都要单独处理;整页这三样都自动对。 + * ⇒ 走 `app.activeModule = '__trip_detail'` 这条分发(见 `Index.moduleContent`), + * 由 `app.tripDetailId` 记住在看哪一趟。 + * + * 页面结构(**照网页版 `trips/show.php`**,顺序与分组名都不改): + * ① 页头:标题 + 动作区(+计划行程 蓝 / +实际行程 绿 / 编辑 黄 / 删除 红 / 设置 ☰) + * ② 题图 16:9(**占满整宽**,与网页版 hero 一致) + * ③ 信息区:时间 · 城市 / 五项统计 / 参与的人 / 简介 + * ④ 行程分组:计划 → 实际 → 未成行,**空分组不显示** + * ⑤ 每个节点:类型图标 + 标题 + 类型徽章 + 修正次数 + 交通方式(车次/车牌) + * + 时间 + 线路/承运方 + 出发地→到达地 + 说明 + 花销 + 参与人 + 照片 + * + * ⚠️ 配色是**契约**不是装饰(v41 用户硬要求): + * 计划=蓝 / 实际=绿 / 编辑=黄 / 删除=红 / 设置图标不变。 + * 守卫里有对应断言,改色会被抓。 + */ + +import { FbColors, FbMetric, fbAlpha, fbTint, FB_MASK, FbTint } from '../../common/Theme'; +import { FbAppState, fbAppState } from '../../common/Store'; +import { + FbAvatar, FbDot, FbEmptyBox, FbIconButton, FbTag +} from '../../components/Widgets'; +import { FB_DOCK_RESERVE } from '../../components/FloatDock'; +import { fbOnScroll, fbOnTouch } from '../../common/FbScroll'; +import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim'; +import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; +import { FB_BACK } from '../../common/FbBack'; +import { FB_SET_TRIPS } from '../../model/FbSettingsMenus'; +import { fbImageSrc } from '../../data/FbAttachQueue'; +import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; +import { + deleteTrip, deleteTripNode, fetchTripDetail, fetchTripNodes, fetchTripSummary +} from '../../data/FbTripRepo'; +import { + FbTripDetail, FbTripItem, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary +} from '../../model/Types'; +import { fbMoney } from '../../model/DemoData'; +import { FbTripNewSheet } from '../../components/FbTripNewSheet'; +import { FbTripNodeSheet } from '../../components/FbTripNodeSheet'; +import { FbNodeTypeSheet, FbTravelModesSheet, FbVehicleSheet } from '../../components/FbTripsSettingsSheets'; + +/** + * 节点类型 → 图标。 + * + * ⚠️ 两种键都要认(服务端 `Trip::typeWord()` 同一套口径): + * ① 老数据的英文 key(`play` / `meal` / `move` / `stay` / `other`); + * ② 用户在「节点类型」里自定义的**中文名字**(如「参观」)。 + * 自定义的取不到固定图标,给一个中性圆点 —— 不能空着(空的看起来像没加载出来)。 + */ +function fbNodeTypeIcon(key: string): string { + if (key === 'play') { + return '🎡'; + } + if (key === 'meal') { + return '🍜'; + } + if (key === 'move') { + return '🚗'; + } + if (key === 'stay') { + return '🏨'; + } + return '📌'; +} + +/** 节点类型 → 中文名(老数据的英文 key;自定义名字原样返回) */ +function fbNodeTypeWord(key: string): string { + if (key === 'play') { + return '游玩'; + } + if (key === 'meal') { + return '用餐'; + } + if (key === 'move') { + return '赶路'; + } + if (key === 'stay') { + return '住宿'; + } + if (key === 'other') { + return '其他'; + } + return key; +} + +/** 出行方式 key → [中文名, 图标](与 `Trip::TRAVEL_MODES` 保持一致) */ +function fbModeWord(key: string): string { + if (key === 'walk') { + return '步行'; + } + if (key === 'bike') { + return '骑行'; + } + if (key === 'drive') { + return '自驾'; + } + if (key === 'taxi') { + return '打车'; + } + if (key === 'bus') { + return '公交'; + } + if (key === 'subway') { + return '地铁'; + } + if (key === 'train') { + return '火车'; + } + if (key === 'plane') { + return '飞机'; + } + if (key === 'ship') { + return '轮船'; + } + if (key === 'other') { + return '其他'; + } + return ''; +} + +function fbModeIcon(key: string): string { + if (key === 'walk') { + return '🚶'; + } + if (key === 'bike') { + return '🚲'; + } + if (key === 'drive') { + return '🚗'; + } + if (key === 'taxi') { + return '🚕'; + } + if (key === 'bus') { + return '🚌'; + } + if (key === 'subway') { + return '🚇'; + } + if (key === 'train') { + return '🚄'; + } + if (key === 'plane') { + return '✈️'; + } + if (key === 'ship') { + return '🚢'; + } + return '🧭'; +} + +/** + * 出行方式后面那个「标识」。 + * + * 🔴 v41 用户第 8 条原话(这是**产品规则**,不是排版偏好): + * 「把火车的车次号直接放在"火车"这两个字后面。如果是自驾, + * 就应该把自驾的车牌号放在后面。」 + * ⇒ 火车/飞机 → 车次航班号;自驾 → 车牌号。别把车次丢到下面一排标签里 + * (那正是"隔了一段、看不出是同一件事"的来源)。 + */ +function fbModeExtra(n: FbTripNode): string { + if (n.mode === 'drive' && n.vehiclePlate !== '') { + return n.vehiclePlate; + } + return n.tripNo; +} + +/** 参与人怎么称呼:有「我叫他什么」优先(与全站口径一致) */ +function fbPersonName(p: FbTripNodePerson): string { + return p.nickname !== '' ? p.nickname : p.name; +} + +/** + * 分组标题的色(网页版 `$groupMeta` 那三组)。 + * + * ⚠️ `FbTag` 的 `tone` 只认 'brand'/'red'/'green'/'blue'/'muted' 五个值, + * 传别的会**静默退回 muted**(看起来像"配色没生效")。所以这里用对象带出去, + * 标题与说明各自取色,不硬塞进 `tone`。 + */ +class FbStageTone { + /** `FbTag` 的 tone */ + tag: string = 'muted'; + /** 说明文字/边框的点缀色 */ + hex: string = '#7E6B5A'; + + constructor(tag: string, hex: string) { + this.tag = tag; + this.hex = hex; + } +} + +function fbStageTone(stage: string): FbStageTone { + if (stage === 'actual') { + return new FbStageTone('green', '#4E7C3F'); + } + if (stage === 'skipped') { + return new FbStageTone('muted', '#7E6B5A'); + } + return new FbStageTone('blue', '#2F6FB0'); +} + +@ComponentV2 +export struct FbTripDetailPage { + + /** + * 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面(随页面一起滚走)。 + */ + @BuilderParam topBar: () => void; + @Param app: FbAppState = fbAppState(); + + /** 模块主题色(旅程 = 绿),由 `Index` 传,与列表页保持同一个色 */ + @Param accent: string = '#3F7D5A'; + + // ---- 数据 ---- + /** 旅程本体(null = 还没取到) */ + @Local detail: FbTripDetail | null = null; + /** 五项统计(null = 还没取到) */ + @Local stat: FbTripSummary | null = null; + /** 全部行程节点(三组都在这里,渲染时按 stage 过滤) */ + @Local nodes: FbTripNode[] = []; + /** 加载态:正在取 */ + @Local loading: boolean = true; + /** 取不到时的说明(空 = 没失败)。**不许**用"还没有行程"糊过去 */ + @Local loadNote: string = ''; + /** 当前在看哪一趟(`app.tripDetailId` 的快照,用于判断"用户是否又点了别的") */ + private tripId: number = 0; + /** 题图是否加载失败(一张坏图不该反复重试) */ + @Local coverFailed: boolean = false; + + // ---- 浮层 ---- + @Local settingsOpen: boolean = false; + /** 新建/编辑旅程浮层(详情页也能改名字与简介) */ + @Local editOpen: boolean = false; + /** 「加一段行程」浮层:>0 = 开着,值 = 要往哪一趟加 */ + @Local nodeSheetTripId: number = 0; + /** 加行程浮层的预选阶段('plan' / 'actual') */ + @Local nodeSheetStage: string = 'plan'; + /** 三项设置浮层 */ + @Local vehiclesOpen: boolean = false; + @Local travelModesOpen: boolean = false; + @Local nodeTypesOpen: boolean = false; + /** 正在删的行程节点 id(0 = 没有待确认的) */ + @Local delNodeId: number = 0; + @Local deleting: boolean = false; + + aboutToAppear(): void { + /* ⚠️ 登记 key 用**跟列表页不同的名字**:两个页面不会同时挂载, + 但 `FB_BACK` 的语义是"后登记的先被问",同名会让注销互相踩。 */ + FB_BACK.register('trip_detail', (): boolean => this.backOnce()); + this.tripId = this.app.tripDetailId; + this.reload(); + } + + aboutToDisappear(): void { + FB_BACK.unregister('trip_detail'); + } + + /** + * 系统返回手势:**先关浮层,再返回列表**。 + * + * 顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 + * 这里最后一步 `return false` 把控制权交回 `Index`(它负责换模块回列表)。 + */ + private backOnce(): boolean { + if (this.delNodeId > 0) { + this.delNodeId = 0; + return true; + } + if (this.nodeSheetTripId > 0) { + this.nodeSheetTripId = 0; + return true; + } + if (this.vehiclesOpen) { + this.vehiclesOpen = false; + return true; + } + if (this.travelModesOpen) { + this.travelModesOpen = false; + return true; + } + if (this.nodeTypesOpen) { + this.nodeTypesOpen = false; + return true; + } + if (this.editOpen) { + this.editOpen = false; + return true; + } + if (this.settingsOpen) { + this.settingsOpen = false; + return true; + } + return false; + } + + @Monitor('settingsOpen') + onSettingsChanged(): void { + fbAppState().sheetCount = this.settingsOpen ? 1 : 0; + } + + @Monitor('editOpen') + onEditChanged(): void { + fbAppState().sheetCount = this.editOpen ? 1 : 0; + } + + @Monitor('nodeSheetTripId') + onNodeSheetChanged(): void { + fbAppState().sheetCount = this.nodeSheetTripId > 0 ? 1 : 0; + } + + /* ⚠️ V2 里一个 @Monitor 只能盯一个变量 ⇒ 三个设置浮层各写一个。 + 它们不会同时为真(设置菜单点一项就关菜单、只开一个),所以直接赋值。 */ + @Monitor('vehiclesOpen') + onVehiclesChanged(): void { + fbAppState().sheetCount = this.vehiclesOpen ? 1 : 0; + } + + @Monitor('travelModesOpen') + onTravelModesChanged(): void { + fbAppState().sheetCount = this.travelModesOpen ? 1 : 0; + } + + @Monitor('nodeTypesOpen') + onNodeTypesChanged(): void { + fbAppState().sheetCount = this.nodeTypesOpen ? 1 : 0; + } + + /** + * 三个接口并发取:本体 / 统计 / 节点。 + * + * ⚠️ 为什么是三个而不是一个:服务端把它们拆开了(`/trips/`、 + * `/summary`、`/nodes`),各自独立性也确实是用户要的 + * (改节点不必重取统计?不,要重取 —— 见 `onNodeSaved`)。 + * 这里并发发起,谁先回来谁先落地,页面不会因为一个慢接口整体卡住。 + */ + private async reload(): Promise { + const token: string = FB_TOKEN_STORE.get(); + const id: number = this.tripId; + if (id <= 0) { + this.loading = false; + this.loadNote = '没找到这趟旅程,请返回重进。'; + return; + } + this.loading = true; + this.loadNote = ''; + const d: FbTripDetail | null = await fetchTripDetail(token, id); + const sm: FbTripSummary | null = await fetchTripSummary(token, id); + const rows: FbTripNode[] = await fetchTripNodes(token, id); + /* ⚠️ 用户可能在等待期间返回了列表 ⇒ 回来时 `detail` 不该被写进已卸载的页面, + 更要紧的是别让"上一趟的数据"落到这一趟上去。 */ + if (this.app.tripDetailId !== id) { + return; + } + this.detail = d; + this.stat = sm; + this.nodes = rows; + this.loading = false; + /* 三种情形要分清(否则用户以为数据丢了): + ① 本体都取不到 → 网络/权限问题,明说 + 可重试; + ② 本体有、统计取不到 → 不算失败(头部少五格,正文照样能读); + ③ 本体有、节点空了 → 这才是"真的还没排行程"。 */ + if (d === null) { + this.loadNote = '这趟旅程没取到(网络或权限问题)。'; + } + } + + build() { + Stack({ alignContent: Alignment.Bottom }) { + Scroll() { + Column({ space: 0 }) { + this.topBar() + + // ---- 页头:标题 + 动作区 ---- + this.headBar() + + if (this.loading) { + Row({ space: 8 }) { + LoadingProgress() + .width(18) + .height(18) + .color(this.accent) + Text('正在取旅程…') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + } + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 20, bottom: 20 }) + .alignItems(VerticalAlign.Center) + } else if (this.loadNote !== '') { + /* 拉失败时**说实话**,还带一个可点的重试 —— 不能只显示一句红字 */ + Column({ space: 8 }) { + Text(this.loadNote) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + Text('点这里重试') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + .padding({ top: 6, bottom: 6 }) + .onClick((): void => { + this.reload(); + }) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 20, bottom: 20 }) + } else { + // ---- 题图 + 信息区 ---- + this.heroCard() + // ---- 行程分组 ---- + this.nodeGroups() + } + } + .width('100%') + .padding({ bottom: FB_DOCK_RESERVE }) + } + .width('100%') + .height('100%') + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + .onDidScroll(fbOnScroll) + .onTouch(fbOnTouch) + .align(Alignment.TopStart) + + // ---- 模块设置浮层(照列表页那套,模板块复用) ---- + if (this.settingsOpen) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) + .onClick((): void => { + this.settingsOpen = false; + }) + + FbModuleSettingsSheet({ + accent: this.accent, + title: '旅程设置', + items: FB_SET_TRIPS, + onClose: (): void => { + this.settingsOpen = false; + }, + onPick: (action: string): void => { + /* ⚠️ 先关设置菜单再开目标浮层:两个叠着时 `sheetCount` 会被后赋值的覆盖 */ + this.settingsOpen = false; + if (action === 'vehicles') { + this.vehiclesOpen = true; + } else if (action === 'travelmodes') { + this.travelModesOpen = true; + } else if (action === 'nodetypes') { + this.nodeTypesOpen = true; + } + } + }) + .transition(fbSheetTransition()) + } + + // ---- 编辑旅程浮层(标题 / 简介 / 是否公开) ---- + if (this.editOpen && this.detail !== null) { + FbTripNewSheet({ + accent: this.accent, + editingId: this.detail.id, + editingTitle: this.detail.title, + editingSummary: this.detail.summary, + editingPublic: this.detail.isPublic, + onClose: (): void => { + this.editOpen = false; + }, + onSaved: (): void => { + this.editOpen = false; + // 保存后重拉本体(标题/简介是服务端回填的,本地拼不出来) + this.reload(); + } + }) + .transition(fbSheetTransition()) + } + + // ---- 加一段行程浮层 ---- + if (this.nodeSheetTripId > 0) { + FbTripNodeSheet({ + tripId: this.nodeSheetTripId, + accent: this.accent, + startStage: this.nodeSheetStage, + onClose: (): void => { + this.nodeSheetTripId = 0; + }, + onSaved: (): void => { + this.onNodeSaved(); + } + }) + .transition(fbSheetTransition()) + } + + // ---- 三项设置浮层 ---- + if (this.vehiclesOpen) { + FbVehicleSheet({ + accent: this.accent, + onClose: (): void => { + this.vehiclesOpen = false; + } + }) + .transition(fbSheetTransition()) + } + + if (this.travelModesOpen) { + FbTravelModesSheet({ + accent: this.accent, + onClose: (): void => { + this.travelModesOpen = false; + } + }) + .transition(fbSheetTransition()) + } + + if (this.nodeTypesOpen) { + FbNodeTypeSheet({ + accent: this.accent, + onClose: (): void => { + this.nodeTypesOpen = false; + } + }) + .transition(fbSheetTransition()) + } + + // ---- 删一段行程的确认框(破坏性操作,必须二次确认) ---- + if (this.delNodeId > 0) { + this.delNodeConfirm() + } + } + .width('100%') + .height('100%') + } + + /** + * 页头:标题(左)+ 动作区(右)。 + * + * 🔴🔴 v41 用户第 4 条给的**顺序与配色是硬要求**: + * 「旅程详情页面右上角的按钮,顺序分别是从左到右: + * +计划行程、+实际行程、编辑、删除、返回旅程和设置。 + * 计划行程的按钮用蓝色背景;实际行程的按钮用绿色背景; + * 编辑用黄色背景;删除用红色背景;设置就用这个图标,不要变。」 + * ⚠️ 手机宽度放不下五颗带字的按钮 ⇒ **分两行**(第一行两颗「+」, + * 第二行编辑/删除),但**相对顺序与配色一字不改**。 + * 这不是"重新设计",是被屏幕宽度逼的排版折行。 + */ + @Builder + headBar() { + Column({ space: 8 }) { + Text(this.detail !== null ? this.detail.title : '旅程') + .fontSize(FbMetric.fontH1) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + + Row({ space: 8 }) { + // ① + 计划行程 —— 蓝 + this.actionBtn('+ 计划行程', '#2F6FB0', (): void => { + this.openNodeSheet('plan'); + }) + // ② + 实际行程 —— 绿 + this.actionBtn('+ 实际行程', '#4E7C3F', (): void => { + this.openNodeSheet('actual'); + }) + Column().layoutWeight(1) + } + .width('100%') + + Row({ space: 8 }) { + // ③ 编辑 —— 黄 + this.actionBtn('编辑', '#B8862B', (): void => { + this.editOpen = true; + }) + // ④ 删除 —— 红 + this.actionBtn('删除', '#C0533F', (): void => { + this.confirmDeleteTrip(); + }) + Column().layoutWeight(1) + // ⑤ 设置 —— 图标**不变**(☰) + FbIconButton({ + icon: $r('app.media.fb_ic_menu'), + tint: FbTint.muted, + onTap: (): void => { + this.settingsOpen = true; + } + }) + } + .width('100%') + } + .width('100%') + .padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 }) + .alignItems(HorizontalAlign.Start) + } + + /** + * 动作区的一颗实心按钮。 + * + * ⚠️ 自己画而不是用 `FbPrimaryButton`:那颗按钮有 `icon` / `block` 等一堆参数, + * 这里只要"给定底色 + 白字 + 固定高度",自己画反而更少样板、也更好调高度对齐。 + */ + @Builder + actionBtn(label: string, bg: string, onTap: () => void) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .padding({ left: 12, right: 12, top: 7, bottom: 7 }) + .backgroundColor(bg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + onTap(); + }) + } + + /** 题图 + 信息区(照 `trips/show.php` 的 `trip-hero`) */ + @Builder + heroCard() { + Column({ space: 0 }) { + /* ---- 题图:16:9,**占满整宽** ---- + 🔴 网页版第 1 条:「题图应该按照 16:9 显示,因为添加的时候是按照这个比例 + 裁剪的」。槽位 `cover` 就是 1920×1080(见 `app/ImageSlot.php`)。 + @‌aspectRatio 比写死高度好:换机型、换屏宽都还是 16:9。 */ + Stack({ alignContent: Alignment.Center }) { + if (this.detail !== null && this.detail.coverKey !== '' && !this.coverFailed) { + Image(fbImageSrc(this.detail.coverKey, FB_TOKEN_STORE.get())) + .width('100%') + .height('100%') + .objectFit(ImageFit.Cover) + .onError((): void => { + this.coverFailed = true; + }) + } + if (this.detail === null || this.detail.coverKey === '' || this.coverFailed) { + Column({ space: 6 }) { + Image($r('app.media.fb_ic_trips')) + .width(34) + .height(34) + .renderMode(ImageRenderMode.Template) + .colorFilter(fbTint('#C9B8A4')) + Text('这趟旅程还没设题图') + .fontSize(FbMetric.fontTiny) + .fontColor('#A8988A') + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .backgroundColor('#F1E7D6') + } + } + .width('100%') + .aspectRatio(16 / 9) + .clip(true) + + // ---- 信息区 ---- + Column({ space: 10 }) { + // 时间 + 城市(网页版 `.trip-when`) + Row({ space: 10 }) { + if (this.detail !== null && this.detail.dateRange !== '') { + Text(this.detail.dateRange) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + } + } + .width('100%') + .alignItems(VerticalAlign.Center) + + if (this.detail !== null && this.detail.cities.length > 0) { + Row({ space: 6 }) { + ForEach(this.detail.cities, (c: string) => { + Text(c) + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + .backgroundColor(fbAlpha(this.accent, 0.1)) + .borderRadius(FbMetric.radiusPill) + }, (c: string): string => 'city_' + c) + } + .width('100%') + } + + // 五项统计 + this.statRow() + + // 参与的人 + if (this.detail !== null && this.detail.attendees.length > 0) { + Column({ space: 6 }) { + Text('参与的人') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + Row({ space: 6 }) { + ForEach(this.detail.attendees, (p: FbTripNodePerson) => { + Row({ space: 4 }) { + FbAvatar({ + name: fbPersonName(p), + color: p.avatarColor !== '' ? p.avatarColor : '#A84A28', + dia: 20, + fontSizeVp: 10, + imageUrl: p.avatar !== '' ? fbImageSrc(p.avatar, FB_TOKEN_STORE.get()) : '' + }) + Text(fbPersonName(p)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(1) + } + .padding({ left: 4, right: 8, top: 2, bottom: 2 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .alignItems(VerticalAlign.Center) + }, (p: FbTripNodePerson): string => 'tp_' + p.id.toString()) + } + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + // 简介:**空也要给一句能点的提示**(网页版第 44 条踩过:整块空着像功能被删了) + if (this.detail !== null && this.detail.summary !== '') { + Text(this.detail.summary) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .lineHeight(21) + .width('100%') + } else if (this.detail !== null) { + Row({ space: 4 }) { + Text('还没有简介 ——') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + Text('点这里写一句') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accent) + .onClick((): void => { + this.editOpen = true; + }) + } + .width('100%') + } + } + .width('100%') + .padding(FbMetric.cardPad) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .clip(true) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad }) + } + + /** + * 五项统计:参与人 / 城市 / 天数 / 段数 / 总花销。 + * + * ⚠️ 数值全来自**服务端 `Trip::summary()`**,不是端上自己数 —— + * 天数要跨天算、城市要 union 去重、花销在花销表里(节点上不一定有)。 + * 端上重算必然和网页版对不上。 + */ + @Builder + statRow() { + Row({ space: 0 }) { + this.statCell(this.stat !== null ? this.stat.people.length.toString() : '—', '参与人') + this.statCell(this.stat !== null ? this.stat.cities.length.toString() : '—', '个城市') + this.statCell( + this.stat !== null && this.stat.days > 0 ? this.stat.days.toString() : '—', '天') + this.statCell(this.stat !== null ? this.stat.nodeCount.toString() : '—', '段行程') + this.statCell( + this.stat !== null ? fbMoney(this.stat.cents) : '—', '总花销') + } + .width('100%') + .padding({ top: 4, bottom: 4 }) + } + + @Builder + statCell(num: string, label: string) { + Column({ space: 2 }) { + Text(num) + .fontSize(FbMetric.fontBody) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(label) + .fontSize(10) + .fontColor(FbColors.faint) + .maxLines(1) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + /** + * 三组行程:计划 → 实际 → 未成行。 + * + * 🔴 分组名与顺序**照网页版 `show.php` 的 `$groupMeta`**: + * plan「计划的行程」/ actual「实际行程」/ skipped「未成行」。 + * ⚠️ 网页版渲染顺序是 plan → actual → skipped(`foreach (['plan','actual','skipped'])`)。 + * 端上列表页原先按「实际在前」排,那是**卡片就地展开**时期的取舍; + * 现在既然"跟网页版一样",就照网页版:计划在前。 + * 🔴 **空分组不显示**(用户第 4 条:「只要是没有就是 0 条,那个类型就不显示了」)。 + */ + @Builder + nodeGroups() { + if (this.nodes.length === 0) { + Column({ space: 6 }) { + FbEmptyBox({ + title: '还没有行程', + hint: '点上面的「+ 计划行程」把打算走的地方排进来,或者「+ 实际行程」直接记已经走过的', + icon: $r('app.media.fb_ic_trips') + }) + } + .width('100%') + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: FbMetric.cardGap }) + } else { + this.stageGroup('plan', '计划的行程', '还没走 —— 可以修正 / 完成 / 标记未成行') + this.stageGroup('actual', '实际行程', '已走过 —— 也出现在日历与首页「往年今日」') + this.stageGroup('skipped', '未成行', '这次没去成;不上日程,但在这里留着可查') + } + } + + /** 一组行程(空组自己跳过 —— 判据放在这里,三个调用点不用各判一次) */ + @Builder + stageGroup(stage: string, title: string, hint: string) { + if (this.nodesOf(stage).length > 0) { + Column({ space: 0 }) { + // ---- 组头 ---- + Row({ space: 8 }) { + FbTag({ text: title, tone: fbStageTone(stage).tag }) + Text(this.nodesOf(stage).length.toString() + ' 段') + .fontSize(10) + .fontColor(FbColors.faint) + } + .width('100%') + .padding({ top: 12, bottom: 2 }) + .alignItems(VerticalAlign.Center) + + Text(hint) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .padding({ bottom: 8 }) + + ForEach(this.nodesOf(stage), (n: FbTripNode) => { + this.nodeCard(n) + }, (n: FbTripNode): string => 'nd_' + n.id.toString()) + } + .width('100%') + .padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 }) + .alignItems(HorizontalAlign.Start) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .border({ width: { left: 3 }, color: fbStageTone(stage).hex }) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: FbMetric.cardGap }) + } + } + + /** + * 一段行程(**照网页版 `show.php` 的 `.node-row` 逐字段搬**)。 + * + * 信息层级(网页版就是这个顺序,别自己重排): + * 类型图标 | 标题 + 类型徽章 + 修正次数 + 交通方式(车次/车牌) + * | 时间 + 「实际」标记 + * | 线路名 / 承运方(有才显示) + * | 出发地 → 到达地(有才显示) + * | 详细说明(有才显示) + * | 💰 这一段花了 ¥x(≠0 才显示) + * | 参与人头像 + * | 照片缩略图墙(有才显示) + * | 编辑 / 删除(自己的两个动作) + * + * ⚠️ 每条行程**独立一张卡**(不是一条连续时间轴的竖线): + * 手机上一段行程的信息量已经有一屏的 1/3,画竖线反而把内容挤窄。 + * 网页版也是每段一个 `.node-row` 卡片。圆点是本地保留的视觉锚(同色系)。 + */ + @Builder + nodeCard(n: FbTripNode) { + Row({ space: 8 }) { + // ---- 左侧:类型图标 + 时间轴圆点 ---- + Column({ space: 6 }) { + Text(fbNodeTypeIcon(n.nodeType)) + .fontSize(16) + FbDot({ + dia: 7, + fillC: n.status === 'done' ? this.accent : '#A8988A' + }) + } + .width(22) + .alignItems(HorizontalAlign.Center) + .padding({ top: 2 }) + + // ---- 右侧:内容 ---- + Column({ space: 5 }) { + // ① 标题行 + Row({ space: 6 }) { + Text(n.title) + .fontSize(FbMetric.fontBody) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .layoutWeight(1) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + + // ② 徽章行:类型 / 修正次数 / 交通方式 + Row({ space: 6 }) { + FbTag({ + text: fbNodeTypeWord(n.nodeType), + tone: n.stage === 'actual' ? 'green' : (n.stage === 'skipped' ? 'muted' : 'blue') + }) + if (n.revisionN > 0) { + Text('已修正 ' + n.revisionN.toString() + ' 次') + .fontSize(10) + .fontColor(FbColors.yellow) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor('#14B8862B') + .borderRadius(FbMetric.radiusPill) + } + if (n.mode !== '') { + /* 🔴 车次/车牌紧跟模式词(v41 用户第 8 条)—— 不能丢到下面那排标签里 */ + Text(fbModeIcon(n.mode) + ' ' + fbModeWord(n.mode) + + (fbModeExtra(n) !== '' ? ' ' + fbModeExtra(n) : '')) + .fontSize(10) + .fontColor('#2F6FB0') + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor('#142F6FB0') + .borderRadius(FbMetric.radiusPill) + .maxLines(1) + } + } + .width('100%') + + // ③ 时间 + 「实际」小标记 + if (n.when !== '') { + Row({ space: 6 }) { + Text(n.when) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + if (n.stage === 'actual') { + Text('实际') + .fontSize(10) + .fontColor('#4E7C3F') + } + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + // ④ 线路名 / 承运方(`trip_no` **不在这里** —— 它已经在 ② 跟着模式走了) + if (n.lineName !== '' || n.carrier !== '') { + Row({ space: 6 }) { + if (n.lineName !== '') { + Text(n.lineName) + .fontSize(10) + .fontColor(FbColors.muted) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(4) + } + if (n.carrier !== '') { + Text(n.carrier) + .fontSize(10) + .fontColor(FbColors.muted) + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(4) + } + } + .width('100%') + } + + // ⑤ 出发地 → 到达地 + if (n.fromPlace !== '' || n.toPlace !== '') { + Row({ space: 5 }) { + Text(n.fromPlace !== '' ? n.fromPlace : '—') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text('→') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + Text(n.toPlace !== '' ? n.toPlace : '—') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + // ⑥ 详细说明 + if (n.detail !== '') { + Text(n.detail) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .lineHeight(19) + .width('100%') + } + + // ⑦ 这一段花了多少 + if (n.expenseCent !== 0) { + Text('💰 这一段花了 ' + fbMoney(n.expenseCent)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .width('100%') + } + + // ⑧ 参与人 + if (n.members.length > 0) { + Row({ space: 5 }) { + ForEach(n.members, (p: FbTripNodePerson) => { + Row({ space: 3 }) { + FbAvatar({ + name: fbPersonName(p), + color: p.avatarColor !== '' ? p.avatarColor : '#A84A28', + dia: 18, + fontSizeVp: 9, + imageUrl: p.avatar !== '' ? fbImageSrc(p.avatar, FB_TOKEN_STORE.get()) : '' + }) + Text(fbPersonName(p)) + .fontSize(10) + .fontColor(FbColors.muted) + .maxLines(1) + } + .alignItems(VerticalAlign.Center) + }, (p: FbTripNodePerson): string => 'nm_' + n.id.toString() + '_' + p.id.toString()) + } + .width('100%') + } + + // ⑨ 照片墙(只铺图片;视频/文件端上先不做,见 `parseNodes` 的说明) + if (this.photosOf(n).length > 0) { + Row({ space: 5 }) { + ForEach(this.photosOf(n), (ph: FbTripNodePhoto) => { + Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get())) + .width(56) + .height(56) + .objectFit(ImageFit.Cover) + .borderRadius(6) + }, (ph: FbTripNodePhoto): string => 'ph_' + ph.id.toString()) + } + .width('100%') + } + + // ⑩ 自己的两个动作 + Row({ space: 8 }) { + Column().layoutWeight(1) + Text('编辑') + .fontSize(FbMetric.fontTiny) + .fontColor('#B8862B') + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .onClick((): void => { + /* ⚠️ 端上暂时只能"加一段",改一段要 `PATCH /trips//nodes/` + —— 服务端 `Api::dispatch()` 只解析三段(`/res/id/sub`), + 第 4 段会被丢掉,所以这条路由现在**不存在**。 + 与其放一颗点了没反应的按钮(本项目红线),不如明说。 */ + this.loadNote = '端上还不能改单段行程,请到网页版修改。'; + }) + Text('删除') + .fontSize(FbMetric.fontTiny) + .fontColor('#C0533F') + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .onClick((): void => { + this.delNodeId = n.id; + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + .padding({ left: 10, bottom: 10 }) + .border({ width: { left: 1 }, color: FbColors.line }) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } + + /** 删一段行程的确认框(自绘,避免动系统弹窗层级) */ + @Builder + delNodeConfirm() { + Stack({ alignContent: Alignment.Center }) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .onClick((): void => { + this.delNodeId = 0; + }) + + Column({ space: 12 }) { + Text('删掉这一段行程?') + .fontSize(FbMetric.fontBody) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + Text('删了就找不回来了。如果只是这次没去成,可以改成「未成行」留着。') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .lineHeight(19) + Row({ space: 10 }) { + Column().layoutWeight(1) + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .padding({ left: 14, right: 14, top: 8, bottom: 8 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.delNodeId = 0; + }) + Text(this.deleting ? '删除中…' : '删除') + .fontSize(FbMetric.fontSmall) + .fontColor('#FFFFFF') + .padding({ left: 14, right: 14, top: 8, bottom: 8 }) + .backgroundColor(this.deleting ? '#D9A9A0' : '#C0533F') + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.doDeleteNode(); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + .width('80%') + .padding(18) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radius) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + } + + /** 打开「加一段行程」浮层,并指定预选阶段 */ + private openNodeSheet(stage: string): void { + this.nodeSheetStage = stage === 'actual' ? 'actual' : 'plan'; + this.nodeSheetTripId = this.tripId; + } + + /** + * 行程加成功后:**本体、统计、节点全都要重取**。 + * + * ⚠️ 只重取节点是不够的:城市 / 天数 / 段数 / 花销都是从节点派生的, + * 加了一段之后头部五项统计就变了。只刷节点会让头部显示旧数字。 + */ + private onNodeSaved(): void { + this.nodeSheetTripId = 0; + this.reload(); + } + + /** 删旅程(整趟)—— 破坏性,先确认,文案里带**名字**(能看出是不是点错了) */ + private confirmDeleteTrip(): void { + if (this.detail === null) { + return; + } + const name: string = this.detail.title; + this.getUIContext().showAlertDialog({ + title: '删除这个旅程', + message: '确定删除「' + name + '」吗?里面的行程、照片会一起删掉,不可恢复。', + autoCancel: true, + primaryButton: { + value: '取消', + action: (): void => { + } + }, + secondaryButton: { + value: '删除', + fontColor: '#C0533F', + action: (): void => { + this.doDeleteTrip(); + } + } + }); + } + + private async doDeleteTrip(): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.loadNote = '未登录,无法删除。'; + return; + } + const res = await deleteTrip(token, this.tripId); + if (!res.ok) { + this.loadNote = res.error !== '' ? res.error : '没删掉,请稍后再试。'; + return; + } + // 删完回列表页(走 `leaveTo`,它会顺带收抽屉 + 复位滚动累积; + // 直接赋 `activeModule` 会让回列表后"轻滑就收起悬浮栏" —— v62 的坑) + this.app.tripDetailId = 0; + this.app.leaveTo('trips'); + } + + private async doDeleteNode(): Promise { + if (this.deleting) { + return; + } + const token: string = FB_TOKEN_STORE.get(); + const nid: number = this.delNodeId; + if (token === '' || nid <= 0) { + return; + } + this.deleting = true; + const res = await deleteTripNode(token, this.tripId, nid); + this.deleting = false; + this.delNodeId = 0; + if (!res.ok) { + this.loadNote = res.error !== '' ? res.error : '没删掉,请稍后再试。'; + return; + } + this.reload(); + } + + private nodesOf(stage: string): FbTripNode[] { + return this.nodes.filter((n: FbTripNode): boolean => n.stage === stage); + } + + /** 一段行程里**能显示**的照片(图片且 key 非空) */ + private photosOf(n: FbTripNode): FbTripNodePhoto[] { + return n.attachs.filter((a: FbTripNodePhoto): boolean => a.isImage && a.key !== ''); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets index bb89f44..4fe0a14 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -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/`(待实现)。 - * 不假装有数据:宁可展开后显示"暂无行程段"。 - */ - @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//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 { - 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); - } -} \ No newline at end of file +}