v76 旅程:信息铺得开 + 每段行程各自成卡 + 照片可点开 / 视频可播

用户 2026-10-22(四条毛病,同一个根因:信息往一行里挤):
「那个城市……如果一行显不下的话,应该显示在下边一行,把所有的城市全列出来。
  就跟那个人员一样,你人员特别多的时候,一行显示不下,就直接往下放就行了。」
「点进详情之后……现在人员显示不下的话,你直接都放在右边,都溢出到这个块外面去了……
  应该直接顺着往下铺就行了,所以它长一点无所谓。」
「实际行程它应该也是一段一段的,不要把它整个实际行程放在一块,
  就是每一个行程都是一个一个块。然后里面的照片要可以点击查看,视频要能够播放。」

根因不是"忘了加滚动",而是 **`Row` 在 ArkUI 里不换行**:
塞不下的子节点会被压缩、或直接横向溢出父容器。四处都改成
`Flex({ wrap: FlexWrap.Wrap })`。

- `Types.ets`:`FbTripItem` 新增 `cities: string[]`(整串只能截断);
  `FbTripNodePhoto` 新增 `mime` / `size`
- `FbParse.parseTrips` 新增 `cityListOf()`:优先用服务端 `cities` 数组,
  只给整串时按 `·` 拆回来("显示不全"不再从别的入口漏进来)
- 列表卡:城市从「📍 一行长串 + 省略号」改成一颗颗 chip,日期单独占一行
- 详情页:城市 / 参与的人 / 每段的参与人 全部改 `Flex wrap`
  (实测「黑龙江」那趟 5 个人自动排成 2 行,不再溢出卡片)
- **每段行程各自一张卡**:组头退成章节标题(不带卡片底),
  `nodeCard` 自己带白底 + 圆角 + 左侧阶段色条(取 `n.stage`)
- 照片:点缩略图开全屏查看器(`PanGesture` 左右滑、上一张/下一张、关闭)
- 视频:按 `mime` 前缀认出视频(`is_image` 只有两态),缩略块 + ▶ 角标,
  点开用系统 `Video` 播放(`media.php` 支持 Range,可拖进度条)

🔴 真机抓到的布局 bug:`Stack` 子节点上的 `.align(Alignment.Bottom/TopEnd)`
  **完全无效** —— 关闭按钮和翻页条双双掉到屏幕正中。改成铺满的 Column +
  `layoutWeight(1)` 撑开,并给控制层 `hitTestBehavior(Transparent)`
  保证"点背景关闭"仍穿透。

