/** * 家书 FamilyBook · 影院管理浮层(鸿蒙端) * * 🔴 v63(用户 2026-10-19)**推翻了 v62 的页签方案**,原话: * 「**影院管理就是影院管理。高级搜索统计和年度报告不在"影院管理"这个功能里**, * 而是点击观影设置后,弹框里面有"影院管理""高级搜索统计"和"年度报告"; * 点击之后,会进入到搜索统计和年度报告里面。 * **影院管理就是管理影院,不要把统计搜索和年报放在这里面。**」 * * ⇒ v62 我把四项做成同一个浮层里的四个页签;用户要的是**平级的三个入口**: * 观影页 ☰ → 设置菜单(影院管理 / 高级搜索、统计 / 年度报告)→ 各自独立浮层。 * ⇒ 本组件**只剩影院本身**:列表 + 新增 / 修改 / 删除。 * 统计 / 搜索 / 年报在 `FbMovieInsightSheet`(由 `mode` 决定显示哪一项)。 * * ⚠️ 数据来自**本地库**(观影同步时一起落库的 `cinemas` 表),不是现拉接口 —— * 见 `FbMovieRepo.cinemaList()` 的说明。 * 但**写操作**(增改删)必须联网,写完立刻回写本地库(见 `saveCinema`)。 */ import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbCinemaItem, FbMovieRepo } from '../data/FbMovieRepo'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; /** * 贴底卡片的手势区缓冲(vp)。 * * 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)—— * 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段, * 否则最后一排按钮会被"从下往上滑"误触。 */ const FB_SHEET_BOTTOM_GAP: number = 16; /** 新建影院时的默认色(与网页 `Cinema::PALETTE` 里的紫同一档) */ const DEFAULT_CINEMA_COLOR: string = '#7A4E8C'; /** 新建影院时可选的颜色(与网页 `Cinema::PALETTE` 同一族,取"色相差得开"的那几个) */ const PALETTE: string[] = [ '#7A4E8C', '#e84393', '#e91e63', '#f06292', '#2980b9', '#6c5ce7', '#16a085', '#2ecc71', '#f8c471', '#a67c52', '#95a5a6', '#7f8c8d' ]; @ComponentV2 export struct FbCinemaSheet { /** 影院列表(本地库;外部传入,改动后由本组件刷新并回调) */ @Param cinemas: FbCinemaItem[] = []; /** 关闭回调 */ @Event onClose: () => void = (): void => { }; /** 影院列表有变化时通知页面刷新(新建/修改/删除之后) */ @Event onCinemasChanged: () => void = (): void => { }; /** * 卡片外圈描边环的颜色(hex,'' = 用观影模块色)。 * * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 */ @Param accent: string = ''; // ---- 编辑态 ---- /** 正在编辑的影院 id(0 = 新增;-1 = 没在编辑) */ @Local editId: number = -1; @Local fName: string = ''; @Local fPlace: string = ''; @Local fAddress: string = ''; @Local fColor: string = DEFAULT_CINEMA_COLOR; /** 保存中(防连点) */ @Local saving: boolean = false; /** 一句话结果(成功/失败都写这里,页面上显示一行灰字) */ @Local note: string = ''; /** 待确认删除的影院 id(0 = 没有) */ @Local delId: number = 0; private repo: FbMovieRepo = FbMovieRepo.inst(); /** 环色:显式给了就用给的,否则取观影模块色(影院属于观影) */ private accentColor(): string { if (this.accent !== '') { return this.accent; } const m: FbModuleDef | undefined = fbModuleOf('movies'); return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; } /** 开始编辑(id=0 表示新增) */ private startEdit(id: number): void { if (id === 0) { this.editId = 0; this.fName = ''; this.fPlace = ''; this.fAddress = ''; this.fColor = DEFAULT_CINEMA_COLOR; } else { const c: FbCinemaItem | undefined = this.cinemas.find((x: FbCinemaItem): boolean => x.id === id); if (c === undefined) { return; } this.editId = id; this.fName = c.name; this.fPlace = c.place; this.fAddress = c.address; this.fColor = c.color; } this.note = ''; } private async save(): Promise { if (this.saving) { return; } if (this.fName.trim() === '') { this.note = '影院名称不能为空'; return; } this.saving = true; const err: string = await this.repo.saveCinema( this.editId, this.fPlace, this.fName, this.fAddress, this.fColor); this.saving = false; if (err !== '') { this.note = err; return; } this.note = this.editId === 0 ? '已新增' : '已保存'; this.editId = -1; this.onCinemasChanged(); } private async remove(id: number): Promise { this.delId = 0; const err: string = await this.repo.deleteCinema(id); this.note = err !== '' ? err : '已删除(历史观影记录保留)'; if (err === '') { this.onCinemasChanged(); } } build() { // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— // 宿主 Stack 里还有别的子元素,改对齐会连带挪走它们。 // ⚠️ 外层铺满 + Center;内层是那张 92% 宽的圆角卡。 Column() { Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { // ---- 标题栏(统一版式:左标题 + 右 ✕,见 FbSheetClose.ets)---- Row({ space: 8 }) { Column({ space: 2 }) { Text(this.editId >= 0 ? (this.editId === 0 ? '新增影院' : '修改影院') : '影院管理') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) /* 🔴 v63:副标题**不再写「N 家」**(用户:「影院电影列表里面有个 『影院26家』,不知道是什么意思……没有用,要把它删掉」)。 ⇒ 换成一句说明"这里能做什么",比一个看不懂的数字有用。 */ Text('管理看过的影院:新增 / 修改 / 删除') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(1) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .flexShrink(1) FbSheetClose({ onClose: (): void => { this.onClose(); }, color: this.accentColor() }) } .width('100%') .alignItems(VerticalAlign.Center) Divider() .color(FbColors.line) .margin({ top: 10, bottom: 4 }) // ---- 内容 ---- // ⚠️ `flexShrink(1)` 让内容**按自身高度收缩**(短内容不撑满 maxHeight), // 本项目在日程详情/模块设置踩过"卡片空半屏"。 Scroll() { Column({ space: 0 }) { this.cinemaPane() } .width('100%') } .flexShrink(1) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) // ---- 底部:一行结果提示 ---- if (this.note !== '') { Text(this.note) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .width('100%') .padding({ top: 8 }) } } .width('100%') .constraintSize({ maxHeight: '76%' }) .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) .alignItems(HorizontalAlign.Start) } } .width('92%') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .hitTestBehavior(HitTestMode.Transparent) } // ==================== 影院:增 / 改 / 删 ==================== @Builder cinemaPane() { // ---- 编辑表单(编辑态才显示)---- if (this.editId >= 0) { Column({ space: 8 }) { this.field('影院名', '如「横店电影城」', this.fName, (v: string): void => { this.fName = v; }) this.field('场所', '如「东明县京都大润发」(可空)', this.fPlace, (v: string): void => { this.fPlace = v; }) this.field('地址', '详细地址(可空)', this.fAddress, (v: string): void => { this.fAddress = v; }) // 颜色:给一排候选(网页 `Cinema::PALETTE` 是同样的做法) Row({ space: 8 }) { Text('颜色') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(52) Row({ space: 6 }) { ForEach(PALETTE, (c: string) => { Column() .width(20) .height(20) .backgroundColor(c) .borderRadius(10) .border({ width: this.fColor === c ? 2 : 0, color: FbColors.ink }) .onClick((): void => { this.fColor = c; }) }, (c: string): string => 'pal_' + c) } .layoutWeight(1) } .width('100%') .alignItems(VerticalAlign.Center) Row({ space: 8 }) { Text(this.saving ? '保存中…' : '保存') .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .textAlign(TextAlign.Center) .layoutWeight(1) .height(40) .backgroundColor(this.saving ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.save(); }) Text('取消') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.muted) .textAlign(TextAlign.Center) .layoutWeight(1) .height(40) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.editId = -1; this.note = ''; }) } .width('100%') .margin({ top: 2 }) } .width('100%') .padding({ top: 13, bottom: 13 }) } else { // ---- 顶部动作:新增 ---- Row() { Text('+ 新增影院') .fontSize(FbMetric.fontSmall) .fontColor(this.accentColor()) .fontWeight(FontWeight.Medium) .height(34) .padding({ left: 12, right: 12 }) .borderRadius(FbMetric.radiusS) .backgroundColor(fbAlpha(this.accentColor(), 0.10)) .textAlign(TextAlign.Center) .onClick((): void => { this.startEdit(0); }) } .width('100%') .padding({ top: 10, bottom: 4 }) } // ---- 列表 ---- if (this.cinemas.length === 0) { Text('还没有影院。可以点上面的「新增影院」自己加,' + '同步观影记录后去过的地方也会自动出现在这里。') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.faint) .width('100%') .padding({ top: 28, bottom: 28, left: 12, right: 12 }) .textAlign(TextAlign.Center) } else { Column({ space: 0 }) { ForEach(this.cinemas, (c: FbCinemaItem) => { Column({ space: 0 }) { Row({ space: 12 }) { // 色点:每家影院一个色(与 Web 端一致,便于在别处一眼认出来) Column() .width(10) .height(10) .backgroundColor(c.color) .borderRadius(5) Column({ space: 3 }) { Row({ space: 8 }) { Text(c.name) .fontSize(FbMetric.fontBody) .fontColor(FbColors.ink) .maxLines(1) .flexShrink(1) if (c.movieCount > 0) { Text(c.movieCount.toString() + ' 部') .fontSize(10) .fontColor(FbColors.faint) } } .alignItems(VerticalAlign.Center) .width('100%') // 场所 / 地址(有才显示) if (c.place !== '' || c.address !== '') { Text(c.place !== '' ? c.place : c.address) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } } .alignItems(HorizontalAlign.Start) .layoutWeight(1) // ---- 改 / 删 ---- Text('改') .fontSize(FbMetric.fontTiny) .fontColor(this.accentColor()) .padding({ left: 8, right: 8, top: 5, bottom: 5 }) .onClick((): void => { this.startEdit(c.id); }) Text('删') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.red) .padding({ left: 8, right: 8, top: 5, bottom: 5 }) .onClick((): void => { this.delId = c.id; }) } .width('100%') .padding({ left: 4, right: 0, top: 11, bottom: 11 }) .alignItems(VerticalAlign.Center) Divider() .strokeWidth(0.5) .color(FbColors.line) } .width('100%') }, (c: FbCinemaItem): string => 'cin_' + c.id.toString()) } .width('100%') } // ---- 删除确认(红底 —— 用户要求"删除按钮全部红色背景")---- if (this.delId > 0) { this.confirmDelete() } } /** 一行输入(标签 + 输入框) */ @Builder field(label: string, placeholder: string, value: string, onChange: (v: string) => void) { Row({ space: 8 }) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(52) TextInput({ text: value, placeholder: placeholder }) .placeholderColor(FbColors.faint) .placeholderFont({ size: FbMetric.fontTiny }) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(38) .layoutWeight(1) .padding({ left: 10, right: 10 }) .onChange((v: string): void => { onChange(v); }) } .width('100%') .alignItems(VerticalAlign.Center) } /** 删除一家影院的二次确认 */ @Builder confirmDelete() { Column({ space: 10 }) { Text('删掉这家影院?') .fontSize(FbMetric.fontBody) .fontColor(FbColors.ink) .fontWeight(FontWeight.Medium) Text('在这家看过的记录**会保留**(只是不再显示影院名)。') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.muted) .textAlign(TextAlign.Center) Row({ space: 10 }) { Text('删除') .fontSize(FbMetric.fontSmall) .fontColor(Color.White) .textAlign(TextAlign.Center) .layoutWeight(1) .height(40) // 🔴 用户 2026-10-19:「删除按钮全部用红色背景。」 .backgroundColor(FbColors.red) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.remove(this.delId); }) Text('取消') .fontSize(FbMetric.fontSmall) .fontColor(FbColors.muted) .textAlign(TextAlign.Center) .layoutWeight(1) .height(40) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.delId = 0; }) } .width('100%') } .width('100%') .padding({ top: 16, bottom: 6 }) .alignItems(HorizontalAlign.Center) } }