/** * 家书 FamilyBook · 「选择影院」弹框(鸿蒙端) * * 🔴 v64(用户 2026-10-19)原话: * 「然后另外**影院都应该是弹框选择**…这种选择方式太难找了。」 * 「这个弹框选择要跟**网页端的一样**,在上面有个筛选框。」 * * ⇒ 观影「记一场」原来把影院做成一排**横向胶囊**(要左右划着找,影院一多就找不到)。 * 网页端(`templates/movies/form.php` 的 `#cinemaPicker`)是: * **一个按钮 → 弹出列表 → 列表顶部有搜索框 → 点一项选中**。 * 这里按同一形态做,并保留网页端已有的两条细节: * · 「不记录影院」这一项**恒在列表最上方**(它是"清空选择"的出口, * 被搜没了用户就取消不掉了 —— 网页端注释里点明过); * · 搜索匹配「地方 + 名称」(网页端 `data-text` 就是这两段拼的), * 并且**不区分大小写、按子串**(与网页 `indexOf` 同一口径)。 * * ⚠️ 为什么与人员选择器**不合并成一个组件**: * 两者的候选源完全不同(影院有颜色/地方;人有意/头像/拼音首字母), * 筛选规则也不同(影院按名称子串,人要按拼音**子序列**)。 * 硬合并会变成一个"什么都做、什么都做不专"的组件,且参数会膨胀到十几个。 * 共用的部分已经抽在 `FbSheetRing` / `FbSheetClose` 里了 —— 那才是真正重复的。 * * ⚠️ 选中即关闭(单选语义): * 影院是单选(一条观影记录只可能在一家影院看),没有"暂存 + 确定"的必要 —— * 那一步只会让用户白点一次。多选的是「同看的人」,那边才有暂存区。 */ import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint, FbTint } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbCinemaItem } from '../data/FbMovieRepo'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; @ComponentV2 export struct FbCinemaPickSheet { /** 候选影院(本地库,通常几十家) */ @Param cinemas: FbCinemaItem[] = []; /** 当前选中的影院 id(0 = 不记录影院) */ @Param selected: number = 0; /** 环色(hex,'' = 观影模块色) */ @Param accent: string = ''; /** 选中一家(0 = 不记录影院) */ @Event onPick: (id: number) => void = (): void => { }; /** 关闭(不改变选择) */ @Event onClose: () => void = (): void => { }; @Local keyword: string = ''; private accentColor(): string { if (this.accent !== '') { return this.accent; } const m: FbModuleDef | undefined = fbModuleOf('movies'); return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; } /** 影院的显示名(「地方 · 名称」;没有地方就只是名称 —— 与网页端同口径) */ private labelOf(c: FbCinemaItem): string { return c.place !== '' ? (c.place + ' · ' + c.name) : c.name; } /** * 按关键词过滤。 * * ⚠️ 与网页端 `filter()` **同一口径**:`toLowerCase().indexOf(kw) !== -1`。 * 用子串而不是前缀 —— 用户常常只记得"万象城"却记不全影院名。 * ⚠️ 匹配的是「地方 + 名称」拼起来的串(网页端 `data-text` 就是这个): * 只匹配名称的话,按"青岛"就搜不出青岛那几家。 */ private filtered(): FbCinemaItem[] { const kw: string = this.keyword.trim().toLowerCase(); if (kw === '') { return this.cinemas; } return this.cinemas.filter((c: FbCinemaItem): boolean => this.labelOf(c).toLowerCase().indexOf(kw) >= 0); } build() { /* 🔴🔴 必须是 `Stack`,**不能**是 `Column`(2026-10-20 真机实测的真 bug)。 `Column` 是**线性**布局:全屏遮罩(100% 高)会占满整高, 卡片被排到它**下面** ⇒ 落到屏幕之外 ⇒ 弹层"点了没反应"。 症状:编译无告警、dumpLayout 里看不到弹层任何节点、点击会穿透到下层。 ⇒ **全屏遮罩与卡片要"叠"(Stack),不能"排"(Column)**。 */ Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .backgroundColor(FB_MASK) .onClick((): void => { this.onClose(); }) Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 10 }) { // ---- 标题栏(统一版式:左标题 + 右 ✕)---- Row({ space: 8 }) { Column({ space: 2 }) { Text('选择影院') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) Text('共 ' + this.cinemas.length.toString() + ' 家') .fontSize(FbMetric.fontTiny) .fontColor(this.accentColor()) .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: this.accentColor() }) } .width('100%') .alignItems(VerticalAlign.Center) // ---- 搜索框(用户点名要"跟网页端一样,上面有个筛选框")---- Row({ space: 6 }) { Image($r('app.media.fb_ic_search')) .width(15) .height(15) .renderMode(ImageRenderMode.Template) .colorFilter(fbTint(FbTint.faint)) TextInput({ text: this.keyword, placeholder: '搜影院(地方或名称)' }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(Color.Transparent) .height(38) .layoutWeight(1) .padding(0) .onChange((v: string): void => { this.keyword = v; }) } .width('100%') .padding({ left: 10, right: 10 }) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .alignItems(VerticalAlign.Center) // ---- 列表 ---- Scroll() { Column({ space: 6 }) { // 「不记录影院」**恒在**(哪怕正在搜索)—— 它是取消选择的唯一出口 this.pickRow(0, '不记录影院', '', true) ForEach(this.filtered(), (c: FbCinemaItem) => { this.pickRow(c.id, this.labelOf(c), c.color, false) }, (c: FbCinemaItem): string => 'cp_' + c.id.toString()) if (this.filtered().length === 0) { Text('没有匹配的影院') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width('100%') .textAlign(TextAlign.Center) .padding({ top: 18, bottom: 18 }) } } .width('100%') } .constraintSize({ maxHeight: 320 }) .width('100%') .scrollBar(BarState.Auto) .align(Alignment.TopStart) } .width('100%') .padding({ left: 18, right: 18, top: 18, bottom: 18 }) .alignItems(HorizontalAlign.Start) } } .width('92%') } .width('100%') .height('100%') .hitTestBehavior(HitTestMode.Transparent) } /** * 一行影院。 * * @param id 影院 id(0 = 不记录影院) * @param text 显示名 * @param color 影院主题色(空 = 画一个虚线空心点,与网页端 `is-none` 一致) * @param isNone 是不是"不记录影院"那一项 */ @Builder pickRow(id: number, text: string, color: string, isNone: boolean) { Row({ space: 10 }) { // 色点:影院色;「不记录影院」画空心圈 if (isNone || color === '') { Text('') .width(12) .height(12) .borderRadius(6) .border({ width: 1.5, color: FbColors.faint }) } else { Text('') .width(12) .height(12) .borderRadius(6) .backgroundColor(color) } Text(text) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) // 选中态:右侧一个对勾(比"整行变色"更明确 —— 列表底色本来就浅) if (id === this.selected) { Text('✓') .fontSize(FbMetric.fontSmall) .fontColor(this.accentColor()) .fontWeight(FontWeight.Medium) } } .width('100%') .height(46) .padding({ left: 12, right: 12 }) .backgroundColor(id === this.selected ? fbAlpha(this.accentColor(), 0.10) : FbColors.inputBg) .borderRadius(FbMetric.radiusS) .alignItems(VerticalAlign.Center) .onClick((): void => { this.onPick(id); }) } }