Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbMovieDetailSheet.ets
T
laoyang 4a063114c5 v62:修 P0 登录被踢 / 关闭按钮统一右上角 / 观影三处 / 影院管理补齐 / 名册三处
用户 2026-10-19 第二批八条(原话逐条对应):

═══ ⑨ P0:「现在还是不稳定,会退出,提示:这台设备已经被退出登录,请重新登录。」

真因**是我 v61 自己埋的**:「个人设置」页(`FbAccountPage`)里两处 `fbApi`
调用**取了 `token` 变量却没传进请求选项** ⇒ 没有 Authorization 头 ⇒ 服务端 401
⇒ `FbHttp` 的全局回调 `unauthorizedCb` **清掉本地令牌 + 踢回登录页**。
而本页在 `aboutToAppear` 里就调它 ⇒ **点一下右上角头像,App 就退出登录**。

⚠️ 我上一轮把同一现象归因成"残留旧令牌 / 设备会话被顶掉"(还去线上查了
   `api_tokens` 表,一切正常)—— **那个结论是错的**,等于把 bug 归给了环境。

修法(两处调用 + **两道架构防线**,缺一不可):
  ① 修两处调用,把 token 传进去;
  ② `fbApi` 内部:**本地有令牌、调用方又没传时自动补上**
     (大部分接口本来就要鉴权,补上是"纠正";`/login`、`/ping` 带了也无害);
  ③ **401 只在"确实带了令牌"时才算被踢** —— 否则"没带令牌"也会触发全局清令牌,
     那正是这次事故的机制。

═══ ④「你这个按钮到底是在弹框的右上角关闭,还是在下面关闭……全放在右上角。」

选**右上角 ✕**(底部已被 编辑/保存/删除 占满,再塞关闭会堆三四个按钮,分不清主操作)。
新增 `components/FbSheetClose.ets`:
  · `FbSheetClose`  —— 32vp 圆形 ✕(热区 40+,小图标在手机上点不中);
  · `FbSheetTitleBar` —— 左标题 + 右收尾(设置类给 `confirmText` 显示「完成」)。
9 个浮层全接;名册详情 / 日程详情 / 简要卡**底部不再有「关闭」**。
⚠️ 浅色底走 `fbAlpha`,**不能拼 `+ '14'`** —— ArkUI 八位色是 `#AARRGGBB`
   (本项目曾因写 `#00000066` 导致遮罩**从来没显示过**)。

═══ ①「观影……弹出的框左上角有一条灰色的线,不知道是什么。」

那是浮层顶部的**抽屉把手**(36×4 的 `line2` 浅灰短线)。本意是"可下拉关掉"的提示,
但 ① 它落在左上角、旁边没有文字;② 这个浮层的关闭本来就不靠下拉。
⇒ 用户认不出它 = 它没起作用,**删掉**(不改成居中 —— 那只是把灰线挪个位置)。

═══ ②「然后这个观影人的头像并没有带进来。」

同看人的 `FbAvatar` **漏传 `imageUrl`** —— 服务端 `Movie::attendees()` 本来就
`SELECT p.avatar`、端上 `parseAttendees` 也解析进了 `a.avatar`,
纯粹是**渲染时忘了用它** ⇒ 有头像的人也退化成色块。
⚠️ 与名册详情 2026-10-15 那个是同一个 bug(当时是 `FbPersonSheet` 漏传)。

═══ ③「点击这个图片呢,不能左右滑动看所有的这个图片。」

全屏查看器原来**只有底部「上一张/下一张」文字按钮**。现在整张大图左右滑翻页。
⚠️ 用 `.gesture(PanGesture(Horizontal))` 而不是 `SwipeGesture`:
   `SwipeGesture` 要"快速划动"才识别,在有滚动容器的页面容易被先吃掉
   (日程页实测:`gesture`/`priorityGesture` 都不触发)。`PanGesture` 看**累计位移**。
⚠️ 判定要求 `|dx| >= 40 && |dx| > |dy|`,否则竖划会被误判成翻页。

═══ ⑤「影院管理……增加影院呢、修改影院呢、高级搜索、统计、年度报告,这些全部都应该补充进去。」

端上原来**只有一个只读列表**(连"新增"都没有)。补成四个页签:
  · **影院**:新增 / 修改 / 删除(服务端 CRUD 早就有,端上一直没接)+ 删除二次确认;
  · **统计**:总览(看过/影院/人数/人次)+ 按影院/按年/按月的条形图;
  · **搜索**:片名 / 影院 / 起止日期(条件全空时不查,与网页 `hasCond` 同行为);
  · **年报**:按年切换(第一场/最后一场、最常去的影院、一起看最多的人、每月分布)。
⚠️ **统计/年报必须联网**(`/movies/stats|report`):口径要与网页**完全一致**
   (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分",
   少同步一次就少一部 ⇒ 用户会发现"网页 124 部、App 121 部"。
   ⇒ 断网时明说"需要联网",而不是给一个偏低的数字。
⚠️ 影院写操作后**立刻回写本地库**(`refreshCinemas`),否则刚建的那家要等下次同步
   才出现,看着像失败;并用 `INSERT OR REPLACE` 而非"清空再插"
   (中途失败会留下一个空表 = 用户的影院列表整个消失)。

═══ ⑥「名册搜索,搜索后,下面有一个白色的框,什么内容都没有,删除。」

那是列表**末尾给悬浮栏留高度的占位块**(`height(FB_DOCK_RESERVE)`),
底色是 `FbColors.surface`(**白**)而列表容器是 `paper`(**米**)——
列表内容一短(搜索只剩一两条)它就露出来,看着像"什么都没有的白框"。
⇒ 改底色与列表同色。**不能删**:删了最后一张卡片会被悬浮栏压住。

═══ ⑦「如果一个人没头像,在列表是有一个文字的头像的,那么点击后弹框,也应该有。」

`FbAvatar` 原来是 `if/else`:有 `imageUrl` 就**只画 Image**,
而图片**加载失败时 `Image` 什么都不显示**(只露出纯色底)⇒ 看着是个光板。
(`alt()` 只在**加载中**起作用,失败后是空白。)
⇒ 改成**叠加**:底色 + 文字头像**常驻**,图片有就叠在上面
(加载成功即盖住文字,失败则自动露出文字头像)。
⚠️ 之前注释说"图层叠会错位"——那是因为当时想靠 Image 的 intrinsic size 撑尺寸;
   现在两层都显式给 `dia`,尺寸来源唯一,不存在错位。

═══ ⑧「现在名册的列表太空了,应该显示更多内容,例如工作单位、职务。」

列表卡片加一行「单位|职务」(独立成行,避免与标签/生日挤)。
⚠️ 用 `persons.org` / `persons.title` 而**不是** `person_jobs`:
   列表读的是**本地库**,而本地库只存 `persons` 那一行
   (`person_jobs` 是一对多,端上没同步那张表)⇒ 服务端加了字段也到不了列表。
   线上实测 801 人里 192 人有单位、48 人有职务,列表立刻有内容。

验证:
· 编译 BUILD SUCCESSFUL
· ArkTS 守卫 536/0(新增整组 groupV62,含 P0 两道防线)
· 反向验证 51/0(新增 13 条注入,逐条确认能变红)
· Schema 列守卫 12/0
· **真机 22/0**(6 组,含照片横滑 `1/5 → 2/5`、四个页签、点头像不被踢)
2026-10-19 15:27:31 +08:00

581 lines
24 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 { 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)
}
}