Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbCinemaSheet.ets
T
laoyang 8146aab1a7 v63+v64:影院管理补齐(v63)与观影「记一场」豆瓣/弹框选择(v64)
⚠️ 本提交含**两批**改动(同一批文件里交织,无法按文件拆开):
  · v63(影院管理与搜索/统计/年报拆成平级入口)—— 本会话之外的工作,
    随本轮一并提交以免工作区长期留半成品;
  · v64(本会话,用户 2026-10-19 六条)。

v64 六条:
① 片名可经豆瓣自动获取简介与海报(与网页端同一份服务端输出:
   summary 全文 + 已落盘的 poster_key);简介**只填空白的**,不覆盖用户写过的。
   联想 320ms 防抖(与网页端同值)。
② 开始/结束改成**与日程同一个**日期时间选择控件 —— 新抽 components/FbDateTimeField.ets
   (行 + 弹层 + 一组换算函数),日程那边旧的自绘实现整段删掉改用它
   (用户要的就是"两处一样")。
③ 影院、同看人改成**弹框选择**:
   · FbCinemaPickSheet:顶部搜索框,匹配「地方 + 名称」,「不记录影院」恒在列表;
   · FbPersonPickSheet:顶部搜索框,拼音首字母**子序列**匹配(yyq 打成 yq 也能搜到),
     多选走"暂存 + 确定"两段式(点一个不关窗)。
   两排横向胶囊/头像全删(用户:"这种方式太难找了")。
④ 允许上传**多张**图片:海报 1 张 + 观影照片可一次选 9 张、可多次追加、可逐张删。
   ⚠️ 顺序是**先建记录再传图**(建记录失败就不会留孤儿图);图失败**不挡记录**。
⑤ 删掉「地点」输入口(location 字段仍发,网页端与历史数据不受影响)。
⑥ 观影页搜索框改明显:白底 + 实色描边 + 图标放大到 18 并染主题色 +
   占位文字提到 muted/fontBody + 右侧「筛选 ›」暗示(用户:"根本看不清")。

FbMovieRepo 新增 doubanSuggest / doubanFetch / uploadPoster / uploadPhoto
(multipart 手写,与头像上传同一形态,抽成共用的 uploadOne)。

验证:静态守卫 636/0 · 端上反向验证 87/0 · 接口 e2e 57/0 · 服务端反向验证 18/0 ·
本地全量 e2e 1521/0 · hvigor 编译成功并已装机。
⚠️ 真机验证脚本 _fb_v64_verify.py 已写好(六条全覆盖),因设备锁屏未能执行。
2026-10-20 07:35:37 +08:00

467 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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<void> {
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<void> {
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)
}
}