用户 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`、四个页签、点头像不被踢)
581 lines
24 KiB
Plaintext
581 lines
24 KiB
Plaintext
/**
|
||
* 家书 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)
|
||
}
|
||
} |