Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbMovieDetailSheet.ets
T
laoyang 1a67247407 v60b:所有弹框统一主题色描边环 + 编辑改居中弹框 + 修名册下拉回弹吃掉悬浮栏
1)9 个浮层统一描边环(抽组件,不复制 8 遍)
   新增 components/FbSheetRing.ets:环宽 FB_RING_W=2 全工程唯一一处;
   实现是"外层 Column 铺色 + padding",不是 border(border 画在盒子内侧);
   外圆角 = 内圆角 + 环宽。简要卡/详情/编辑/日程详情/日程编辑/观影详情/影院/
   日程设置/模块设置全部改用;各浮层的环色 = 所属模块的主题色。

2)编辑从"贴底满宽 + 只有上圆角"改成居中弹框
   人员编辑与日程编辑:Stack 对齐改 Center,卡片 92% 宽 + 四角圆 + 环;
   constraintSize 留在卡片自己身上(挪到外层会让内层 Scroll().layoutWeight
   拿到 0 高度,资料区塌成一条线)。日程的日期时间选择弹层、两个删除确认框
   一并套环,避免同一次交互里出现两种形态。

3)修名册下拉到头回弹后悬浮按钮消失
   EdgeEffect.Spring 在越界松手后把内容弹回,每帧 offset>0 被当成"用户在往上滑"
   ⇒ 收起悬浮栏。判据 = 手指已抬起 + 最后一次方向是下滑 + 抬手不到 500ms;
   拦截必须排在 offset >= HIDE_AT 快路径之前(回弹第一帧位移常 >= 12vp)。

4)真机截图暴露的两个观感问题(静态检查全绿也照错)
   - Scroll().layoutWeight(1) 让短内容也撑满 maxHeight ⇒ 卡片空半屏
     (日程详情/模块设置),改为 flexShrink(1);内容本来就长的浮层不动。
   - 人员编辑"虚拟人物(还没弄清真实身份)"被卡片裁掉半句:两个小开关从
     并排 Row 改为 Flex(wrap) 换行,间距改走 margin。

验证:ArkTS 守卫 444/0;反向验证 17/17;真机 _fb_v60b_verify.py 11/0,
且关掉回弹拦截重编重装后该用例确实变红(证明测试不是假绿);
9 个浮层逐个真机截图确认。
2026-10-19 08:29:44 +08:00

533 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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 { 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() {
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('#E6000000')
.onClick((): void => {
this.viewerOpen = false;
})
// ② 大图
// ⚠️ `ImageFit.Contain`:查看器的目的是"看清整张图",
// 裁掉边缘就失去意义(缩略图才用 Cover)。
Image(fbImageSrc(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(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
})
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)
}
}