Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbMovieDetailSheet.ets
T

506 lines
20 KiB
Plaintext
Raw Normal View History

/**
* 家书 FamilyBook · 观影详情浮层(鸿蒙端)
*
* 点观影卡片后弹出,展示:海报大图、片名、时间、影院(含地址)、
* 剧情简介、评分、同看的人、照片。
*
* 🔴 为什么用**页面内浮层(Stack 覆盖)**而不是系统弹窗:
* ① 系统弹窗(`AlertDialog` / `CustomDialog`)受"多弹窗堆叠""后台不许弹"
* 等一堆约束,还要处理生命周期;
* ② 浮层就是一个普通组件,**状态完全由父页面掌控**(`@Param` 一个
* `visible` 就行),要加动画/手势/局部刷新都自由。
* 本项目 Web 端 v43 也是这个思路(`?embed=1` 浮层而不是整页跳转)。
*
* ⚠️ 详情数据是**异步拉的**(网络),所以这个组件要处理三种态:
* 加载中 / 成功 / 失败。别只画成功态 —— 断网时用户会看到"空白浮层"。
*/
import { FbColors, FbMetric } from '../common/Theme';
import { FbMovieAttendeeRow, FbMovieDetail } from '../data/FbMovieRepo';
import { fbImageUrl } from '../data/FbHttp';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FbAvatar, FbRating } from './Widgets';
/**
* 照片缩略图的宽高比。
*
* 🔴 **实测值**:线上照片是 **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 => {
};
// ---- 全屏大图查看器 ----
/** 查看器是否打开 */
@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 }) {
Column({ space: 0 }) {
this.sheetHeader()
this.sheetBody()
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
// ⚠️ 同样用 `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() {
Row() {
// 把手(视觉提示"可以关掉")
Column()
.width(36)
.height(4)
.backgroundColor(FbColors.line2)
.borderRadius(2)
Blank()
Text('关闭')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.onClick((): void => {
this.onClose();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 8, bottom: 4 })
}
/** 浮层主体(三种态) */
@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('#000000E6')
.onClick((): void => {
this.viewerOpen = false;
})
// ② 大图
// ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图",
// 裁掉边缘就失去意义(缩略图才用 Cover)。
Image(fbImageUrl(this.viewerList[this.viewerIndex], FB_TOKEN_STORE.get()))
.width('100%')
.height('76%')
.objectFit(ImageFit.Contain)
// ③ 底部控制条(靠下对齐)
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(fbImageUrl(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
})
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(fbImageUrl(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 对齐)。
.width('92%')
.constraintSize({ maxHeight: '72%' })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}