Files
FamilyBook_harmonyos/entry/src/main/ets/pages/movies/FbMoviesPage.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

721 lines
29 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 · 观影模块(鸿蒙端)
*
* 对应 Web 端 `templates/movies/index.php`。Web 端是**海报墙**(waterfall 瀑布流),
* 端上改成**两列网格**:手机宽度下两列海报刚好一屏看清 4–6 部,
* 比瀑布流(列宽随图片高度浮动)更整齐、也更好点。
*
* 页头右侧两个入口:搜索、设置(影院管理 / 统计 / 年度报告 —— 与 Web 端一致,
* 「影院」不单独占抽屉位置,它是观影模块的一部分)。
*
* 🔴 **本地优先(local-first)的第一个落地模块**(用户 2026-10-12 定):
* 数据流是「接口 → 本地库 → UI」,而不是「UI 直接读接口」。
* · `aboutToAppear` 时先读**本地库**渲染(有数据就立刻显示,不等网络);
* · 然后**后台**跑一次 `sync()` 把服务端数据补进来;
* · 同步完再读一次本地库刷新界面。
* 这样断网时页面照常可用(因为读的本来就是本地),
* 有网时数据会自动变新。
*
* ⚠️ 为什么不在 `aboutToAppear` 里 `await sync()` 再渲染:
* 那样就成了"网络优先"——转圈等 2 秒才出内容,
* 断网时更是完全空白。**先渲染本地、再后台同步**是这套架构的要点。
*/
import { FbColors, FbMetric, fbPalette, fbTint, FbTint, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbKinChip } from '../../model/Types';
import { FbCinemaItem } from '../../data/FbMovieRepo';
import { FbDot, FbEmptyBox, FbIconButton, FbPersonChip, FbPrimaryButton, FbRating, FbSectionTitle } from '../../components/Widgets';
import { FB_DOCK_RESERVE } from '../../components/FloatDock';
import { FbDb } from '../../data/FbDb';
import { FbMovieDetail, FbMovieListItem, FbMovieRepo, FbSyncResult } from '../../data/FbMovieRepo';
import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet';
import { FbCinemaSheet } from '../../components/FbCinemaSheet';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack';
import { FB_SET_MOVIES } from '../../model/FbSettingsMenus';
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fbOnScroll, fbOnTouch } from '../../common/FbScroll';
import { fbMaskTransition, fbSheetTransition } from '../../common/FbAnim';
import { fbImageSrc } from '../../data/FbAttachQueue';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbMoviesPage';
/**
* 把接口的完整时间('YYYY-MM-DD HH:mm:ss')裁成卡片上够用的短形式。
*
* ⚠️ 只在**显示**时裁,存库仍存完整值 —— 否则详情页/排序会丢精度。
* ⚠️ 输入可能为空(老数据没填时间)⇒ 必须返回空串而不是 'undefined'。
*/
function shortTime(v: string): string {
if (v.length < 16) {
return v; // 短于 'YYYY-MM-DD HH:mm' 就原样返回(含空串)
}
return v.substring(0, 16); // 去掉最后的 ':ss'
}
@ComponentV2
export struct FbMoviesPage {
@Param app: FbAppState = fbAppState();
/**
* 顶栏插槽:由 Index.ets 传入,放在本页滚动流最前面 ⇒ 随页面一起滚走。
* 🔴 用户要求「向上滑动时滑动时标题栏跟着一起滑走」。
*/
@BuilderParam topBar: () => void;
/**
* 观影列表(**来自本地库**)。
*
* ⚠️ 初值给空数组而不是演示数据:一上来就显示假数据、随后被真数据替换,
* 用户会看到"闪一下"。空数组 + 一个明确的"加载中/暂无"状态更诚实。
* (演示数据仍保留在 DemoData 里,供后续做空态预览用。)
*/
@Local movies: FbMovieListItem[] = [];
/** 是否正在首次加载(决定显示"加载中"还是"暂无记录") */
@Local loading: boolean = true;
/** 观影页顶部一行提示(自检/同步状态共用;谁最后写谁显示) */
@Local syncNote: string = '';
/** 临时自检结果(与同步提示分开,避免互相覆盖) */
/**
* 海报加载失败的电影 id。
*
* 🔴 为什么需要它:海报是网络图,取不到是**常态**(没网、图被删、令牌失效)。
* 不处理的话这张卡会一直显示"半张白图",用户以为页面坏了。
* ⇒ 记下失败的 id,那张卡改回"图标 + 片名"的占位(与"本来就没海报"同观感)。
* ⚠️ 用 `@Local` + 数组整体替换来触发刷新:
* ArkUI 的 `@Local` 只代理第一层,直接 `push` 到数组里**不会刷新界面**
* (§05-state 的规则)。所以每次都用新数组赋值。
*/
@Local posterFailIds: number[] = [];
// ---- 详情浮层 ----
/** 浮层是否显示 */
@Local sheetOpen: boolean = false;
/** 浮层里的详情(null = 还没加载出来) */
@Local sheetDetail: FbMovieDetail | null = null;
/** 详情是否加载中 */
@Local sheetLoading: boolean = false;
/** 详情加载失败信息(空串 = 没失败) */
@Local sheetError: string = '';
/** 浮层对应的列表项评分(详情接口不给评分,从列表带,见 FbMovieRepo.detail 说明) */
@Local sheetRating: number = 0;
@Local sheetReviewCount: number = 0;
/**
* 浮层正在展示的电影 id。
*
* ⚠️ 两个用途,缺一不可:
* ① **防重入**:连点同一条时不重复叠浮层(实测会叠层,照片数翻倍);
* ② **防串数据**:详情是异步拉的,回来时要确认"还是这条"才写入
* (否则点 A 又点 B,A 的数据会糊到 B 的浮层上)。
*/
@Local sheetMovieId: number = 0;
/**
* 影院列表(**从本地库读**,`/api/v1/cinemas` 的同步结果)。
*
* 🔴 之前这里是 `FB_DEMO_CINEMAS`(写死的演示数组),只用来显示"影院 N 家"
* —— 而「5 家」那个数字**恰好**跟真实值一样,所以看不出是假的。
* 改成真实列表后,数字与列表同源,不会再对不上。
*/
@Local cinemas: FbCinemaItem[] = [];
/**
* 模块设置浮层是否打开。
*
* 🔴 用户 2026-10-16:右上角按钮统一改成网页版那种**三条横线**(☰),
* 点开是**这个模块的设置菜单**(照 `templates/movies/_settings_menu.php`)。
*/
@Local settingsOpen: boolean = false;
/** 影院浮层是否打开 */
@Local cinemaSheetOpen: boolean = false;
/**
* 🔴 浮层开合时**让底部悬浮栏让位**(与名册页同一处 bug,见 `FbPeoplePage` 的说明)。
*
* 浮层装在**页面自己的 Stack** 里,而悬浮栏在**更外层 `Index` 的 Stack** 里
* ⇒ 悬浮栏层级更高 ⇒ 浮层底部的按钮会与它重叠、点击被抢走。
* ⇒ 用 `@Monitor` 一处声明、自动跟随派生(避免在多个赋值点手写、漏一处就出事)。
*/
@Monitor('sheetOpen', 'cinemaSheetOpen', 'settingsOpen')
onSheetChanged(): void {
const app: FbAppState = fbAppState();
app.sheetCount = (this.sheetOpen || this.cinemaSheetOpen || this.settingsOpen) ? 1 : 0;
}
/** 模块主题色(观影 = 紫) */
private accent: string = '#7A4E8C';
private repo: FbMovieRepo = FbMovieRepo.inst();
/**
* 页面出现:先读本地库渲染,再后台同步。
* ⚠️ 不能声明成 async 让框架 await —— 生命周期钩子返回 void 即可,
* 异步逻辑在内部自己跑(否则会影响页面首帧时机)。
*/
/**
* 🔴 v59e(用户 2026-10-18):系统返回手势 —— 只关**最上面一层**浮层。
*
* 本页三个浮层:详情(`sheetOpen`)/ 影院(`cinemaSheetOpen`)/ 设置(`settingsOpen`)。
* ⚠️ 顺序按"后出现的在上层":设置里点「影院管理」会把设置关掉、开影院
* (两者不会同时开),但反过来"影院开着时又点设置"是可能的 ⇒ 先收设置。
*/
aboutToDisappear(): void {
FB_BACK.unregister('movies');
}
private backOnce(): boolean {
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
if (this.cinemaSheetOpen) {
this.cinemaSheetOpen = false;
return true;
}
if (this.sheetOpen) {
this.sheetOpen = false;
return true;
}
return false;
}
aboutToAppear(): void {
FB_BACK.register('movies', (): boolean => this.backOnce());
this.bootstrap();
}
/** 该电影的海报是否已确认加载失败 */
private posterFailed(id: number): boolean {
return this.posterFailIds.indexOf(id) >= 0;
}
/**
* 记下"这张海报加载失败"。
*
* ⚠️ 必须**换成新数组**赋值(`concat`),不能 `push` ——
* `@Local` 不代理数组内部变化,push 进去界面不会刷新
* (表现为"图片还是白的,占位也没出现",看着像没生效)。
* ⚠️ 已经记过就不重复加:`onError` 可能被多次触发,
* 重复添加会让数组无限增长(虽然去重判断也要 O(n),但这里 n 很小)。
*/
private markPosterFailed(id: number): void {
if (this.posterFailed(id)) {
return;
}
this.posterFailIds = this.posterFailIds.concat([id]);
}
/**
* 点卡片 → 打开详情浮层。
*
* 🔴 先"立刻开浮层 + 显示加载中",再异步拉详情:
* 如果等详情回来才开浮层,用户点下去会有**明显延迟**(网络快则一闪、
* 慢则好几秒毫无反应,用户以为没点到)。
* 先把浮层弹出来、里面写"正在加载详情…",体感完全不同。
*
* ⚠️ 未登录(没令牌)时**不开浮层**,而是给一句提示:
* 详情要连服务端拉,没令牌必然失败;让用户看到"加载失败"不如
* 直接说清"没连服务器"。
*/
private openDetail(mv: FbMovieListItem): void {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
this.syncNote = '未连接服务器,详情需要联网查看';
return;
}
// 🔴 **防重入**:已经是这条、且浮层已经开着 ⇒ 直接返回。
// 不加这个判断时,连点卡片会**反复叠加浮层**
// (2026-10-12 实测:浮层里的照片数从 5 变成 9,就是叠了两层)。
if (this.sheetOpen && this.sheetMovieId === mv.id) {
return;
}
this.sheetMovieId = mv.id;
// 先把浮层弹出来(带加载态),再拉数据
this.sheetRating = mv.reviewAvg;
this.sheetReviewCount = mv.reviewCount;
this.sheetDetail = null;
this.sheetError = '';
this.sheetLoading = true;
this.sheetOpen = true;
this.loadDetail(mv.id, token, mv);
}
/** 拉详情并填进浮层 */
private async loadDetail(id: number, token: string, mv: FbMovieListItem): Promise<void> {
const d: FbMovieDetail = await this.repo.detail(id, token, mv);
// ⚠️ 用户可能在等待期间**已经关掉浮层**(或点了别的卡片)
// ⇒ 回来时先确认浮层还开着、且**还是这一条**,否则会把旧数据糊到新浮层上。
if (!this.sheetOpen || this.sheetMovieId !== id) {
return;
}
this.sheetLoading = false;
if (!d.ok) {
this.sheetError = d.error;
return;
}
this.sheetDetail = d;
}
/** 启动流程:开库 → 读本地 → 后台同步 → 再读本地 */
private async bootstrap(): Promise<void> {
hilog.info(DOMAIN, TAG, 'bootstrap 开始');
try {
// ① 取 Ability 上下文。
// 🔴 用 `getUIContext().getHostContext()`,**不用全局 `getContext(this)`**:
// 全局 getContext 在 Stage 模型多 ArkUI 实例下可能取错/取不到,
// 规则文件明确要求换掉(r/quick-rules/02-uicontext.md)。
// RDB / preferences 都要靠这个 Context 定位应用沙箱目录。
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
hilog.error(DOMAIN, TAG, '拿不到 Ability Context,本地库无法打开');
this.loading = false;
this.syncNote = '本地存储不可用(上下文获取失败)';
return;
}
hilog.info(DOMAIN, TAG, '拿到 Context,准备打开本地库');
// ② 打开本地库(首装会建表;这一步本身很快)
await FbDb.inst().open(ctx);
hilog.info(DOMAIN, TAG, '本地库打开成功');
} catch (err) {
// ⚠️ 把**真实错误**显示到界面上:
// 这台设备的 hilog 缓冲区读不出内容(已确认),界面是唯一可靠通道。
// 临时诊断用,定位完就换回友好文案。
const e: BusinessError = err as BusinessError;
const detail: string = (e.code !== undefined ? 'code=' + e.code.toString() + ' ' : '') +
(e.message !== undefined ? e.message : JSON.stringify(err));
hilog.error(DOMAIN, TAG, '本地库打开失败:%{public}s', detail);
this.loading = false;
this.syncNote = '本地库打开失败:' + detail;
return;
}
// ③ 先读本地(有数据立刻显示)
await this.reloadFromLocal();
// ④ 后台同步(不阻塞界面)
this.syncInBackground();
}
/** 临时:把本地库自检结果拼成一行显示(定位完删除) */
/** 从本地库读列表刷新界面 */
private async reloadFromLocal(): Promise<void> {
try {
const rows: FbMovieListItem[] = await this.repo.list(300);
this.movies = rows;
} catch (err) {
hilog.error(DOMAIN, TAG, '读本地观影失败:%{public}s', JSON.stringify(err));
}
// 🔴 影院与观影列表**分开 try**。
// 为什么:影院是"附属信息"(只是页头那行"影院 N 家"),
// 它读失败**不该**影响观影列表的显示。
// ⚠️ 踩过的坑:一开始把这段塞进了上面的 `catch` 里
// (脚本按"插在 finally 前"定位,结果落进了 catch)——
// 表现为"只有读失败时才有影院",而正常路径永远是 0 家。
// ⇒ **自动插入代码后必须看插入点的上下文**,别只看"插进去了"。
try {
this.cinemas = await this.repo.cinemaList();
} catch (err) {
hilog.warn(DOMAIN, TAG, '读本地影院失败:%{public}s', JSON.stringify(err));
}
this.loading = false;
}
/**
* 重新读一次本地影院列表(影院管理里增/改/删之后调)。
*
* 🔴 v62:不重读的话,关掉影院浮层后页头上那个「影院 N 家」还是旧数字
* (用户刚新增了一家,页头却还写着 26 家 ⇒ 会以为没保存成功)。
* ⚠️ 只读本地库(`cinemaList()`)不联网 —— 写操作那边已经回写过本地库了
* (见 `FbMovieRepo.saveCinema` 的说明),这里再打一次接口是多余的往返。
* ⚠️ 失败**不弹错**:这只是个页头计数,失败了下次同步也会纠正。
*/
private async reloadCinemas(): Promise<void> {
try {
this.cinemas = await this.repo.cinemaList();
} catch (err) {
hilog.warn(DOMAIN, TAG, '重读本地影院失败:%{public}s', JSON.stringify(err));
}
}
/**
* 后台同步:拉接口 → 落本地库 → 再读一次刷新界面。
*
* ⚠️ 失败**不弹窗、不清空已有数据** —— 本地优先下"同步失败"只是
* "这次没更新",用户手上的数据仍然可用。只在页面上留一行小字说明。
*/
private async syncInBackground(): Promise<void> {
const token: string = FB_TOKEN_STORE.get();
if (token === '') {
// 还没有令牌:不做同步,但**本地数据照样能看**(这就是落盘的价值)
this.syncNote = '未登录,显示的是本地数据';
return;
}
const res: FbSyncResult = await this.repo.sync(token);
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '观影同步失败:%{public}s', res.error);
this.syncNote = '同步失败:' + res.error;
return;
}
this.syncNote = '';
await this.reloadFromLocal(); // 同步成功再刷一次,把新数据显示出来
}
build() {
// 🔴 用 Stack 把"页面内容"和"详情浮层"叠起来:
// 浮层是页面内的一个覆盖层(**不是系统弹窗**,理由见
// FbMovieDetailSheet 的文件头说明)。
Stack({ alignContent: Alignment.Bottom }) {
this.pageContent()
// ---- 详情浮层 ----
if (this.sheetOpen) {
// 半透明遮罩:点它也能关(大屏上比只能点"关闭"更顺手)
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.sheetOpen = false;
})
FbMovieDetailSheet({
detail: this.sheetDetail,
loading: this.sheetLoading,
error: this.sheetError,
listRating: this.sheetRating,
listReviewCount: this.sheetReviewCount,
onClose: (): void => {
this.sheetOpen = false;
}
})
.transition(fbSheetTransition())
}
// ---- 影院列表浮层 ----
// ⚠️ 与详情浮层**同一个 Stack**、同一个层级:
// 两者不会同时打开(详情由点电影触发、影院由点"影院 N 家"触发),
// 所以不需要额外处理"谁盖谁"。
if (this.cinemaSheetOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.cinemaSheetOpen = false;
})
FbCinemaSheet({
cinemas: this.cinemas,
// 🔴 v62:环色/主题色也传(影院管理里的页签、按钮都用观影模块色)
accent: this.accent,
onClose: (): void => {
this.cinemaSheetOpen = false;
},
/* 🔴 v62(用户 2026-10-19):影院管理里新增/修改/删除之后,
要让**本页也拿到新列表** —— 否则关掉浮层,页头上那个
「影院 N 家」和影院色点还是旧的(用户会以为没保存成功)。 */
onCinemasChanged: (): void => {
this.reloadCinemas();
}
})
.transition(fbSheetTransition())
}
// ---- 模块设置浮层(三条横线点开的那个)----
if (this.settingsOpen) {
Column()
.width('100%')
.height('100%')
.backgroundColor(FB_MASK)
.transition(fbMaskTransition())
.onClick((): void => {
this.settingsOpen = false;
})
FbModuleSettingsSheet({
// 🔴 环色 = 本模块主题色(用户 2026-10-19)
accent: this.accent,
title: '观影设置',
items: FB_SET_MOVIES,
onClose: (): void => {
this.settingsOpen = false;
},
onPick: (action: string): void => {
// 目前只有「影院管理」是已实现的;其余在菜单里显示「暂未开放」
if (action === 'cinema') {
this.settingsOpen = false;
this.cinemaSheetOpen = true;
}
}
})
.transition(fbSheetTransition())
}
}
.width('100%')
.height('100%')
}
/** 页面主体(原 build 的内容,抽出来给 Stack 用) */
@Builder
pageContent() {
Scroll() {
Column() {
this.topBar()
// ---- 页头:统计 + 记一场电影 + 设置 ----
// 🔴 2026-10-19:模块名大标题已删(顶栏左上角就是「观影」,不再重复)
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('共 ' + this.movies.length.toString() + ' 部 · 按观影时间排序')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一场',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 🔴 设置按钮 = 网页版那种**三条横线**(☰);「影院」也不单独占抽屉位置,
// 它是观影模块的一部分,从设置菜单里进(与网页版一致)。
FbIconButton({
icon: $r('app.media.fb_ic_menu'),
tint: FbTint.muted,
dia: 36,
onTap: (): void => {
this.settingsOpen = true;
}
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 搜索条 ----
Row({ space: 6 }) {
Image($r('app.media.fb_ic_search'))
.width(15)
.height(15)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
Text('搜索片名、影院、观影人…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.layoutWeight(1)
.maxLines(1)
}
.height(36)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 4 })
.alignItems(VerticalAlign.Center)
// ---- 影院数(可点:看影院列表)----
// 🔴 之前这是个**纯文字**(只能看数字);现在点它能看到名单 ——
// Web 端「影院管理」就在观影模块里,这里给它一个入口。
Row({ space: 6 }) {
Blank()
Text('影院 ' + this.cinemas.length.toString() + ' 家')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text('›')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.onClick((): void => {
this.cinemaSheetOpen = true;
})
.padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 6, bottom: 6 })
// ---- 同步状态提示(只在有话说时显示,不占位)----
if (this.syncNote !== '') {
Row() {
Text(this.syncNote)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
}
// ---- 海报网格 ----
if (this.loading) {
// ⚠️ "加载中"与"暂无记录"必须分开:本页先读本地库(通常几毫秒),
// 所以 loading 几乎一闪而过;若把它和空态混在一起,
// 用户会看到"还没有观影记录"闪一下再变成列表。
Row() {
Text('正在读取本地数据…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 40, bottom: 40 })
.justifyContent(FlexAlign.Center)
} else if (this.movies.length === 0) {
FbEmptyBox({
title: '还没有观影记录',
hint: '一家人一起看过的电影,都会以海报墙的方式排在这里',
icon: $r('app.media.fb_ic_movies')
})
} else {
Grid() {
ForEach(this.movies, (mv: FbMovieListItem) => {
GridItem() {
this.movieCard(mv)
}
}, (mv: FbMovieListItem): string => 'mv_' + mv.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
// ⚠️ 不能给 layoutWeight:本页已整体放进 Scroll,
// Column 的高度由内容决定,layoutWeight(1) 会去分"剩余空间"(此时为 0)
// ⇒ Grid 高度塌成 0、整页只有顶栏和页头。
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
// 底部悬浮快捷栏的预留:它浮在内容之上,不留白会压住最后一屏
.padding({ bottom: FB_DOCK_RESERVE })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
// 🔴 滚动方向上报(驱动底部悬浮栏随滚收起 / 唤回)—— 见 common/FbScroll.ets
.onDidScroll(fbOnScroll)
// 🔴 触摸方向兜底(内容不足一屏的页面滚不动,只能靠手指位移判断)
// 与上面的 `onDidScroll` 互补、互不冲突 —— 见 common/FbScroll.ets
.onTouch(fbOnTouch)
.align(Alignment.TopStart)
}
/** 一张电影卡:海报位(2:3)+ 片名 + 元信息 + 同看的人 */
@Builder
movieCard(mv: FbMovieListItem) {
Column({ space: 0 }) {
// 海报位:有海报就放真图,没有/加载失败就画占位(与 Web 端一致)
Stack({ alignContent: Alignment.Center }) {
if (mv.posterKey !== '' && !this.posterFailed(mv.id)) {
// 🔴 真海报:走服务端 `media.php` 代理,令牌用 URL query 带过去
// (`Image()` 无法加自定义请求头,见 fbImageUrl 的说明)。
Image(fbImageSrc(mv.posterKey, FB_TOKEN_STORE.get()))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
// ⚠️ `alt` 给一张底图:图片没加载出来时先显示它,不会闪白块
.alt($r('app.media.fb_ic_movies'))
// ⚠️ 不 `syncLoad`:海报是大图,同步加载会卡住列表滚动。
// ⚠️ 加载失败**必须记下来**(见 onError):否则这张卡会一直
// 显示"半张白图",用户以为坏了,其实只是这张图取不到。
.onError((): void => {
this.markPosterFailed(mv.id);
})
} else {
// 无海报 / 加载失败:底图 + 图标/片名占位
Column()
.width('100%')
.height('100%')
.backgroundColor(FbColors.inputBg)
}
// 占位层:只在"没有海报"或"海报加载失败"时显示(有真图时不遮挡)
if (mv.posterKey === '' || this.posterFailed(mv.id)) {
Column({ space: 6 }) {
Image($r('app.media.fb_ic_movies'))
.width(22)
.height(22)
.renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint))
Text(mv.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.maxLines(3)
.padding({ left: 10, right: 10 })
}
}
}
.width('100%')
.aspectRatio(0.75)
.clip(true)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
// 卡片正文
Column({ space: 5 }) {
Text(mv.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
// ⚠️ 显示上只取「日期 + 时:分」:接口给的是完整 DATETIME(含秒),
// 秒在卡片上纯属噪音。
Text(shortTime(mv.startsAt))
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
if (mv.cinemaName.length > 0) {
Row({ space: 4 }) {
FbDot({ dia: 6, fillC: mv.cinemaColor })
Text(mv.cinemaName)
.fontSize(10)
.fontColor(mv.cinemaColor)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
}
Row({ space: 8 }) {
if (mv.reviewCount > 0) {
FbRating({ score: mv.reviewAvg, max: 10 })
}
Blank()
// 同看人数(只出数字,空间紧;点头像看详情是后续的事)
if (mv.attendeeCount > 0) {
Text(mv.attendeeCount.toString() + ' 人同看')
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding(10)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.clip(true)
// 🔴 点卡片 → 打开详情浮层(用户 2026-10-12 反馈"点不进去")
.onClick((): void => {
this.openDetail(mv);
})
}
}