/** * 家书 FamilyBook · 6×4 服务卡片(观影 · 上概述,下**两列海报墙**)—— v92 / v95 * * 用户 2026-10-25:「观影应该显示图片……实际上应该用大图的形式显示, * 就做成和观影模式一样,不行吗?」 * ⇒ 与 4×4 同一套海报墙。 * * 🔴 v95(用户 2026-10-26):「4x6,宽度都是一样的,为啥要弄成三列?」 * ⇒ 列数从 3 改成 **2**(与 4×4 一致),并补上列间距 `gutter`。 * ⚠️ 文件头原来写的是"这块卡更宽、一排放三张"——那句已经**过时**, * 别再照着它改回 `.lanes(3)`。 * * 🔴 卡片里的图不能写 http 地址,只能 `Image('memory://' + img)` * (图由 `FbCardMediaData` 下载+缩图、以 `formImages` 的 fd 交过来)。 * ⚠️ 卡片不支持 `Scroll`,只支持 `List` ⇒ 多列用 `List().lanes(n, gutter)`,别换成 Grid。 * ⚠️ ForEach key 带 version 前缀(v89 教训)。 */ let storageMvP6 = new LocalStorage(); /** 海报墙一格 */ class FbMovieCell9b { key: string = ''; title: string = ''; dateText: string = ''; cinema: string = ''; rating: string = ''; /** `memory://` 的内存键;空串 = 没图,画占位 */ img: string = ''; } @Entry(storageMvP6) @Component struct FbMovieCard6x4 { @LocalStorageProp('state') state: string = 'loading'; @LocalStorageProp('hint') hint: string = ''; @LocalStorageProp('updatedAt') updatedAt: string = ''; @LocalStorageProp('yearCount') yearCount: number = 0; @LocalStorageProp('totalCount') totalCount: number = 0; @LocalStorageProp('listJson') listJson: string = '[]'; @LocalStorageProp('version') version: string = ''; private rows(): FbMovieCell9b[] { try { const all: FbMovieCell9b[] = JSON.parse(this.listJson) as FbMovieCell9b[]; return all; } catch (err) { return []; } } private openApp(): void { postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { fbModule: 'movies' } }); } /** 「+ 新建」→ 进 app 直开「记一场」浮层(EntryAbility → modCreate 通道) */ private addOne(): void { postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { fbModule: 'movies', widgetAction: 'add' } }); } /** 图下那行小字:`10月24日 · 万象城`(都空就不画) */ private subText(item: FbMovieCell9b): string { let s: string = item.dateText; if (item.cinema !== '') { s = s !== '' ? s + ' · ' + item.cinema : item.cinema; } return s; } /** 一格格:大图 + 底部黑条上的片名/日期 */ @Builder posterCell(item: FbMovieCell9b) { Stack({ alignContent: Alignment.Bottom }) { if (item.img !== '') { // ⚠️ 只能 memory://(见文件头):写 http 地址在卡片里是一张空白 Image('memory://' + item.img) .width('100%') .height('100%') .objectFit(ImageFit.Cover) .borderRadius(10) } else { Column() { Text(item.title) .fontSize(10) .fontColor('#7A4E8C') .maxLines(3) .textAlign(TextAlign.Center) .padding({ left: 4, right: 4 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor('#EDE6F1') .borderRadius(10) } Column({ space: 1 }) { Text(item.title) .fontSize(10) .fontWeight(FontWeight.Medium) .fontColor('#FFFFFF') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') if (this.subText(item) !== '') { Text(this.subText(item)) .fontSize(8) .fontColor('#FFFFFF') .opacity(0.8) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } } .width('100%') .padding({ left: 6, right: 6, top: 3, bottom: 3 }) .backgroundColor('#66000000') } .width('100%') .aspectRatio(0.68) .borderRadius(10) .clip(true) } /** 空态 */ @Builder emptyHint() { Column() { Text(this.hint !== '' ? this.hint : '还没有观影记录') .fontSize(12) .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') .maxLines(3) .textAlign(TextAlign.Center) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } build() { Column({ space: 6 }) { // ===== 顶栏:标题 + 更新时间 + + 新建 ===== Row({ space: 6 }) { Text('观影') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#2C231D') .maxLines(1) .layoutWeight(1) Text(this.updatedAt) .fontSize(9) .fontColor('#A8988A') .maxLines(1) Button('+') .fontSize(13) .fontColor('#FFFFFF') .width(26) .height(26) .borderRadius(13) .padding(0) .backgroundColor('#7A4E8C') .onClick((): void => this.addOne()) } .width('100%') // ===== 概述行 ===== Row({ space: 8 }) { Text(`今年 ${this.yearCount} 场`) .fontSize(11) .fontColor('#7A4E8C') .fontWeight(FontWeight.Medium) Text(`共 ${this.totalCount} 条`) .fontSize(10) .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) } .width('100%') // ===== 海报墙(三列,可滚动)===== if (this.rows().length === 0) { this.emptyHint() } else { List({ space: 8 }) { ForEach(this.rows(), (item: FbMovieCell9b) => { ListItem() { this.posterCell(item) } }, (item: FbMovieCell9b): string => `mp_${this.version}_${item.key}`) } /* 🔴🔴 v95(用户 2026-10-26):「4x6,宽度都是一样的,为啥要弄成三列?」 —— 原来是 `.lanes(3)`(文件头那句"这块卡更宽、一排放三张")。 ⇒ 改成**两列**,与 4×4 一致: · 用户明确要求,且三列时每张海报明显更小、更挤,观感差; · 两列 + 更高的卡高 ⇒ 一屏能看到的行数更多,反而比三列看得多。 ⚠️ 同时补 `gutter`:`List({ space: 8 })` 的 `space` 在 lanes 多列 模式下只管**行间距**,列缝必须靠 `lanes(count, gutter)` 给, 否则两列又会贴死(与 4×4 同一个坑,见 FbMovieCard4x4 里的详细说明)。 `gutter` 是**位置参数**(`Dimension`),直接传数字,**不是** `{ gutter: 8 }`。 ⚠️ 别再改回 `.lanes(3)`:列数一多,同一块卡里的海报就被压到 看不清片名,而用户要的恰恰是"大图"。 */ .lanes(2, 8) .alignListItem(ListItemAlign.Start) .width('100%') .layoutWeight(1) .scrollBar(BarState.Off) } } .width('100%') .height('100%') .padding(12) .backgroundColor('#FFFFFF') .borderRadius(16) .onClick((): void => this.openApp()) } }