/** * 家书 FamilyBook · 观影详情浮层(鸿蒙端) * * 点观影卡片后弹出,展示:海报大图、片名、时间、影院(含地址)、 * 剧情简介、评分、同看的人、照片。 * * 🔴 为什么用**页面内浮层(Stack 覆盖)**而不是系统弹窗: * ① 系统弹窗(`AlertDialog` / `CustomDialog`)受"多弹窗堆叠""后台不许弹" * 等一堆约束,还要处理生命周期; * ② 浮层就是一个普通组件,**状态完全由父页面掌控**(`@Param` 一个 * `visible` 就行),要加动画/手势/局部刷新都自由。 * 本项目 Web 端 v43 也是这个思路(`?embed=1` 浮层而不是整页跳转)。 * * ⚠️ 详情数据是**异步拉的**(网络),所以这个组件要处理三种态: * 加载中 / 成功 / 失败。别只画成功态 —— 断网时用户会看到"空白浮层"。 */ import { FbColors, FbMetric } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FbAvatar, FbRating } from './Widgets'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; import { fbImageSrc } from '../data/FbAttachQueue'; /** * 照片缩略图的宽高比。 * * 🔴 **实测值**:线上照片是 **270×377**(`270/377 ≈ 0.7162`)。 * 别改成 1(正方形)—— 竖版照片会被裁掉上下大半,用户一眼就看出不对。 * ⚠️ 服务端目前上传的图都是这个尺寸(4 张实测一致), * 若将来允许不同比例,应改成"每张读自己的尺寸"而不是统一比例。 */ const FB_PHOTO_RATIO: number = 270 / 377; @ComponentV2 export struct FbMovieDetailSheet { /** 要展示的详情(null = 还在加载) */ @Param detail: FbMovieDetail | null = null; /** 加载中标志(与 detail 配合:加载中时 detail 也可能是 null) */ @Param loading: boolean = false; /** 加载失败信息(空串 = 没失败) */ @Param error: string = ''; /** 列表项带过来的评分(详情接口不给,见 FbMovieRepo.detail 的说明) */ @Param listRating: number = 0; @Param listReviewCount: number = 0; /** 关闭回调 */ @Event onClose: () => void = (): void => { }; /** * 卡片外圈描边环的颜色(hex,'' = 用观影模块色)。 * * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 */ @Param accent: string = ''; /** 环色:显式给了就用给的,否则取观影模块色 */ private accentColor(): string { if (this.accent !== '') { return this.accent; } const m: FbModuleDef | undefined = fbModuleOf('movies'); return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; } // ---- 全屏大图查看器 ---- /** 查看器是否打开 */ @Local viewerOpen: boolean = false; /** 查看器当前显示第几张 */ @Local viewerIndex: number = 0; /** 查看器里的图片列表(打开时快照,避免与详情刷新打架) */ @Local viewerList: string[] = []; /** * 详情里所有可看的图:**票根在前 + 现场照片**。 * * ⚠️ 与 Web 端口径一致(`templates/movies/show.php` 把票根和照片 * "合成一块展示、同一套灯箱",注释里写明了不再分类)。 * 端上也别分类 —— 用户关心的是"这次观影的照片",不是它的来源。 */ private allPhotos(d: FbMovieDetail): string[] { const out: string[] = []; if (d.ticketKey !== '') { out.push(d.ticketKey); } for (const k of d.photos) { out.push(k); } return out; } /** 打开全屏查看器(点缩略图时) */ private openViewer(key: string): void { const list: string[] = this.allPhotos(this.detail !== null ? this.detail : this.emptyForViewer()); const idx: number = list.indexOf(key); if (idx < 0) { return; } this.viewerList = list; this.viewerIndex = idx; this.viewerOpen = true; } /** 查看器用的空详情(列表为空时 openViewer 不会走到,仅类型需要) */ private emptyForViewer(): FbMovieDetail { return { ok: false, error: '', id: 0, title: '', startsAt: '', endsAt: '', note: '', info: '', posterKey: '', ticketKey: '', cinemaName: '', cinemaPlace: '', cinemaAddress: '', cinemaColor: '', myRating: 0, myReview: '', reviewCount: 0, reviewAvg: 0, attendees: [], photos: [] }; } /** 上一张 / 下一张(**循环**:到末张再往后回到第一张) */ private viewerStep(delta: number): void { const n: number = this.viewerList.length; if (n === 0) { return; } // ⚠️ 必须取模归一到 [0,n):直接加会让 index 越界 / 变成负数 this.viewerIndex = ((this.viewerIndex + delta) % n + n) % n; } /** * 时间显示:'YYYY-MM-DD HH:mm'(去掉秒)。 * ⚠️ 之前这里写错过一次(把小时当分钟取),时用 substring(11,13)。 */ private shortTime(v: string): string { if (v.length < 16) { return v; } return v.substring(0, 16); } build() { // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— // 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 // ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 Column() { // 🔴 用 Stack 把"浮层内容"和"全屏查看器"叠起来: // 查看器要盖住整个浮层(含它自己的圆角/把手),所以必须是 // **同一个 Stack 里的后声明节点**,不能放进浮层内部的 Column // (那会被浮层的圆角裁剪、也会被内层 Scroll 的滚动带走)。 Stack({ alignContent: Alignment.Center }) { /* ⚠️ 2026-10-19:外面这一层只给宽度(92%)—— 以前卡片是 `width('100%')`(贴屏幕两边)+ 内部 Scroll 92%, 看上去是"一整块通栏白",加环也没处可加(环会跑出屏幕)。 现在:卡片 92% + 主题色环,内部滚动区 100%(等价于原来的内容宽度)。 */ Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { this.sheetHeader() this.sheetBody() } .width('100%') } } .width('92%') // ⚠️ 同样用 `Default`(不写)。`Block` 会挡住浮层内部的子节点 // (照片缩略图、关闭按钮都点不动)。 // `Default` 已足够:浮层在遮罩之后声明 ⇒ 是兄弟节点、会被优先命中。 // ---- 全屏大图查看器 ---- if (this.viewerOpen) { this.viewer() } } .width('100%') // ⚠️ 高度随内容自适应(浮层是"底部抽屉"形态)。 // 🔴 但**查看器打开时必须撑满**:查看器要盖住整屏, // 如果这里还是内容高度,查看器会被裁在一小块里, // 而且**它没盖住的地方点击会穿透到外层的遮罩** —— // 表现就是"点查看器的 ✕,结果把详情浮层也一起关了" // ( 2026-10-12 实测到的 bug)。 .height(this.viewerOpen ? '100%' : undefined) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .hitTestBehavior(HitTestMode.Transparent) } /** 浮层顶部:把手 + 关闭 */ /** 浮层顶部:**只有右上角的 ✕**(关闭位置全应用统一)。 */ @Builder sheetHeader() { /* 🔴🔴 用户 2026-10-19:「点击一个影片之后,弹出的框**左上角有一条灰色的线**, 不知道是什么。」 ⇒ 那是原来放在标题行**最左端**的"把手"(36×4 的浅灰短线, `FbColors.line2`)。我的本意是"抽屉可以下拉关掉"的视觉提示, 但 ① 它落在左上角、旁边没有任何文字,看起来就是**一条莫名其妙的灰线**; ② 这个浮层的关闭本来就不靠下拉(有按钮)。 ⇒ 直接**去掉把手**(用户明确认不出它 = 它没起到提示作用), 同时把关闭收到右上角统一的 ✕(见 `FbSheetClose.ets`)。 ⚠️ 不要"改成居中的把手"来救 —— 那只会把灰线从左上角挪到中间, 用户依然不知道它是什么;这一屏真正需要的信息是"怎么关掉我", 而那个就是右上角的 ✕。 */ Row() { Blank() FbSheetClose({ onClose: (): void => { this.onClose(); } }) } .width('100%') .alignItems(VerticalAlign.Center) .padding({ left: 16, right: 16, top: 10, bottom: 2 }) } /** 浮层主体(三种态) */ @Builder sheetBody() { Column({ space: 0 }) { if (this.error !== '') { // 失败:明确告诉用户,别留白 Column({ space: 8 }) { Text('详情没能加载出来') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) Text(this.error) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(3) .textAlign(TextAlign.Center) } .width('100%') .padding({ top: 40, bottom: 40 }) .justifyContent(FlexAlign.Center) } else if (this.loading || this.detail === null) { Row() { Text('正在加载详情…') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) } .width('100%') .padding({ top: 40, bottom: 40 }) .justifyContent(FlexAlign.Center) } else { this.body(this.detail) } } .width('100%') } /** * 全屏大图查看器。 * * 交互(对齐 Web 端灯箱): * · 点背景关闭; * · 左右两个按钮翻页(**循环**); * · 底部显示"第 n / N 张"。 * * ⚠️ 图片用 `ImageFit.Contain` 而不是 `Cover`: * 查看器的目的是"看清整张图",裁掉边缘就失去意义了。 * (缩略图用 Cover 保持整齐,大图用 Contain 保证完整 —— 两者目的不同。) */ @Builder viewer() { // 🔴 必须用一个容器把查看器的所有零件**包在一层里**。 // `@Builder` 里平铺多个节点时,它们会被依次挂到**父容器**(浮层的 // Stack)上,各自独立参与布局 —— 结果是遮罩/图/控制条/关闭按钮 // 全都变成浮层 Stack 的兄弟节点,位置靠 position/margin 硬凑, // 点击区域互相错位(2026-10-12 实测:点 ✕ 会连带关掉详情浮层)。 // ⇒ 外层一个 Stack 包住,内部用 alignContent 定位,才是正确写法。 Stack({ alignContent: Alignment.Center }) { // ① 全屏背景(点它关闭) Column() .width('100%') .height('100%') .backgroundColor('#E6000000') .onClick((): void => { this.viewerOpen = false; }) // ② 大图 // ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图", // 裁掉边缘就失去意义(缩略图才用 Cover)。 // 🔴 用户 2026-10-19:「点击这个图片呢,不能左右滑动看所有的这个图片。」 // ⇒ 原来只有底部的「上一张/下一张」文字按钮,**没有滑动手势**。 // 现在整张大图可以左右滑(见下面 `parallelGesture`)。 Stack({ alignContent: Alignment.Center }) { Image(fbImageSrc(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get())) .width('100%') .height('76%') .objectFit(ImageFit.Contain) /* 🔴🔴 左右滑翻页。 ⚠️ 用 **`gesture(PanGesture)`** 而不是 `SwipeGesture`: 本轮在日程页实测过 —— `SwipeGesture` 要求"**快速**划动"才识别, 在有滚动容器/复杂布局的页面里容易被别的手势先吃掉,**慢划不触发** (`.gesture` 与 `.priorityGesture` 都试过)。而 `PanGesture` 看的是 **累计位移**(`event.offsetX`),只要够长就认,稳得多。 ⚠️ 这里用 `.gesture`(普通优先级)而不是 `parallelGesture`: 查看器是**全屏覆盖层**,下面没有需要并行滚动的东西; 而且同一个节点上挂两条方向不同的 `parallelGesture` 会互相压制 (日程页周条的实测结论)。 ⚠️ 判定必须要求「横向位移明显大于纵向」:竖着划(想关掉/看上下)时 不该被当成翻页。 */ .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; } // 左滑(dx < 0)= 看下一张;右滑 = 上一张(与相册一致) this.viewerStep(dx < 0 ? 1 : -1); }) ) } .width('100%') .height('76%') // ③ 底部控制条(靠下对齐) 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.viewerList.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) // ⚠️ 用 `align` 把它压到 Stack 底部,而不是靠 margin 推 .align(Alignment.Bottom) .margin({ bottom: 40 }) // ④ 右上角关闭(比"点背景"更明确) // ⚠️ 必须留在 Stack 里当子节点(不能平铺出去)—— // 平铺会跑到浮层 Stack 上,position 相对的是**浮层**而不是查看器。 Column({ space: 2 }) { Text('✕') .fontSize(FbMetric.fontTitle) .fontColor('#FFFFFFCC') Text('关闭') .fontSize(9) .fontColor('#FFFFFF88') } .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .align(Alignment.TopEnd) .onClick((): void => { this.viewerOpen = false; }) } .width('100%') .height('100%') // 🔴 事件拦截取 **`Default`(默认值,不写就行)**,**不能用 `Block`**。 // 🔴 SDK 原文(`component/enums.d.ts`)说得很清楚: // · `Default` —— 自身和**子节点**响应,阻挡**兄弟节点**,不影响祖先; // · `Block` —— 自身响应,**阻挡子节点**、兄弟、祖先; // · `Transparent` —— 自身和子节点响应,**不阻挡**兄弟和祖先。 // 需求是"查看器挡住下层遮罩(兄弟),但里面的翻页/关闭按钮要能点(子节点)" // ⇒ 正是 `Default`。 // ⚠️ 我一开始用了 `Block`,结果**子节点(缩略图/翻页按钮)全部点不动** // —— 2026-10-12 实测:点缩略图毫无反应,查 SDK 注释才发现 Block 会挡子节点。 // // ⚠️ 这里**不额外加 onClick**:加了会让"点查看器任意处"都关掉查看器, // 而遮罩层(子节点之一)已经负责"点空白关闭",两者会打架。 // 靠 `Default` + 遮罩子节点即可:遮罩铺满、点它关闭; // 翻页/关闭按钮在遮罩**之后**声明,层级更高、优先响应。 } /** 成功态的主体 */ @Builder body(d: FbMovieDetail) { Scroll() { Column({ space: 10 }) { // ---- 海报(大图,2:3)---- if (d.posterKey !== '') { Image(fbImageSrc(d.posterKey, FB_TOKEN_STORE.get())) .width(140) .aspectRatio(0.75) .objectFit(ImageFit.Cover) .borderRadius(FbMetric.radiusS) .alt($r('app.media.fb_ic_movies')) } // ---- 片名 ---- Text(d.title) .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .textAlign(TextAlign.Center) // ---- 时间 ---- Text(this.shortTime(d.startsAt) + (d.endsAt !== '' ? ' — ' + this.shortTime(d.endsAt).substring(11) : '')) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) // ---- 评分(来自列表项,见 FbMovieRepo.detail 说明)---- if (this.listReviewCount > 0) { Row({ space: 6 }) { FbRating({ score: this.listRating, max: 10 }) Text(this.listRating.toFixed(1).toString() + ' 分 · ' + this.listReviewCount.toString() + ' 条评价') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .alignItems(VerticalAlign.Center) } // ---- 影院 ---- if (d.cinemaName !== '') { Column({ space: 4 }) { Row({ space: 6 }) { Column() .width(6) .height(6) .backgroundColor(d.cinemaColor !== '' ? d.cinemaColor : FbColors.muted) .borderRadius(3) Text(d.cinemaName) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) } .alignItems(VerticalAlign.Center) if (d.cinemaAddress !== '') { Text(d.cinemaAddress) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(2) } } .width('100%') .alignItems(HorizontalAlign.Center) } // ---- 同看的人 ---- if (d.attendees.length > 0) { Column({ space: 6 }) { Text('同看的人') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Row({ space: 10 }) { ForEach(d.attendees, (a: FbMovieAttendeeRow) => { Column({ space: 3 }) { FbAvatar({ name: a.myNickname !== '' ? a.myNickname : a.name, // ⚠️ 用接口给的头像色(`avatar_color`);没有才退回灰。 // 别写死一个灰 —— 那样所有人的头像都是同一个颜色。 color: a.avatarColor !== undefined && a.avatarColor !== '' ? a.avatarColor : '#A84A28', dia: 34, /* 🔴🔴 用户 2026-10-19:「然后这个观影人的头像并没有带进来。」 ⇒ 同看人这里**只传了名字和底色**,漏了 `imageUrl` —— 于是**有头像的人也退化成色块字**,看起来像"头像没带进来"。 服务端 `Movie::attendees()` 本来就 `SELECT p.avatar`, 端上 `parseAttendees` 也解析进了 `a.avatar`, 纯粹是**渲染时忘了用它**。 ⚠️ 与名册详情 2026-10-15 踩的那个是**同一个 bug** (当时是 `FbPersonSheet` 漏传 `imageUrl`)—— 凡是 `FbAvatar` 都要检查是否传了 `imageUrl`。 ⚠️ `a.avatar` 是**裸 key**,必须走 `fbImageSrc` 拼绝对地址 + 令牌(`Image()` 不支持自定义请求头,令牌只能进查询串)。 */ imageUrl: a.avatar !== '' ? fbImageSrc(a.avatar, FB_TOKEN_STORE.get()) : '' }) Text(a.myNickname !== '' ? a.myNickname : a.name) .fontSize(10) .fontColor(FbColors.muted) .maxLines(1) } }, (a: FbMovieAttendeeRow): string => 'att_' + a.id.toString()) } } .width('100%') .alignItems(HorizontalAlign.Center) } // ---- 剧情简介 ---- if (d.info !== '') { Column({ space: 6 }) { Text('简介') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(d.info) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) // ⚠️ 不设 maxLines:简介本来就长,让用户滚着看全文 .textAlign(TextAlign.Start) } .width('100%') .alignItems(HorizontalAlign.Start) } // ---- 备注 ---- if (d.note !== '') { Column({ space: 6 }) { Text('备注') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(d.note) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) } .width('100%') .alignItems(HorizontalAlign.Start) } // ---- 照片(票根 + 现场照片合成一块,与 Web 端一致)---- if (this.allPhotos(d).length > 0) { Column({ space: 6 }) { Row({ space: 6 }) { Text('观影照片') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) Text(this.allPhotos(d).length.toString() + ' 张') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) } .alignItems(VerticalAlign.Center) Grid() { ForEach(this.allPhotos(d), (k: string) => { GridItem() { // 🔴 缩略图必须按**照片真实比例**做格子。 // 实测线上照片是 **270×377(竖版,比例 ≈0.716)**, // 而第一版用的是**正方形格子**(GridItem 没有固定高度, // 由 Grid 行高决定)⇒ 竖版照片被 `Cover` 裁掉上下大半, // "看起来格式不对"。用户 2026-10-12 反馈的就是这个。 Column() { Image(fbImageSrc(k, FB_TOKEN_STORE.get())) .width('100%') .height('100%') .objectFit(ImageFit.Cover) .alt($r('app.media.fb_ic_movies')) } .width('100%') // ⚠️ 用 `aspectRatio` 指定比例,而不是靠父容器推: // 在 Grid 里 `height('100%')` 拿不到确定的高度 // (行高是内容决定的),必须显式给比例。 .aspectRatio(FB_PHOTO_RATIO) .clip(true) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.openViewer(k); }) } }, (k: string): string => 'ph_' + k) } .columnsTemplate('1fr 1fr 1fr') .columnsGap(6) .rowsGap(6) .width('100%') } .width('100%') .alignItems(HorizontalAlign.Start) } } .width('100%') .padding({ left: 16, right: 16, top: 4, bottom: 28 }) .alignItems(HorizontalAlign.Center) } // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 /* ⚠️ 100%(不是 92%):卡片本身已经是 92% 宽, 这里再 92% 就变成 84.6%("卡片比容器窄一圈"的经典错)。 */ .width('100%') .constraintSize({ maxHeight: '72%' }) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) } }