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:
@@ -58,6 +58,22 @@ export class FbAppState {
|
|||||||
/** 是否宽屏(窗口短边 ≥ 600vp)—— 决定抽屉是「常驻侧栏」还是「滑出层」 */
|
/** 是否宽屏(窗口短边 ≥ 600vp)—— 决定抽屉是「常驻侧栏」还是「滑出层」 */
|
||||||
@Trace wideLayout: boolean = false;
|
@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` 时应跳登录页) */
|
/** 当前会话(接口接上之前先用演示数据;`loggedIn=false` 时应跳登录页) */
|
||||||
@Trace session: FbSession = FB_DEMO_SESSION;
|
@Trace session: FbSession = FB_DEMO_SESSION;
|
||||||
|
|
||||||
@@ -266,6 +282,24 @@ export class FbAppState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从「深层页」退回它上面那层(例:旅程详情删掉整趟后回列表)。
|
||||||
|
*
|
||||||
|
* 🔴 为什么不能直接写 `this.app.activeModule = 'trips'`(v75 差点这么干):
|
||||||
|
* `applyModule()` 除了换 key,还会做两件**看不见但缺了就出问题**的事 ——
|
||||||
|
* ① 收抽屉;② `resetScroll()` 复位滚动累积。
|
||||||
|
* 漏掉 ② 的后果是:回列表后**第一次轻滑就"立刻收起悬浮栏"**
|
||||||
|
* (其实用户什么都没做),v62 已经把这个坑记过一遍。
|
||||||
|
*
|
||||||
|
* ⚠️ 与 `backToPrevModule()` 的区别:那个会**弹历史**(用户按返回),
|
||||||
|
* 这个是**程序主动退回父页**(删除/保存后离开当前页),
|
||||||
|
* 它**不弹**历史 —— 因为详情页那一层本来就是用户点进来的,
|
||||||
|
* 父页仍在历史里,弹掉反而会让"再按一次返回"跳到更早的页。
|
||||||
|
*/
|
||||||
|
leaveTo(parentKey: string): void {
|
||||||
|
this.applyModule(parentKey);
|
||||||
|
}
|
||||||
|
|
||||||
/** 有浮层开着吗(悬浮栏据此让位) */
|
/** 有浮层开着吗(悬浮栏据此让位) */
|
||||||
sheetOpen(): boolean {
|
sheetOpen(): boolean {
|
||||||
return this.sheetCount > 0;
|
return this.sheetCount > 0;
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ export struct FbTripNodeSheet {
|
|||||||
@Param tripId: number = 0;
|
@Param tripId: number = 0;
|
||||||
/** 模块主题色 */
|
/** 模块主题色 */
|
||||||
@Param accent: string = '#3F7D5A';
|
@Param accent: string = '#3F7D5A';
|
||||||
|
/**
|
||||||
|
* 打开时预选的阶段:`'plan'` / `'actual'`。
|
||||||
|
*
|
||||||
|
* 🔴 v75(用户 2026-10-21):详情页右上角是**两颗**按钮
|
||||||
|
* 「+ 计划行程(蓝)」「+ 实际行程(绿)」(这是 v41 定下的硬要求),
|
||||||
|
* 点哪颗就该落在哪个阶段 —— 原来只有一颗「加一段行程」,
|
||||||
|
* 进来后阶段恒为「计划」,用户点"实际行程"还是要手动改一次。
|
||||||
|
* ⚠️ 浮层里那个「阶段」选择器**保留**:用户点错了还能改,
|
||||||
|
* 不能因为"入口已经指定了"就把选择器藏掉(那是把选择权拿走)。
|
||||||
|
*/
|
||||||
|
@Param startStage: string = 'plan';
|
||||||
|
|
||||||
@Event onClose: () => void = (): void => {};
|
@Event onClose: () => void = (): void => {};
|
||||||
/** 加成功后父页面重新拉节点 */
|
/** 加成功后父页面重新拉节点 */
|
||||||
@@ -84,6 +95,8 @@ export struct FbTripNodeSheet {
|
|||||||
aboutToAppear(): void {
|
aboutToAppear(): void {
|
||||||
// 默认今天(多数场景是"记刚刚发生的"或"排今天剩下的")
|
// 默认今天(多数场景是"记刚刚发生的"或"排今天剩下的")
|
||||||
this.date = fbIsoOf(new Date());
|
this.date = fbIsoOf(new Date());
|
||||||
|
/* 入口指定的阶段(详情页两颗按钮)—— 只认这两个值,别的一律按计划 */
|
||||||
|
this.category = this.startStage === 'actual' ? 'actual' : 'plan';
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ export struct FbTopBar {
|
|||||||
if (k === '__account') {
|
if (k === '__account') {
|
||||||
return '个人设置';
|
return '个人设置';
|
||||||
}
|
}
|
||||||
|
/* 🔴 v75:旅程详情页顶栏还写「旅程」—— 它是旅程模块的**下一层**,
|
||||||
|
不是另一个模块。写成"旅程详情"会让标题在列表↔详情之间跳变。 */
|
||||||
|
if (k === '__trip_detail') {
|
||||||
|
return '旅程';
|
||||||
|
}
|
||||||
const m = fbModuleOf(k);
|
const m = fbModuleOf(k);
|
||||||
return m !== undefined ? m.name : '家书';
|
return m !== undefined ? m.name : '家书';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
|
||||||
import { FbTripNode } from '../model/Types';
|
import { FbTripDetail, FbTripNode, FbTripNodePerson, FbTripNodePhoto, FbTripSummary } from '../model/Types';
|
||||||
|
|
||||||
/** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */
|
/** 新建/修改旅程时提交的字段(与网页版 form.php 对齐) */
|
||||||
export interface FbTripDraft {
|
export interface FbTripDraft {
|
||||||
@@ -130,12 +130,102 @@ export function parseNodes(json: string): FbTripNode[] {
|
|||||||
stage: strOf(o, 'stage') !== '' ? strOf(o, 'stage') : strOf(o, 'category'),
|
stage: strOf(o, 'stage') !== '' ? strOf(o, 'stage') : strOf(o, 'category'),
|
||||||
status: strOf(o, 'status'),
|
status: strOf(o, 'status'),
|
||||||
mode: strOf(o, 'travel_mode'),
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉一趟旅程的**本体**(`GET /api/v1/trips/<id>`)。
|
||||||
|
*
|
||||||
|
* 与列表项的区别:这里带 `cities` 数组、`activitySpan` 折好的时间串、
|
||||||
|
* 完整参与人名单、`is_public` —— 详情页头部那一整块都读它。
|
||||||
|
*
|
||||||
|
* @returns 失败返回 `null`(页面据此显示"没取到,可重试",不假装空白)
|
||||||
|
*/
|
||||||
|
export async function fetchTripDetail(token: string, tripId: number): Promise<FbTripDetail | null> {
|
||||||
|
const opts: FbApiOptions = { method: 'GET', token: token };
|
||||||
|
const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString(), opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let raw: Object;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(res.data) as Object;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw === null || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const o: Record<string, Object> = raw as Record<string, Object>;
|
||||||
|
const cities: string[] = strListOf(o, 'cities');
|
||||||
|
return {
|
||||||
|
id: numOf(o, 'id'),
|
||||||
|
title: strOf(o, 'title'),
|
||||||
|
dateRange: activitySpanOf(o),
|
||||||
|
coverKey: strOf(o, 'cover_key'),
|
||||||
|
summary: strOf(o, 'summary'),
|
||||||
|
// 服务端给的是 0/1 或 true/false,`numOf` 认前者
|
||||||
|
isPublic: boolOf(o, 'is_public'),
|
||||||
|
cities: cities,
|
||||||
|
attendees: peopleOf(o, 'attendees')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉五项统计(`GET /api/v1/trips/<id>/summary`)。
|
||||||
|
*
|
||||||
|
* 🔴 五项**全部由服务端从行程里算**(`Trip::summary()`)——
|
||||||
|
* 端上绝不能自己用 nodes 求和:
|
||||||
|
* ① 天数要按"最早开始 / 最晚结束"跨天算,不是数条数;
|
||||||
|
* ② 城市要 union 出发地到达地去重;
|
||||||
|
* ③ 花销在**花销表**里(可能在别的月份,节点上不一定看得到)。
|
||||||
|
*/
|
||||||
|
export async function fetchTripSummary(token: string, tripId: number): Promise<FbTripSummary | null> {
|
||||||
|
const opts: FbApiOptions = { method: 'GET', token: token };
|
||||||
|
const res: FbApiResult = await fbApi('/api/v1/trips/' + tripId.toString() + '/summary', opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let raw: Object;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(res.data) as Object;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw === null || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const o: Record<string, Object> = raw as Record<string, Object>;
|
||||||
|
return {
|
||||||
|
people: peopleOf(o, 'people'),
|
||||||
|
cities: strListOf(o, 'cities'),
|
||||||
|
days: numOf(o, 'days'),
|
||||||
|
nodeCount: numOf(o, 'nodes'),
|
||||||
|
skipped: numOf(o, 'skipped'),
|
||||||
|
cents: numOf(o, 'cents')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 加一段行程 */
|
/** 加一段行程 */
|
||||||
export async function addTripNode(token: string, tripId: number, d: FbTripNodeDraft): Promise<FbApiResult> {
|
export async function addTripNode(token: string, tripId: number, d: FbTripNodeDraft): Promise<FbApiResult> {
|
||||||
/* 同上:逐键赋值(ArkTS 不允许 `Record` 的字面量初始化) */
|
/* 同上:逐键赋值(ArkTS 不允许 `Record` 的字面量初始化) */
|
||||||
@@ -226,3 +316,135 @@ function placeOf(o: Record<string, Object>): string {
|
|||||||
}
|
}
|
||||||
return from !== '' ? from : to;
|
return from !== '' ? from : to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布尔(服务端用 0/1,也可能给 true/false)。
|
||||||
|
*
|
||||||
|
* ⚠️ 不能复用 `numOf(...) !== 0`:`is_public` 缺字段时 `numOf` 给 0
|
||||||
|
* ⇒ 会把"没这个字段"误判成"不公开"。缺字段按**公开**算
|
||||||
|
* (与 `Trip::all()` 里 `COALESCE(d.is_public, 1)` 同一口径)。
|
||||||
|
*/
|
||||||
|
function boolOf(o: Record<string, Object>, k: string): boolean {
|
||||||
|
const v: Object | undefined = o[k];
|
||||||
|
if (typeof v === 'boolean') {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
return v !== 0;
|
||||||
|
}
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
return v === '1' || v === 'true';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字符串数组(`cities`) */
|
||||||
|
function strListOf(o: Record<string, Object>, k: string): string[] {
|
||||||
|
const v: Object | undefined = o[k];
|
||||||
|
const out: string[] = [];
|
||||||
|
if (!Array.isArray(v)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const it of v as Object[]) {
|
||||||
|
if (typeof it === 'string' && it !== '') {
|
||||||
|
out.push(it);
|
||||||
|
} else if (typeof it === 'number') {
|
||||||
|
out.push(it.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一组人(`members` / `attendees` / `people` 三处形状一样)。
|
||||||
|
*
|
||||||
|
* 服务端字段:`id` / `name` / `avatar` / `avatar_color` / `my_nickname`。
|
||||||
|
*/
|
||||||
|
function peopleOf(o: Record<string, Object>, k: string): FbTripNodePerson[] {
|
||||||
|
const v: Object | undefined = o[k];
|
||||||
|
const out: FbTripNodePerson[] = [];
|
||||||
|
if (!Array.isArray(v)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const it of v as Object[]) {
|
||||||
|
const p: Record<string, Object> = it as Record<string, Object>;
|
||||||
|
const id: number = numOf(p, 'id');
|
||||||
|
if (id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
id: id,
|
||||||
|
name: strOf(p, 'name'),
|
||||||
|
nickname: strOf(p, 'my_nickname'),
|
||||||
|
avatarColor: strOf(p, 'avatar_color'),
|
||||||
|
avatar: strOf(p, 'avatar')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件数组(服务端 `attachments` 行:`id` / `key` / `name` / `is_image` …)。
|
||||||
|
*
|
||||||
|
* ⚠️ 详情页只铺**图片**的缩略图(视频/文件点了没播放器,端上先不做),
|
||||||
|
* 但这里仍然全量解析 —— 过滤留给渲染层(数据层不替 UI 做决定)。
|
||||||
|
*/
|
||||||
|
function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
|
||||||
|
const v: Object | undefined = o[k];
|
||||||
|
const out: FbTripNodePhoto[] = [];
|
||||||
|
if (!Array.isArray(v)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const it of v as Object[]) {
|
||||||
|
const a: Record<string, Object> = it as Record<string, Object>;
|
||||||
|
const key: string = strOf(a, 'key');
|
||||||
|
if (key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
id: numOf(a, 'id'),
|
||||||
|
key: key,
|
||||||
|
name: strOf(a, 'name'),
|
||||||
|
isImage: numOf(a, 'is_image') === 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旅程的**一段时间文案**(`Trip::activitySpan()` 的端上等价物)。
|
||||||
|
*
|
||||||
|
* 🔴 为什么不在端上用 `starts_at` 自己拼:
|
||||||
|
* 服务端对"一段行程都没有"这种情形给的是「**还没有旅程**」,
|
||||||
|
* 而不是 `formatSpan()` 那句「日期待补」——两者语义完全不同:
|
||||||
|
* 前者是"这趟旅程还没排内容",后者是"这一段没填日期"。
|
||||||
|
* 端上自己拼就会把这句话丢掉,让用户以为时间坏了。
|
||||||
|
* ⚠️ 服务端详情接口的 `starts_at` 已经是**派生值**(从节点算的),可直接用。
|
||||||
|
*/
|
||||||
|
function activitySpanOf(o: Record<string, Object>): string {
|
||||||
|
const a: string = strOf(o, 'starts_at');
|
||||||
|
if (a === '') {
|
||||||
|
return '还没有旅程';
|
||||||
|
}
|
||||||
|
const s: string = cnLong(a);
|
||||||
|
const e: string = strOf(o, 'ends_at');
|
||||||
|
if (e === '') {
|
||||||
|
return s + ' 起';
|
||||||
|
}
|
||||||
|
return s + ' – ' + cnLong(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `2026-10-20 09:30:00` → `2026年10月20日`(与 Web 端 `Y年n月j日` 同口径) */
|
||||||
|
function cnLong(v: string): string {
|
||||||
|
const t: string = v.replace('T', ' ');
|
||||||
|
if (t.length < 10) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
const y: number = Number.parseInt(t.substring(0, 4), 10);
|
||||||
|
const m: number = Number.parseInt(t.substring(5, 7), 10);
|
||||||
|
const d: number = Number.parseInt(t.substring(8, 10), 10);
|
||||||
|
if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) {
|
||||||
|
return t.substring(0, 10);
|
||||||
|
}
|
||||||
|
return y.toString() + '年' + m.toString() + '月' + d.toString() + '日';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[] = [
|
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: 1, title: '出发', when: '10-02 07:30', place: '家 → 青岛北站',
|
||||||
{ id: 3, title: '海边散步 + 午餐', when: '10-02 12:00', place: '仰口海水浴场', stage: 'actual', status: 'done', mode: '步行', expenseCent: 21800 },
|
stage: 'actual', status: 'done', mode: 'drive', expenseCent: 0,
|
||||||
{ id: 4, title: '太清宫', when: '10-02 15:00', place: '崂山太清景区', stage: 'actual', status: 'done', mode: '自驾', expenseCent: 27000 },
|
nodeType: 'move', detail: '六点起床,路上买了包子当早饭。',
|
||||||
{ id: 5, title: '登山(巨峰)', when: '10-03 08:00', place: '崂山巨峰景区', stage: 'plan', status: 'done', mode: '自驾', expenseCent: 34000 },
|
fromPlace: '家', toPlace: '青岛北站', lineName: '', carrier: '',
|
||||||
{ id: 6, title: '返程', when: '10-03 17:00', place: '青岛 → 家', stage: 'plan', status: 'done', mode: '自驾', expenseCent: 0 }
|
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: []
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -95,7 +95,13 @@ export function fbModuleName(key: string): string {
|
|||||||
|
|
||||||
/** 模块主题色(认不出回落到首页砖红) */
|
/** 模块主题色(认不出回落到首页砖红) */
|
||||||
export function fbModuleColor(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';
|
return m !== undefined ? m.color : '#A84A28';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -409,23 +409,116 @@ export interface FbTripItem {
|
|||||||
attendeeCount: number;
|
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 {
|
export interface FbTripNode {
|
||||||
id: number;
|
id: number;
|
||||||
/** 段标题,如「出发」「抵达民宿」 */
|
/** 段标题,如「出发」「抵达民宿」 */
|
||||||
title: string;
|
title: string;
|
||||||
/** 时间文案 */
|
/** 时间文案(已按服务端 `nodeSpan` 的口径折成「10-20 09:30」这种短串) */
|
||||||
when: string;
|
when: string;
|
||||||
/** 地点 */
|
/** 地点(`place`,没有则「出发地 → 到达地」) */
|
||||||
place: string;
|
place: string;
|
||||||
/** 阶段:plan(计划)/ actual(实际) */
|
/** 阶段:plan(计划)/ actual(实际)/ skipped(未成行) */
|
||||||
stage: string;
|
stage: string;
|
||||||
/** 状态:todo / done / skip */
|
/** 状态:planned / ongoing / done / cancelled */
|
||||||
status: string;
|
status: string;
|
||||||
/** 交通方式(trips/modes) */
|
/** 交通方式 key(walk / drive / train…),空串 = 没填 */
|
||||||
mode: string;
|
mode: string;
|
||||||
/** 花费(分) */
|
/** 这一段花了多少(**分**;来自 `spent_cents`,是花销表按 node_id 汇总的) */
|
||||||
expenseCent: number;
|
expenseCent: number;
|
||||||
|
/**
|
||||||
|
* 节点类型(`node_type`)。
|
||||||
|
* ⚠️ 两种取值:老数据的英文 key(play/meal/move/stay/other),
|
||||||
|
* 或用户在「节点类型」里自定义的**中文名字**。取图标时两种都要认。
|
||||||
|
*/
|
||||||
|
nodeType: string;
|
||||||
|
/** 详细说明(网页版 `.node-detail`) */
|
||||||
|
detail: string;
|
||||||
|
/** 出发地 / 到达地(网页版 `.node-route-line`,各带导航链接) */
|
||||||
|
fromPlace: string;
|
||||||
|
toPlace: string;
|
||||||
|
/** 线路名 / 承运方(网页版 `.node-traffic` 的两个 `.ntag`) */
|
||||||
|
lineName: string;
|
||||||
|
carrier: string;
|
||||||
|
/** 车次 / 航班号(火车、飞机才有;跟在模式词后面) */
|
||||||
|
tripNo: string;
|
||||||
|
/** 自驾时的车牌号(服务端 `nodes()` 用一条 IN 查询挂上去的) */
|
||||||
|
vehiclePlate: string;
|
||||||
|
/** 已修正次数(>0 才显示「已修正 N 次」徽章) */
|
||||||
|
revisionN: number;
|
||||||
|
/** 这一段的参与人 */
|
||||||
|
members: FbTripNodePerson[];
|
||||||
|
/** 照片 / 附件(图片直接铺缩略图墙) */
|
||||||
|
attachs: FbTripNodePhoto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行程节点里的一个人(服务端 `Trip::nodeMembersFor()` 的返回) */
|
||||||
|
export interface FbTripNodePerson {
|
||||||
|
id: number;
|
||||||
|
/** 本名 */
|
||||||
|
name: string;
|
||||||
|
/** 「我叫他什么」(服务端 `my_nickname`;空则用 name) */
|
||||||
|
nickname: string;
|
||||||
|
avatarColor: string;
|
||||||
|
/** 头像 key(裸 key,渲染时走 `fbImageSrc`) */
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行程节点上的一个附件(图片/视频/文件) */
|
||||||
|
export interface FbTripNodePhoto {
|
||||||
|
id: number;
|
||||||
|
/** 存储相对 key(裸 key,渲染时走 `fbImageSrc`) */
|
||||||
|
key: string;
|
||||||
|
/** 原始文件名(非图片才有意义) */
|
||||||
|
name: string;
|
||||||
|
/** 1 = 图片,可直接铺缩略图 */
|
||||||
|
isImage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旅程详情页右上角的五项统计(服务端 `Trip::summary()`) */
|
||||||
|
export interface FbTripSummary {
|
||||||
|
/** 参与人(带「我叫他什么」) */
|
||||||
|
people: FbTripNodePerson[];
|
||||||
|
/** 去过的城市(去重) */
|
||||||
|
cities: string[];
|
||||||
|
/** 天数(含首尾;0 = 还没有行程) */
|
||||||
|
days: number;
|
||||||
|
/** 段数(只数计划 + 实际,未成行单独算) */
|
||||||
|
nodeCount: number;
|
||||||
|
/** 未成行的段数 */
|
||||||
|
skipped: number;
|
||||||
|
/** 总花销(分) */
|
||||||
|
cents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旅程详情(`GET /api/v1/trips/<id>` + `/summary` 合成)。
|
||||||
|
*
|
||||||
|
* ⚠️ 列表项的 `FbTripItem` 不够用:详情页要 `cities` 数组(不是拼好的串)、
|
||||||
|
* 要一天的文案(`activitySpan`),还要参与人名单。
|
||||||
|
*/
|
||||||
|
export interface FbTripDetail {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
/** 一段时间文案(服务端 `activitySpan`:「2026年10月2日 – 2026年10月3日」) */
|
||||||
|
dateRange: string;
|
||||||
|
coverKey: string;
|
||||||
|
summary: string;
|
||||||
|
/** 是否公开(编辑表单要回填) */
|
||||||
|
isPublic: boolean;
|
||||||
|
cities: string[];
|
||||||
|
attendees: FbTripNodePerson[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
|
|||||||
import { BusinessError } from '@kit.BasicServicesKit';
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
import { FbColors, FbMetric } from '../common/Theme';
|
import { FbColors, FbMetric } from '../common/Theme';
|
||||||
import { FbAppState, FB_WIDE_BREAKPOINT, fbAppState } from '../common/Store';
|
import { FbAppState, FB_WIDE_BREAKPOINT, fbAppState } from '../common/Store';
|
||||||
|
import { fbModuleColor } from '../model/Modules';
|
||||||
import { FbDrawerShell } from '../components/Drawer';
|
import { FbDrawerShell } from '../components/Drawer';
|
||||||
import { FbTopBar } from '../components/TopBar';
|
import { FbTopBar } from '../components/TopBar';
|
||||||
import { FB_GRIP, fbStartGripWatch } from '../common/FbGrip';
|
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 { FbHomePage } from './home/FbHomePage';
|
||||||
import { FbMoviesPage } from './movies/FbMoviesPage';
|
import { FbMoviesPage } from './movies/FbMoviesPage';
|
||||||
import { FbTripsPage } from './trips/FbTripsPage';
|
import { FbTripsPage } from './trips/FbTripsPage';
|
||||||
|
/* 🔴 v75:旅程详情是**整页**(用户:「点击一个旅程,应该进入到一个新的界面」) */
|
||||||
|
import { FbTripDetailPage } from './trips/FbTripDetailPage';
|
||||||
import { FbCalendarPage } from './calendar/FbCalendarPage';
|
import { FbCalendarPage } from './calendar/FbCalendarPage';
|
||||||
import { FbTasksPage } from './tasks/FbTasksPage';
|
import { FbTasksPage } from './tasks/FbTasksPage';
|
||||||
import { FbPeoplePage } from './people/FbPeoplePage';
|
import { FbPeoplePage } from './people/FbPeoplePage';
|
||||||
@@ -669,6 +672,11 @@ struct Index {
|
|||||||
FbNotesPage({ app: this.app, topBar: this.topBarSlot })
|
FbNotesPage({ app: this.app, topBar: this.topBarSlot })
|
||||||
} else if (this.app.activeModule === '__notifications') {
|
} else if (this.app.activeModule === '__notifications') {
|
||||||
FbNotificationsPage({ app: this.app, topBar: this.topBarSlot })
|
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') {
|
} else if (this.app.activeModule === '__settings') {
|
||||||
FbSettingsPage({ app: this.app, topBar: this.topBarSlot })
|
FbSettingsPage({ app: this.app, topBar: this.topBarSlot })
|
||||||
} else if (this.app.activeModule === '__account') {
|
} else if (this.app.activeModule === '__account') {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* 家书 FamilyBook · 旅程模块(鸿蒙端)
|
* 家书 FamilyBook · 旅程模块(鸿蒙端)
|
||||||
*
|
*
|
||||||
* 对应 Web 端 `templates/trips/index.php` + `trips/show.php`。
|
* 对应 Web 端 `templates/trips/index.php`(列表)+ `trips/show.php`(详情)。
|
||||||
*
|
*
|
||||||
* 术语照 Web 端**固定下来**的那套(用户明确要求):
|
* 术语照 Web 端**固定下来**的那套(用户明确要求):
|
||||||
* · **旅程** = 列表里的一条(容器,如「青岛·崂山 两日」);
|
* · **旅程** = 列表里的一条(容器,如「青岛·崂山 两日」);
|
||||||
* · **行程** = 旅程里面的一段一段(详情页时间轴上的节点)。
|
* · **行程** = 旅程里面的一段一段(详情页里的一组组节点)。
|
||||||
*
|
*
|
||||||
* 端上的形态:**上列表 / 下详情同屏**不现实(手机高度不够),
|
* 🔴 v75(用户 2026-10-21)改了端上的形态:
|
||||||
* 所以这页做「列表 + 展开选中项的行程摘要」;点某条旅程展开它的行程时间轴。
|
* 「每一个旅程里面的行程才是重头戏……点击一个旅程,应该进入到**一个新的界面**,
|
||||||
* 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。
|
* 在里面列出所有的行程,就跟网页版的一样。」
|
||||||
|
* ⇒ 本页只负责**列表**;点卡片进 `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 { FbAppState, fbAppState } from '../../common/Store';
|
||||||
import { FbTripItem, FbTripNode } from '../../model/Types';
|
import { FbTripItem } from '../../model/Types';
|
||||||
import { fbMoney } from '../../model/DemoData';
|
|
||||||
import { parseTrips } from '../../data/FbParse';
|
import { parseTrips } from '../../data/FbParse';
|
||||||
import { loadModule } from '../../data/FbModuleLoad';
|
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 { FB_DOCK_RESERVE } from '../../components/FloatDock';
|
||||||
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
|
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
|
||||||
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
|
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
|
||||||
@@ -28,9 +32,7 @@ import { FB_SET_TRIPS } from '../../model/FbSettingsMenus';
|
|||||||
/* v65:题图要走媒体代理(`Image()` 加不了自定义请求头,令牌得用 URL query 带过去) */
|
/* v65:题图要走媒体代理(`Image()` 加不了自定义请求头,令牌得用 URL query 带过去) */
|
||||||
import { fbImageSrc } from '../../data/FbAttachQueue';
|
import { fbImageSrc } from '../../data/FbAttachQueue';
|
||||||
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
|
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
|
||||||
import { fetchTripNodes } from '../../data/FbTripRepo';
|
|
||||||
import { FbTripNewSheet } from '../../components/FbTripNewSheet';
|
import { FbTripNewSheet } from '../../components/FbTripNewSheet';
|
||||||
import { FbTripNodeSheet } from '../../components/FbTripNodeSheet';
|
|
||||||
/* v66:旅程三项设置(车辆 / 出行方式 / 节点类型) */
|
/* v66:旅程三项设置(车辆 / 出行方式 / 节点类型) */
|
||||||
import { FbNodeTypeSheet, FbTravelModesSheet, FbVehicleSheet } from '../../components/FbTripsSettingsSheets';
|
import { FbNodeTypeSheet, FbTravelModesSheet, FbVehicleSheet } from '../../components/FbTripsSettingsSheets';
|
||||||
|
|
||||||
@@ -60,20 +62,10 @@ export struct FbTripsPage {
|
|||||||
@Local settingsOpen: boolean = false;
|
@Local settingsOpen: boolean = false;
|
||||||
|
|
||||||
@Local trips: FbTripItem[] = [];
|
@Local trips: FbTripItem[] = [];
|
||||||
/**
|
|
||||||
* 行程节点。
|
|
||||||
*
|
|
||||||
* ⚠️ 接口 `/trips` 只给旅程**本身**(含 `node_count`),不给每一段。
|
|
||||||
* 所以这里保持空 —— 展开时才去拉 `/trips/<id>`(待实现)。
|
|
||||||
* 不假装有数据:宁可展开后显示"暂无行程段"。
|
|
||||||
*/
|
|
||||||
@Local nodes: FbTripNode[] = [];
|
|
||||||
/** 顶部提示(同步状态) */
|
/** 顶部提示(同步状态) */
|
||||||
@Local syncNote: string = '';
|
@Local syncNote: string = '';
|
||||||
/** 模块主题色(旅程 = 绿) */
|
/** 模块主题色(旅程 = 绿) */
|
||||||
private accent: string = '#3F7D5A';
|
private accent: string = '#3F7D5A';
|
||||||
/** 当前展开的旅程 id(0 = 都没展开) */
|
|
||||||
@Local expandedId: number = 0;
|
|
||||||
/**
|
/**
|
||||||
* 「新建旅程」浮层是否打开(v65)。
|
* 「新建旅程」浮层是否打开(v65)。
|
||||||
*
|
*
|
||||||
@@ -85,16 +77,8 @@ export struct FbTripsPage {
|
|||||||
@Local editingId: number = 0;
|
@Local editingId: number = 0;
|
||||||
@Local editingTitle: string = '';
|
@Local editingTitle: string = '';
|
||||||
@Local editingSummary: string = '';
|
@Local editingSummary: string = '';
|
||||||
/** 行程节点正在加载(展开时拉 `/trips/<id>/nodes`) */
|
|
||||||
@Local nodesLoading: boolean = false;
|
|
||||||
/** 节点加载失败的说明(空 = 没失败) */
|
|
||||||
@Local nodesNote: string = '';
|
|
||||||
/** 哪一趟旅程的节点是「当前这份 `nodes`」(避免 A 的节点画到 B 上) */
|
|
||||||
@Local nodesTripId: number = 0;
|
|
||||||
/** 题图加载失败集合(一张图坏了不该反复重试,会让列表一直闪) */
|
/** 题图加载失败集合(一张图坏了不该反复重试,会让列表一直闪) */
|
||||||
@Local coverFailedIds: number[] = [];
|
@Local coverFailedIds: number[] = [];
|
||||||
/** 「加一段行程」浮层是否打开,以及它属于哪一趟旅程(0 = 关着) */
|
|
||||||
@Local nodeSheetTripId: number = 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔴 v66(用户 2026-10-20):「旅程的……车辆管理、出行方式、节点设置,
|
* 🔴 v66(用户 2026-10-20):「旅程的……车辆管理、出行方式、节点设置,
|
||||||
@@ -124,10 +108,6 @@ export struct FbTripsPage {
|
|||||||
private backOnce(): boolean {
|
private backOnce(): boolean {
|
||||||
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
|
/* v65:返回手势要**先关新的浮层**,再关设置菜单 ——
|
||||||
顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 */
|
顺序按"层级由浅到深"(后打开的在前),否则按一次返回会跳关两层。 */
|
||||||
if (this.nodeSheetTripId > 0) {
|
|
||||||
this.nodeSheetTripId = 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
/* v66:三项设置浮层。⚠️ 内部还有"表单展开 / 待确认删除"两层,
|
/* v66:三项设置浮层。⚠️ 内部还有"表单展开 / 待确认删除"两层,
|
||||||
按返回先由浮层自己处理(组件的遮罩 onClick 也会关)—— 这里只负责关整层。 */
|
按返回先由浮层自己处理(组件的遮罩 onClick 也会关)—— 这里只负责关整层。 */
|
||||||
if (this.vehiclesOpen) {
|
if (this.vehiclesOpen) {
|
||||||
@@ -150,11 +130,8 @@ export struct FbTripsPage {
|
|||||||
this.settingsOpen = false;
|
this.settingsOpen = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// 行程展开着时,返回先收起它(否则用户只能靠再点一次卡片)
|
/* 🔴 v75:原来这里还有「收起展开的行程」一层 —— 就地展开那套已删除
|
||||||
if (this.expandedId !== 0) {
|
(点卡片改成进详情页了)。别留一个永远为假的判断:`expandedId` 已不存在。 */
|
||||||
this.expandedId = 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,12 +174,6 @@ export struct FbTripsPage {
|
|||||||
fbAppState().sheetCount = this.newOpen ? 1 : 0;
|
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 => {
|
onSaved: (): void => {
|
||||||
this.newOpen = false;
|
this.newOpen = false;
|
||||||
// 保存后重新拉一遍列表(服务端的城市/时间是派生的,本地拼不出来)
|
// 保存后重新拉一遍列表(服务端的城市/时间是派生的,本地拼不出来)
|
||||||
this.expandedId = 0;
|
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.transition(fbSheetTransition())
|
.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)----
|
// ---- 旅程三项设置浮层(v66)----
|
||||||
if (this.vehiclesOpen) {
|
if (this.vehiclesOpen) {
|
||||||
FbVehicleSheet({
|
FbVehicleSheet({
|
||||||
@@ -421,227 +372,184 @@ export struct FbTripsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一张旅程卡:**题图 + 标题 + 时间/城市/段数 + 简介 + 同行人**。
|
* 一张旅程卡:**题图在上(占满整宽)+ 标题与信息在下**。
|
||||||
*
|
*
|
||||||
* 🔴 v65(用户 2026-10-20):「旅程的列表也不太对呀,应该有,题图啊、标题啊、
|
* 🔴🔴 v75(用户 2026-10-21 原话):
|
||||||
* 然后城市啊,应该跟网页版的题图是类似的。」
|
* 「这个旅程的图片是比较重要的,所以我建议就是这个显示的时候啊,
|
||||||
* ⇒ 照 `templates/trips/_list_body.php` 的 `trip-card` 重做:
|
* 图片在正上方,就是充满整个的这个列表的宽度。然后呢把这个标题
|
||||||
* 网页版是**横排**(左题图 132×96 + 右 `trip-main`)。
|
* 还有其他的一些信息放在下面。如果是图片放左边,信息放右边的话,
|
||||||
* 手机上按同样比例缩到 96×72(4:3),右边留足文字宽度。
|
* 它总是被挤占,显示不全。这样的话就可以显示更多的信息。
|
||||||
|
* 想看其他的这个旅程的话,直接往下滑就可以了。」
|
||||||
*
|
*
|
||||||
* ⚠️ 题图比例:网页版列表卡是 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
|
@Builder
|
||||||
tripCard(t: FbTripItem) {
|
tripCard(t: FbTripItem) {
|
||||||
Column({ space: 0 }) {
|
Column({ space: 0 }) {
|
||||||
// ---- 卡头:左题图 + 右内容(横排,同网页版)----
|
// ---- 题图:占满整宽,16:9(与上传槽位比例一致)----
|
||||||
Row({ space: 12 }) {
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
// 题图:有图放真图,没图放占位(网页版是 🧳)
|
if (t.coverKey !== '' && !this.coverFailed(t.id)) {
|
||||||
Stack({ alignContent: Alignment.Center }) {
|
Image(fbImageSrc(t.coverKey, FB_TOKEN_STORE.get()))
|
||||||
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))
|
|
||||||
}
|
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
.justifyContent(FlexAlign.Center)
|
.objectFit(ImageFit.Cover)
|
||||||
.backgroundColor('#F1E7D6')
|
// ⚠️ 不给 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)
|
.width('100%')
|
||||||
.height(72)
|
.alignItems(VerticalAlign.Center)
|
||||||
.borderRadius(FbMetric.radiusS)
|
|
||||||
.clip(true)
|
|
||||||
.flexShrink(0)
|
|
||||||
|
|
||||||
// 右侧:标题 / meta / 简介
|
// 段数 / 已完成 / 同行人数
|
||||||
Column({ space: 4 }) {
|
Row({ space: 8 }) {
|
||||||
Text(t.title)
|
Text(t.nodeCount.toString() + ' 段行程')
|
||||||
.fontSize(FbMetric.fontTitle)
|
.fontSize(FbMetric.fontTiny)
|
||||||
.fontWeight(FontWeight.Medium)
|
.fontColor(t.nodeCount > 0 ? this.accent : FbColors.faint)
|
||||||
.fontColor(FbColors.ink)
|
if (t.doneCount > 0) {
|
||||||
.maxLines(2)
|
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 })
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
.lineHeight(18)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.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%')
|
.width('100%')
|
||||||
.padding(FbMetric.cardPad)
|
.padding(FbMetric.cardPad)
|
||||||
.alignItems(VerticalAlign.Top)
|
.alignItems(HorizontalAlign.Start)
|
||||||
.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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.backgroundColor(FbColors.surface)
|
.backgroundColor(FbColors.surface)
|
||||||
.borderRadius(FbMetric.radius)
|
.borderRadius(FbMetric.radius)
|
||||||
.clip(true)
|
.clip(true)
|
||||||
.alignItems(HorizontalAlign.Start)
|
.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;
|
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 {
|
private markCoverFailed(id: number): void {
|
||||||
if (this.coverFailedIds.indexOf(id) < 0) {
|
if (this.coverFailedIds.indexOf(id) < 0) {
|
||||||
this.coverFailedIds = this.coverFailedIds.concat([id]);
|
this.coverFailedIds = this.coverFailedIds.concat([id]);
|
||||||
@@ -732,116 +570,4 @@ export struct FbTripsPage {
|
|||||||
return this.coverFailedIds.indexOf(id) >= 0;
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user