验证:真机 33/0(含"城市 chip 数 == 统计里个城市"、"相邻行程卡有实际间隙"、
"点视频块起播放器且无放不出来");守卫 953/0;反向验证 181/0
(其中一条注入暴露了我新写断言的假绿,已改成结构化顺序判据)。
This commit is contained in:
2026-10-22 09:02:16 +08:00
parent 8c92195558
commit 79e942d0e4
6 changed files with 484 additions and 53 deletions
+39 -1
View File
@@ -271,7 +271,7 @@ export function parseTrips(json: string): FbTripItem[] {
const rows: RawTrip[] = asArray<RawTrip>(json); const rows: RawTrip[] = asArray<RawTrip>(json);
const out: FbTripItem[] = []; const out: FbTripItem[] = [];
for (const r of rows) { for (const r of rows) {
const cities: string[] = arr<string>(r.cities); const cities: string[] = cityListOf(r);
const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at)); const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at));
const it: FbTripItem = { const it: FbTripItem = {
id: n(r.id), id: n(r.id),
@@ -285,6 +285,7 @@ export function parseTrips(json: string): FbTripItem[] {
⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */ ⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */
coverKey: s(r.cover_key), coverKey: s(r.cover_key),
city: s(r.city) !== '' ? s(r.city) : cities.join(' · '), city: s(r.city) !== '' ? s(r.city) : cities.join(' · '),
cities: cities,
summary: s(r.summary), summary: s(r.summary),
attendeeCount: n(r.attendee_count), attendeeCount: n(r.attendee_count),
}; };
@@ -293,6 +294,43 @@ export function parseTrips(json: string): FbTripItem[] {
return out; return out;
} }
/**
* 城市的**列表**(卡片上一颗颗 chip 换行用)。
*
* 🔴 v76(用户 2026-10-22):「如果一行显不下的话,应该显示在下边一行,
* 把所有的城市全列出来。」
* ⇒ 端上必须要数组,不能只有一个 `city` 整串 —— 整串只能截断。
*
* ⚠️ 服务端 `Trip::peek()` 同时给 `cities`(数组,`Trip::cities()` 的原值)
* 与 `city`(`implode(' · ', $cities)`)。**优先用数组**;
* 万一将来某条接口只给整串(老版本 / 单条接口),按同一个分隔符拆回来,
* 这样"显示不全"这个毛病不会再从别的入口漏进来。
*/
function cityListOf(r: RawTrip): string[] {
const arr0: string[] = arr<string>(r.cities);
const out: string[] = [];
for (const c of arr0) {
const t: string = c.trim();
if (t !== '' && out.indexOf(t) < 0) {
out.push(t);
}
}
if (out.length > 0) {
return out;
}
const whole: string = s(r.city);
if (whole === '') {
return out;
}
for (const p of whole.split('·')) {
const t: string = p.trim();
if (t !== '' && out.indexOf(t) < 0) {
out.push(t);
}
}
return out;
}
/** 纪念日 → FbAnniversaryItem */ /** 纪念日 → FbAnniversaryItem */
export function parseDays(json: string): FbAnniversaryItem[] { export function parseDays(json: string): FbAnniversaryItem[] {
const rows: RawDay[] = asArray<RawDay>(json); const rows: RawDay[] = asArray<RawDay>(json);
+6 -1
View File
@@ -405,7 +405,12 @@ function attachsOf(o: Record<string, Object>, k: string): FbTripNodePhoto[] {
id: numOf(a, 'id'), id: numOf(a, 'id'),
key: key, key: key,
name: strOf(a, 'name'), name: strOf(a, 'name'),
isImage: numOf(a, 'is_image') === 1 isImage: numOf(a, 'is_image') === 1,
/* 🔴 v76:`mime` / `size` 是「照片可点开、视频能播放」的前提。
⚠️ 服务端 `Attachment::list()` 是 `SELECT *`,这两列本来就有;
以前端上不取,所以只能靠 `is_image` 二选一(视频 vs 图片分不开)。 */
mime: strOf(a, 'mime'),
size: numOf(a, 'size')
}); });
} }
return out; return out;
+4 -4
View File
@@ -114,22 +114,22 @@ export const FB_DEMO_CINEMAS: string[] = ['家里', '万达影城·李沧店', '
export const FB_DEMO_TRIPS: FbTripItem[] = [ export const FB_DEMO_TRIPS: FbTripItem[] = [
{ {
id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03', id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03',
nodeCount: 8, doneCount: 8, coverKey: '', city: '青岛', summary: '海边住了两晚,早上赶海、下午爬山。', nodeCount: 8, doneCount: 8, coverKey: '', city: '青岛', cities: ['青岛'], summary: '海边住了两晚,早上赶海、下午爬山。',
attendeeCount: 3 attendeeCount: 3
}, },
{ {
id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16', id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16',
nodeCount: 6, doneCount: 6, coverKey: '', city: '烟台', summary: '带孩子去看海,吃了顿海鲜。', nodeCount: 6, doneCount: 6, coverKey: '', city: '烟台', cities: ['烟台'], summary: '带孩子去看海,吃了顿海鲜。',
attendeeCount: 4 attendeeCount: 4
}, },
{ {
id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04', id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04',
nodeCount: 12, doneCount: 12, coverKey: '', city: '西安', summary: '兵马俑、城墙、回民街。', nodeCount: 12, doneCount: 12, coverKey: '', city: '西安', cities: ['西安'], summary: '兵马俑、城墙、回民街。',
attendeeCount: 3 attendeeCount: 3
}, },
{ {
id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24', id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24',
nodeCount: 0, doneCount: 0, coverKey: '', city: '哈尔滨', summary: '', nodeCount: 0, doneCount: 0, coverKey: '', city: '哈尔滨', cities: ['哈尔滨'], summary: '',
attendeeCount: 0 attendeeCount: 0
} }
]; ];
+21 -1
View File
@@ -403,6 +403,15 @@ export interface FbTripItem {
* 这个字段**只放城市**,供卡片单独显示「📍 青岛」那一行。 * 这个字段**只放城市**,供卡片单独显示「📍 青岛」那一行。
*/ */
city: string; city: string;
/**
* 城市列表(已去重,服务端 `cities` 原样)。
*
* 🔴 v76(用户 2026-10-22):「那个城市……如果一行显不下的话,应该显示在下边一行,
* 把所有的城市全列出来。就跟那个人员一样。」
* ⇒ 卡片上不再拼成一个长串(那样只能截断),改成**一颗颗 chip 自动换行**。
* ⚠️ `city` 保留:有的地方(如按城市搜索)还是想要一个整串。
*/
cities: string[];
/** 简介(网页版列表卡截 80 字显示) */ /** 简介(网页版列表卡截 80 字显示) */
summary: string; summary: string;
/** 同行人数(所有行程参与人的合集,与网页版一致) */ /** 同行人数(所有行程参与人的合集,与网页版一致) */
@@ -482,8 +491,19 @@ export interface FbTripNodePhoto {
key: string; key: string;
/** 原始文件名(非图片才有意义) */ /** 原始文件名(非图片才有意义) */
name: string; name: string;
/** 1 = 图片,可直接铺缩略图 */ /** 1 = 图片,可直接铺缩略图(服务端 `is_image`,按内容嗅探得到) */
isImage: boolean; isImage: boolean;
/**
* 内容嗅探出来的 MIME(服务端 `attachments.mime`)。
*
* 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看,视频要能够播放。」
* ⇒ 端上第一次需要区分「图片 / 视频 / 其它」,而 `is_image` 只有两态。
* ⚠️ 判视频一律用 `mime` 前缀 + 扩展名兜底,**不看 `is_image === false`**
* —— 那份 pdf/word 也不是图片,但不是视频。
*/
mime: string;
/** 字节数(0 = 服务端没给) */
size: number;
} }
/** 旅程详情页右上角的五项统计(服务端 `Trip::summary()`) */ /** 旅程详情页右上角的五项统计(服务端 `Trip::summary()`) */
@@ -216,6 +216,36 @@ function fbStageTone(stage: string): FbStageTone {
return new FbStageTone('blue', '#2F6FB0'); return new FbStageTone('blue', '#2F6FB0');
} }
/**
* 附件到底是什么 —— `'image'` / `'video'` / `'file'`。
*
* 🔴 v76(用户 2026-10-22):「照片要可以点击查看,视频要能够播放。」
* ⇒ 端上必须能把视频从附件里挑出来,而 `is_image` 只有两态(true / false),
* "false" 里混着视频、PDF、Word —— 只按它判会把 PDF 当视频去播。
*
* ⚠️ 判据口径照服务端 `Attachment::kind()`:
* ① 先看 `is_image`(服务端按**内容嗅探**得出,比扩展名可靠);
* ② 再看 `mime` 前缀 `video/`;
* ③ `mime` 为空(老数据)时才退到扩展名 —— 不要一上来就信扩展名,
* 用户把 `a.mp4` 改成 `a.jpg` 也是有的。
*/
function fbPhotoKind(a: FbTripNodePhoto): string {
if (a.isImage) {
return 'image';
}
const mime: string = a.mime.toLowerCase();
if (mime.indexOf('video/') === 0) {
return 'video';
}
const dot: number = a.key.lastIndexOf('.');
const ext: string = dot >= 0 ? a.key.substring(dot + 1).toLowerCase() : '';
if (mime === '' &&
(ext === 'mp4' || ext === 'm4v' || ext === 'webm' || ext === 'mov' || ext === 'mkv' || ext === 'ts')) {
return 'video';
}
return 'file';
}
@ComponentV2 @ComponentV2
export struct FbTripDetailPage { export struct FbTripDetailPage {
@@ -260,6 +290,18 @@ export struct FbTripDetailPage {
@Local delNodeId: number = 0; @Local delNodeId: number = 0;
@Local deleting: boolean = false; @Local deleting: boolean = false;
/* ---- v76:照片查看器 / 视频播放器 ----
🔴 用户 2026-10-22:「里面的照片要可以点击查看,视频要能够播放。」
⚠️ 两个都是**全屏覆盖层**,所以用"非空即开着"的语义
(`viewerKeys` 空 / `videoKey` 空 = 关着),不用额外的布尔开关 ——
少一个开关就少一处"关了浮层忘了复位"的漏洞。 */
/** 查看器里的图片键列表(空 = 关着) */
@Local viewerKeys: string[] = [];
@Local viewerIndex: number = 0;
/** 正在播放的视频键(空 = 关着) */
@Local videoKey: string = '';
@Local videoName: string = '';
aboutToAppear(): void { aboutToAppear(): void {
/* ⚠️ 登记 key 用**跟列表页不同的名字**:两个页面不会同时挂载, /* ⚠️ 登记 key 用**跟列表页不同的名字**:两个页面不会同时挂载,
但 `FB_BACK` 的语义是"后登记的先被问",同名会让注销互相踩。 */ 但 `FB_BACK` 的语义是"后登记的先被问",同名会让注销互相踩。 */
@@ -279,6 +321,18 @@ export struct FbTripDetailPage {
* 这里最后一步 `return false` 把控制权交回 `Index`(它负责换模块回列表)。 * 这里最后一步 `return false` 把控制权交回 `Index`(它负责换模块回列表)。
*/ */
private backOnce(): boolean { private backOnce(): boolean {
/* 🔴 v76:查看器/播放器是**最上面那一层**,必须第一个关,
否则按返回会直接退出详情页,而用户以为只是"关掉大图"。 */
if (this.videoKey !== '') {
this.videoKey = '';
this.videoName = '';
return true;
}
if (this.viewerKeys.length > 0) {
this.viewerKeys = [];
this.viewerIndex = 0;
return true;
}
if (this.delNodeId > 0) { if (this.delNodeId > 0) {
this.delNodeId = 0; this.delNodeId = 0;
return true; return true;
@@ -342,6 +396,46 @@ export struct FbTripDetailPage {
fbAppState().sheetCount = this.nodeTypesOpen ? 1 : 0; fbAppState().sheetCount = this.nodeTypesOpen ? 1 : 0;
} }
/* v76:全屏查看器 / 播放器也要让底部悬浮栏让位(否则大图底下漏出一条悬浮栏)。
⚠️ 同样是"一个 @Monitor 盯一个变量",两个分开写 —— 它们互斥,直接赋值即可。 */
@Monitor('viewerKeys')
onViewerChanged(): void {
fbAppState().sheetCount = this.viewerKeys.length > 0 ? 1 : 0;
}
@Monitor('videoKey')
onVideoChanged(): void {
fbAppState().sheetCount = this.videoKey !== '' ? 1 : 0;
}
/** 打开某一组图片的大图查看器(从第几张开始) */
private openViewer(keys: string[], at: number): void {
if (keys.length === 0) {
return;
}
this.viewerKeys = keys;
// ⚠️ 必须夹到 [0, n):点第 3 张时若列表刚被删短,直接赋会越界
this.viewerIndex = at < 0 ? 0 : (at >= keys.length ? keys.length - 1 : at);
}
/**
* 查看器翻页。
* ⚠️ 取模归一到 [0, n):直接加减会让 index 越界或变负数(相册里踩过)。
*/
private viewerStep(delta: number): void {
const n: number = this.viewerKeys.length;
if (n === 0) {
return;
}
this.viewerIndex = ((this.viewerIndex + delta) % n + n) % n;
}
/** 打开一个视频(`key` 是裸 key,播放地址现拼,不带缓存) */
private openVideo(a: FbTripNodePhoto): void {
this.videoKey = a.key;
this.videoName = a.name !== '' ? a.name : '视频';
}
/** /**
* 三个接口并发取:本体 / 统计 / 节点。 * 三个接口并发取:本体 / 统计 / 节点。
* *
@@ -556,6 +650,16 @@ export struct FbTripDetailPage {
if (this.delNodeId > 0) { if (this.delNodeId > 0) {
this.delNodeConfirm() this.delNodeConfirm()
} }
/* 🔴 v76:照片查看器 / 视频播放器放在**最后一个位置**。
在 `Stack` 里"后写的盖在上面",所以它压住所有浮层 —— 用户点了照片
就该直接看到大图,而不是"点开又冒出一个浮层把它盖住"。 */
if (this.viewerKeys.length > 0) {
this.photoViewer()
}
if (this.videoKey !== '') {
this.videoPlayer()
}
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
@@ -695,8 +799,13 @@ export struct FbTripDetailPage {
.width('100%') .width('100%')
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
/* 城市 chip 墙 —— **一行放不下就换行,全部列出来**。
🔴 v76(用户 2026-10-22):「如果一行显不下的话,应该显示在下边一行,
把所有的城市全列出来。就跟那个人员一样。」
⚠️ 原来是 `Row({space:6})`:`Row` **不会换行**,城市多了会被压扁
(每个 chip 缩到只剩一两个字)或者直接溢出卡片。 */
if (this.detail !== null && this.detail.cities.length > 0) { if (this.detail !== null && this.detail.cities.length > 0) {
Row({ space: 6 }) { Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.cities, (c: string) => { ForEach(this.detail.cities, (c: string) => {
Text(c) Text(c)
.fontSize(FbMetric.fontTiny) .fontSize(FbMetric.fontTiny)
@@ -704,6 +813,7 @@ export struct FbTripDetailPage {
.padding({ left: 8, right: 8, top: 2, bottom: 2 }) .padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(fbAlpha(this.accent, 0.1)) .backgroundColor(fbAlpha(this.accent, 0.1))
.borderRadius(FbMetric.radiusPill) .borderRadius(FbMetric.radiusPill)
.margin({ right: 6, bottom: 6 })
}, (c: string): string => 'city_' + c) }, (c: string): string => 'city_' + c)
} }
.width('100%') .width('100%')
@@ -719,7 +829,14 @@ export struct FbTripDetailPage {
.fontSize(FbMetric.fontTiny) .fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint) .fontColor(FbColors.faint)
.width('100%') .width('100%')
Row({ space: 6 }) { /* 🔴 v76(用户 2026-10-22):「把那个人员……现在人员显示不下的话,
你直接都放在右边,都溢出到这个块外面去了,这都不好看。
然后那个应该直接顺着往下铺就行了,所以它长一点无所谓。」
⚠️ 原来是 `Row({space:6})` —— `Row` **不换行**,人一多就横向溢出卡片。
改成 `Flex({wrap})`:一行放不下自动换到下一行,卡片跟着变高。
⚠️ 原来那个 `maxLines(1)` 也保留(名字太长自己截断,别把 chip 撑爆),
但整条 chip 不再会被挤到卡片外面。 */
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.attendees, (p: FbTripNodePerson) => { ForEach(this.detail.attendees, (p: FbTripNodePerson) => {
Row({ space: 4 }) { Row({ space: 4 }) {
FbAvatar({ FbAvatar({
@@ -733,11 +850,14 @@ export struct FbTripDetailPage {
.fontSize(FbMetric.fontTiny) .fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted) .fontColor(FbColors.muted)
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 120 })
} }
.padding({ left: 4, right: 8, top: 2, bottom: 2 }) .padding({ left: 4, right: 8, top: 2, bottom: 2 })
.backgroundColor(FbColors.inputBg) .backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill) .borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.margin({ right: 6, bottom: 6 })
}, (p: FbTripNodePerson): string => 'tp_' + p.id.toString()) }, (p: FbTripNodePerson): string => 'tp_' + p.id.toString())
} }
.width('100%') .width('100%')
@@ -858,15 +978,21 @@ export struct FbTripDetailPage {
stageGroup(stage: string, title: string, hint: string) { stageGroup(stage: string, title: string, hint: string) {
if (this.nodesOf(stage).length > 0) { if (this.nodesOf(stage).length > 0) {
Column({ space: 0 }) { Column({ space: 0 }) {
// ---- 组头 ---- /* ---- 组头:**只当分隔标识,不再套一张大卡** ----
🔴 v76(用户 2026-10-22):「它那个实际行程啊,它应该也是一段一段的,
不要把它整个实际行程放在一块,就是每一个行程都是一个一个块。」
⇒ 组头退成一条"章节标题",每段行程各自一张卡(见 `nodeCard`)。
⚠️ 原来整组是一张白卡、里面用一条灰细线分隔各段 —— 视觉上
仍然"糊成一块",看不出这是一段一段的记录。 */
Row({ space: 8 }) { Row({ space: 8 }) {
FbTag({ text: title, tone: fbStageTone(stage).tag }) FbTag({ text: title, tone: fbStageTone(stage).tag })
Text(this.nodesOf(stage).length.toString() + ' 段') Text(this.nodesOf(stage).length.toString() + ' 段')
.fontSize(10) .fontSize(10)
.fontColor(FbColors.faint) .fontColor(FbColors.faint)
Column().layoutWeight(1)
} }
.width('100%') .width('100%')
.padding({ top: 12, bottom: 2 }) .padding({ top: 16, bottom: 4 })
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
Text(hint) Text(hint)
@@ -880,13 +1006,7 @@ export struct FbTripDetailPage {
}, (n: FbTripNode): string => 'nd_' + n.id.toString()) }, (n: FbTripNode): string => 'nd_' + n.id.toString())
} }
.width('100%') .width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.border({ width: { left: 3 }, color: fbStageTone(stage).hex })
// ⚠️ 同上:不写左右 margin(外层已有 pagePad padding),只留纵向间距
.margin({ top: FbMetric.cardGap })
} }
} }
@@ -1044,9 +1164,9 @@ export struct FbTripDetailPage {
.width('100%') .width('100%')
} }
// ⑧ 参与人 // ⑧ 参与人(v76:`Flex wrap` —— 人多时顺着往下铺,不横向溢出卡片)
if (n.members.length > 0) { if (n.members.length > 0) {
Row({ space: 5 }) { Flex({ wrap: FlexWrap.Wrap }) {
ForEach(n.members, (p: FbTripNodePerson) => { ForEach(n.members, (p: FbTripNodePerson) => {
Row({ space: 3 }) { Row({ space: 3 }) {
FbAvatar({ FbAvatar({
@@ -1060,23 +1180,63 @@ export struct FbTripDetailPage {
.fontSize(10) .fontSize(10)
.fontColor(FbColors.muted) .fontColor(FbColors.muted)
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 100 })
} }
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.margin({ right: 8, bottom: 4 })
}, (p: FbTripNodePerson): string => 'nm_' + n.id.toString() + '_' + p.id.toString()) }, (p: FbTripNodePerson): string => 'nm_' + n.id.toString() + '_' + p.id.toString())
} }
.width('100%') .width('100%')
} }
// ⑨ 照片墙(只铺图片;视频/文件端上先不做,见 `parseNodes` 的说明) /* ⑨ 照片墙 / 视频
if (this.photosOf(n).length > 0) { 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看,视频要能够播放。」
Row({ space: 5 }) { ⚠️ 原来这里只铺图片、56×56、**点了没反应**,视频压根不显示
ForEach(this.photosOf(n), (ph: FbTripNodePhoto) => { (注释里写着"视频/文件端上先不做")。现在:
Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get())) ① 图片:56 缩略图,点开全屏大图(可左右滑看同段的所有图);
.width(56) ② 视频:56 方块 + ▶ 角标 + 文件名,点开用 `Video` 播放。
.height(56) ⚠️ 缩略图用 `Wrap` 排:一段行程带十几张照片时不能横向溢出卡片。 */
.objectFit(ImageFit.Cover) if (this.imagesOf(n).length > 0 || this.videosOf(n).length > 0) {
.borderRadius(6) Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.imagesOf(n), (ph: FbTripNodePhoto) => {
Stack({ alignContent: Alignment.Center }) {
Image(fbImageSrc(ph.key, FB_TOKEN_STORE.get()))
.width(64)
.height(64)
.objectFit(ImageFit.Cover)
}
.width(64)
.height(64)
.borderRadius(6)
.clip(true)
.margin({ right: 6, bottom: 6 })
.onClick((): void => {
// 点第几张就从第几张开始看(不是每次都从第一张)
this.openViewer(this.imageKeysOf(n), this.imagesOf(n).indexOf(ph));
})
}, (ph: FbTripNodePhoto): string => 'ph_' + ph.id.toString()) }, (ph: FbTripNodePhoto): string => 'ph_' + ph.id.toString())
ForEach(this.videosOf(n), (vd: FbTripNodePhoto) => {
Stack({ alignContent: Alignment.Center }) {
// 视频没有服务端封面 ⇒ 给深色底 + 播放三角,比放一张坏图诚实
Column()
.width('100%')
.height('100%')
.backgroundColor('#2A2320')
Text('▶')
.fontSize(20)
.fontColor('#FFFFFFCC')
}
.width(64)
.height(64)
.borderRadius(6)
.clip(true)
.margin({ right: 6, bottom: 6 })
.onClick((): void => {
this.openVideo(vd);
})
}, (vd: FbTripNodePhoto): string => 'vd_' + vd.id.toString())
} }
.width('100%') .width('100%')
} }
@@ -1108,11 +1268,197 @@ export struct FbTripDetailPage {
} }
.layoutWeight(1) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.padding({ left: 10, bottom: 10 })
.border({ width: { left: 1 }, color: FbColors.line })
} }
.width('100%') .width('100%')
.alignItems(VerticalAlign.Top) .alignItems(VerticalAlign.Top)
/* 🔴 v76(用户 2026-10-22):「每一个行程都是一个一个块。」
⇒ 每段行程**自己一张卡**:白底 + 圆角 + 左侧阶段色条 + 卡片内边距,
不是原来"整组一张大卡、段与段之间一条灰细线"。
⚠️ 色条取 `n.stage`(节点自带)而不是外层入参 —— 将来把某一段从
「计划」改到「实际」,颜色自动跟着走,不用同步两个地方。 */
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
.border({ width: { left: 3 }, color: fbStageTone(n.stage).hex })
.margin({ bottom: 10 })
}
/**
* 照片查看器(全屏大图 + 左右滑翻页)。
*
* 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看。」
* ⚠️ 照 `FbMovieDetailSheet` 那套查看器写(同一套手势、同一套关闭方式),
* 不要另起一种"点一下放大一点、再点一下缩回去"的交互 ——
* 两个页面同一功能长得不一样,用户会以为其中一个是坏的。
* ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张",裁边缘就失去意义
* (只有缩略图才用 `Cover`)。
* 🔴 左右滑用 **`PanGesture`** 不用 `SwipeGesture`:后者要求"快速划动"才识别,
* 在复杂布局里慢划经常不触发(日程页实测过)。
*/
@Builder
photoViewer() {
Stack({ alignContent: Alignment.Center }) {
// ① 背景(点它关闭)
Column()
.width('100%')
.height('100%')
.backgroundColor('#E6000000')
.onClick((): void => {
this.viewerKeys = [];
this.viewerIndex = 0;
})
// ② 大图 + 左右滑翻页
Stack({ alignContent: Alignment.Center }) {
Image(fbImageSrc(this.viewerKeys[this.viewerIndex], FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
.gesture(
PanGesture({ direction: PanDirection.Horizontal })
.onActionEnd((event: GestureEvent): void => {
const dx: number = event.offsetX;
const dy: number = event.offsetY;
// 位移太小 = 手抖;竖着划 = 用户想上下看,不该翻页
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) {
return;
}
// 左滑看下一张,右滑看上一张(与相册一致)
this.viewerStep(dx < 0 ? 1 : -1);
})
)
}
.width('100%')
.height('76%')
/* ③ 顶部关闭 + 底部翻页条
🔴🔴 v76 真机实测踩到:原来这两块各自写 `.align(Alignment.Bottom)` /
`.align(Alignment.TopEnd)`,以为能贴到 Stack 的角上 —— 实测**完全无效**,
两块都掉到了屏幕正中(关闭按钮跑到 x=518..569,正中间)。
⇒ 别再靠 `.align()` 定位;用**铺满的 Column + SpaceBetween** 顶到底:
第一行放关闭(右对齐),中间 layoutWeight(1) 撑开,
最后一行放翻页条。这样位置只由布局决定,与对齐属性无关。
⚠️ `hitTestBehavior(Transparent)` 必须有:这一层是**透明的**,
它只负责摆按钮的位置;中间大片空白要让点击穿下去给 ① 的遮罩
(否则"点背景关闭"失效)。Transparent = 自身与子节点响应、不阻挡兄弟。
*/
Column() {
Row() {
Column().layoutWeight(1)
Column({ space: 2 }) {
Text('✕')
.fontSize(FbMetric.fontTitle)
.fontColor('#FFFFFFCC')
Text('关闭')
.fontSize(9)
.fontColor('#FFFFFF88')
}
.padding({ left: 18, right: 6, top: 8, bottom: 8 })
.alignItems(HorizontalAlign.End)
.onClick((): void => {
this.viewerKeys = [];
this.viewerIndex = 0;
})
}
.width('100%')
Column().layoutWeight(1)
Row({ space: 18 }) {
Text('‹ 上一张')
.fontSize(FbMetric.fontSmall)
.fontColor('#FFFFFFCC')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.viewerStep(-1);
})
Text((this.viewerIndex + 1).toString() + ' / ' + this.viewerKeys.length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFF99')
Text('下一张 ›')
.fontSize(FbMetric.fontSmall)
.fontColor('#FFFFFFCC')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.viewerStep(1);
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
.padding({ top: 8, bottom: 48 })
.hitTestBehavior(HitTestMode.Transparent)
}
.width('100%')
.height('100%')
}
/**
* 视频播放器(全屏居中 + 系统控件)。
*
* 🔴 v76(用户 2026-10-22):「视频要能够播放。」
* ⚠️ 走 `media.php?k=…&token=…` 这个代理拿字节:它**支持 Range(206)**
* (`media.php` 第 6 段),所以拖进度条、边下边播都正常。
* 直接把 OSS 地址塞给播放器是不行的 —— 桶是私有的,而且没有令牌。
* ⚠️ `controls(true)`:进度条/暂停/全屏都用系统的,不要自己画一套
* (自己画的那套在真机上连"拖进度条"都做不到)。
*/
@Builder
videoPlayer() {
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#F2000000')
.onClick((): void => {
this.videoKey = '';
this.videoName = '';
})
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(this.videoName)
.fontSize(FbMetric.fontSmall)
.fontColor('#FFFFFFCC')
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('✕')
.fontSize(FbMetric.fontTitle)
.fontColor('#FFFFFFCC')
.padding({ left: 14, right: 2, top: 4, bottom: 4 })
.onClick((): void => {
this.videoKey = '';
this.videoName = '';
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Video({
src: fbImageSrc(this.videoKey, FB_TOKEN_STORE.get())
})
.width('100%')
.aspectRatio(16 / 9)
.controls(true)
.autoPlay(true)
.loop(false)
.objectFit(ImageFit.Contain)
.backgroundColor('#000000')
.borderRadius(8)
.onError((): void => {
// 播放不了要**说实话**,不能留一块黑屏让人以为在缓冲
this.videoName = '这个视频放不出来(格式或网络问题)';
})
}
.width('100%')
.padding(16)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
} }
/** 删一段行程的确认框(自绘,避免动系统弹窗层级) */ /** 删一段行程的确认框(自绘,避免动系统弹窗层级) */
@@ -1254,8 +1600,22 @@ export struct FbTripDetailPage {
return this.nodes.filter((n: FbTripNode): boolean => n.stage === stage); return this.nodes.filter((n: FbTripNode): boolean => n.stage === stage);
} }
/** 一段行程里**能显示**的照片(图片且 key 非空) */ /** 一段行程里的**图片**(可点开看大图) */
private photosOf(n: FbTripNode): FbTripNodePhoto[] { private imagesOf(n: FbTripNode): FbTripNodePhoto[] {
return n.attachs.filter((a: FbTripNodePhoto): boolean => a.isImage && a.key !== ''); return n.attachs.filter((a: FbTripNodePhoto): boolean => fbPhotoKind(a) === 'image' && a.key !== '');
}
/** 一段行程里的**视频**(可点开播放) */
private videosOf(n: FbTripNode): FbTripNodePhoto[] {
return n.attachs.filter((a: FbTripNodePhoto): boolean => fbPhotoKind(a) === 'video' && a.key !== '');
}
/** 查看器要用的键列表(与 `imagesOf` 顺序严格一致 —— 索引要对得上) */
private imageKeysOf(n: FbTripNode): string[] {
const out: string[] = [];
for (const a of this.imagesOf(n)) {
out.push(a.key);
}
return out;
} }
} }
+28 -20
View File
@@ -17,7 +17,7 @@
* 别把它加回来当"快捷查看":两套并存的代价是两份节点渲染要同时维护。 * 别把它加回来当"快捷查看":两套并存的代价是两份节点渲染要同时维护。
*/ */
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK } from '../../common/Theme'; import { FbColors, FbMetric, fbTint, FbTint, fbAlpha, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store'; import { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem } from '../../model/Types'; import { FbTripItem } from '../../model/Types';
import { parseTrips } from '../../data/FbParse'; import { parseTrips } from '../../data/FbParse';
@@ -439,27 +439,35 @@ export struct FbTripsPage {
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%') .width('100%')
// 时间 · 城市(一行放得下了 —— 整宽之后不再被小图挤) // 时间(单独一行 —— 城市改成 chip 之后不能再挤在同一行里抢宽度)
Row({ space: 10 }) { if (t.dateRange !== '') {
if (t.dateRange !== '') { Text(t.dateRange)
Text(t.dateRange) .fontSize(FbMetric.fontSmall)
.fontSize(FbMetric.fontSmall) .fontColor(FbColors.muted)
.fontColor(FbColors.muted) .width('100%')
.maxLines(1) }
/* 城市:**一颗颗 chip,一行放不下就自动换到下一行**,全部列出来。
🔴 v76(用户 2026-10-22):「那个城市……如果一行显不下的话,
应该显示在下边一行,把所有的城市全列出来。就跟那个人员一样。」
⚠️ 原来这里是 `Text('📍 ' + t.city).maxLines(1).ellipsis` ——
城市一多就变成「📍 青岛 · 威海 → 济…」,用户看到的就是"显示不全"。
`Row` 不会换行,所以必须用 `Flex({ wrap: FlexWrap.Wrap })`。 */
if (t.cities.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(t.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)
// chip 之间自己留间距(`Flex wrap` 不吃 `space` 参数)
.margin({ right: 6, bottom: 6 })
}, (c: string): string => 'lc_' + t.id.toString() + '_' + c)
} }
if (t.city !== '') { .width('100%')
Text('📍 ' + t.city)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
} else {
Column().layoutWeight(1)
}
} }
.width('100%')
.alignItems(VerticalAlign.Center)
// 段数 / 已完成 / 同行人数 // 段数 / 已完成 / 同行人数
Row({ space: 8 }) { Row({ space: 8 }) {