/** * 家书 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 { 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 { 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 { 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 { try { this.cinemas = await this.repo.cinemaList(); } catch (err) { hilog.warn(DOMAIN, TAG, '重读本地影院失败:%{public}s', JSON.stringify(err)); } } /** * 后台同步:拉接口 → 落本地库 → 再读一次刷新界面。 * * ⚠️ 失败**不弹窗、不清空已有数据** —— 本地优先下"同步失败"只是 * "这次没更新",用户手上的数据仍然可用。只在页面上留一行小字说明。 */ private async syncInBackground(): Promise { 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); }) } }