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