diff --git a/entry/src/main/ets/data/FbParse.ets b/entry/src/main/ets/data/FbParse.ets index 07785b8..a25dd47 100644 --- a/entry/src/main/ets/data/FbParse.ets +++ b/entry/src/main/ets/data/FbParse.ets @@ -271,7 +271,7 @@ export function parseTrips(json: string): FbTripItem[] { const rows: RawTrip[] = asArray(json); const out: FbTripItem[] = []; for (const r of rows) { - const cities: string[] = arr(r.cities); + const cities: string[] = cityListOf(r); const dayCount: number = daySpan(s(r.starts_at), s(r.ends_at)); const it: FbTripItem = { id: n(r.id), @@ -285,6 +285,7 @@ export function parseTrips(json: string): FbTripItem[] { ⚠️ `cover_key` 存**原始键**,拼地址交给 `fbMediaUrl()`(见 FbTripItem 的说明)。 */ coverKey: s(r.cover_key), city: s(r.city) !== '' ? s(r.city) : cities.join(' · '), + cities: cities, summary: s(r.summary), attendeeCount: n(r.attendee_count), }; @@ -293,6 +294,43 @@ export function parseTrips(json: string): FbTripItem[] { 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(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 */ export function parseDays(json: string): FbAnniversaryItem[] { const rows: RawDay[] = asArray(json); diff --git a/entry/src/main/ets/data/FbTripRepo.ets b/entry/src/main/ets/data/FbTripRepo.ets index 2144a16..e89156c 100644 --- a/entry/src/main/ets/data/FbTripRepo.ets +++ b/entry/src/main/ets/data/FbTripRepo.ets @@ -405,7 +405,12 @@ function attachsOf(o: Record, k: string): FbTripNodePhoto[] { id: numOf(a, 'id'), key: key, 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; diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets index dbc7e19..14b6161 100644 --- a/entry/src/main/ets/model/DemoData.ets +++ b/entry/src/main/ets/model/DemoData.ets @@ -114,22 +114,22 @@ export const FB_DEMO_CINEMAS: string[] = ['家里', '万达影城·李沧店', ' export const FB_DEMO_TRIPS: FbTripItem[] = [ { 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 }, { 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 }, { 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 }, { 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 } ]; diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets index f8475ca..fb03f20 100644 --- a/entry/src/main/ets/model/Types.ets +++ b/entry/src/main/ets/model/Types.ets @@ -403,6 +403,15 @@ export interface FbTripItem { * 这个字段**只放城市**,供卡片单独显示「📍 青岛」那一行。 */ city: string; + /** + * 城市列表(已去重,服务端 `cities` 原样)。 + * + * 🔴 v76(用户 2026-10-22):「那个城市……如果一行显不下的话,应该显示在下边一行, + * 把所有的城市全列出来。就跟那个人员一样。」 + * ⇒ 卡片上不再拼成一个长串(那样只能截断),改成**一颗颗 chip 自动换行**。 + * ⚠️ `city` 保留:有的地方(如按城市搜索)还是想要一个整串。 + */ + cities: string[]; /** 简介(网页版列表卡截 80 字显示) */ summary: string; /** 同行人数(所有行程参与人的合集,与网页版一致) */ @@ -482,8 +491,19 @@ export interface FbTripNodePhoto { key: string; /** 原始文件名(非图片才有意义) */ name: string; - /** 1 = 图片,可直接铺缩略图 */ + /** 1 = 图片,可直接铺缩略图(服务端 `is_image`,按内容嗅探得到) */ isImage: boolean; + /** + * 内容嗅探出来的 MIME(服务端 `attachments.mime`)。 + * + * 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看,视频要能够播放。」 + * ⇒ 端上第一次需要区分「图片 / 视频 / 其它」,而 `is_image` 只有两态。 + * ⚠️ 判视频一律用 `mime` 前缀 + 扩展名兜底,**不看 `is_image === false`** + * —— 那份 pdf/word 也不是图片,但不是视频。 + */ + mime: string; + /** 字节数(0 = 服务端没给) */ + size: number; } /** 旅程详情页右上角的五项统计(服务端 `Trip::summary()`) */ diff --git a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets index 115a29e..b28e1ca 100644 --- a/entry/src/main/ets/pages/trips/FbTripDetailPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripDetailPage.ets @@ -216,6 +216,36 @@ function fbStageTone(stage: string): FbStageTone { 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 export struct FbTripDetailPage { @@ -260,6 +290,18 @@ export struct FbTripDetailPage { @Local delNodeId: number = 0; @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 { /* ⚠️ 登记 key 用**跟列表页不同的名字**:两个页面不会同时挂载, 但 `FB_BACK` 的语义是"后登记的先被问",同名会让注销互相踩。 */ @@ -279,6 +321,18 @@ export struct FbTripDetailPage { * 这里最后一步 `return false` 把控制权交回 `Index`(它负责换模块回列表)。 */ 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) { this.delNodeId = 0; return true; @@ -342,6 +396,46 @@ export struct FbTripDetailPage { 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) { this.delNodeConfirm() } + + /* 🔴 v76:照片查看器 / 视频播放器放在**最后一个位置**。 + 在 `Stack` 里"后写的盖在上面",所以它压住所有浮层 —— 用户点了照片 + 就该直接看到大图,而不是"点开又冒出一个浮层把它盖住"。 */ + if (this.viewerKeys.length > 0) { + this.photoViewer() + } + if (this.videoKey !== '') { + this.videoPlayer() + } } .width('100%') .height('100%') @@ -695,8 +799,13 @@ export struct FbTripDetailPage { .width('100%') .alignItems(VerticalAlign.Center) + /* 城市 chip 墙 —— **一行放不下就换行,全部列出来**。 + 🔴 v76(用户 2026-10-22):「如果一行显不下的话,应该显示在下边一行, + 把所有的城市全列出来。就跟那个人员一样。」 + ⚠️ 原来是 `Row({space:6})`:`Row` **不会换行**,城市多了会被压扁 + (每个 chip 缩到只剩一两个字)或者直接溢出卡片。 */ if (this.detail !== null && this.detail.cities.length > 0) { - Row({ space: 6 }) { + Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.detail.cities, (c: string) => { Text(c) .fontSize(FbMetric.fontTiny) @@ -704,6 +813,7 @@ export struct FbTripDetailPage { .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(fbAlpha(this.accent, 0.1)) .borderRadius(FbMetric.radiusPill) + .margin({ right: 6, bottom: 6 }) }, (c: string): string => 'city_' + c) } .width('100%') @@ -719,7 +829,14 @@ export struct FbTripDetailPage { .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .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) => { Row({ space: 4 }) { FbAvatar({ @@ -733,11 +850,14 @@ export struct FbTripDetailPage { .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: 120 }) } .padding({ left: 4, right: 8, top: 2, bottom: 2 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusPill) .alignItems(VerticalAlign.Center) + .margin({ right: 6, bottom: 6 }) }, (p: FbTripNodePerson): string => 'tp_' + p.id.toString()) } .width('100%') @@ -858,15 +978,21 @@ export struct FbTripDetailPage { stageGroup(stage: string, title: string, hint: string) { if (this.nodesOf(stage).length > 0) { Column({ space: 0 }) { - // ---- 组头 ---- + /* ---- 组头:**只当分隔标识,不再套一张大卡** ---- + 🔴 v76(用户 2026-10-22):「它那个实际行程啊,它应该也是一段一段的, + 不要把它整个实际行程放在一块,就是每一个行程都是一个一个块。」 + ⇒ 组头退成一条"章节标题",每段行程各自一张卡(见 `nodeCard`)。 + ⚠️ 原来整组是一张白卡、里面用一条灰细线分隔各段 —— 视觉上 + 仍然"糊成一块",看不出这是一段一段的记录。 */ Row({ space: 8 }) { FbTag({ text: title, tone: fbStageTone(stage).tag }) Text(this.nodesOf(stage).length.toString() + ' 段') .fontSize(10) .fontColor(FbColors.faint) + Column().layoutWeight(1) } .width('100%') - .padding({ top: 12, bottom: 2 }) + .padding({ top: 16, bottom: 4 }) .alignItems(VerticalAlign.Center) Text(hint) @@ -880,13 +1006,7 @@ export struct FbTripDetailPage { }, (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(外层已有 pagePad padding),只留纵向间距 - .margin({ top: FbMetric.cardGap }) } } @@ -1044,9 +1164,9 @@ export struct FbTripDetailPage { .width('100%') } - // ⑧ 参与人 + // ⑧ 参与人(v76:`Flex wrap` —— 人多时顺着往下铺,不横向溢出卡片) if (n.members.length > 0) { - Row({ space: 5 }) { + Flex({ wrap: FlexWrap.Wrap }) { ForEach(n.members, (p: FbTripNodePerson) => { Row({ space: 3 }) { FbAvatar({ @@ -1060,23 +1180,63 @@ export struct FbTripDetailPage { .fontSize(10) .fontColor(FbColors.muted) .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: 100 }) } .alignItems(VerticalAlign.Center) + .margin({ right: 8, bottom: 4 }) }, (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) + /* ⑨ 照片墙 / 视频 + 🔴 v76(用户 2026-10-22):「里面的照片要可以点击查看,视频要能够播放。」 + ⚠️ 原来这里只铺图片、56×56、**点了没反应**,视频压根不显示 + (注释里写着"视频/文件端上先不做")。现在: + ① 图片:56 缩略图,点开全屏大图(可左右滑看同段的所有图); + ② 视频:56 方块 + ▶ 角标 + 文件名,点开用 `Video` 播放。 + ⚠️ 缩略图用 `Wrap` 排:一段行程带十几张照片时不能横向溢出卡片。 */ + if (this.imagesOf(n).length > 0 || this.videosOf(n).length > 0) { + 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()) + + 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%') } @@ -1108,11 +1268,197 @@ export struct FbTripDetailPage { } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - .padding({ left: 10, bottom: 10 }) - .border({ width: { left: 1 }, color: FbColors.line }) } .width('100%') .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); } - /** 一段行程里**能显示**的照片(图片且 key 非空) */ - private photosOf(n: FbTripNode): FbTripNodePhoto[] { - return n.attachs.filter((a: FbTripNodePhoto): boolean => a.isImage && a.key !== ''); + /** 一段行程里的**图片**(可点开看大图) */ + private imagesOf(n: FbTripNode): FbTripNodePhoto[] { + 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; } } \ 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 4fe0a14..2f0c7b4 100644 --- a/entry/src/main/ets/pages/trips/FbTripsPage.ets +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -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 { FbTripItem } from '../../model/Types'; import { parseTrips } from '../../data/FbParse'; @@ -439,27 +439,35 @@ export struct FbTripsPage { .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') - // 时间 · 城市(一行放得下了 —— 整宽之后不再被小图挤) - Row({ space: 10 }) { - if (t.dateRange !== '') { - Text(t.dateRange) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .maxLines(1) + // 时间(单独一行 —— 城市改成 chip 之后不能再挤在同一行里抢宽度) + if (t.dateRange !== '') { + Text(t.dateRange) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .width('100%') + } + + /* 城市:**一颗颗 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 !== '') { - Text('📍 ' + t.city) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } else { - Column().layoutWeight(1) - } + .width('100%') } - .width('100%') - .alignItems(VerticalAlign.Center) // 段数 / 已完成 / 同行人数 Row({ space: 8 }) {