⚠️ 本提交含**两批**改动(同一批文件里交织,无法按文件拆开): · 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 已写好(六条全覆盖),因设备锁屏未能执行。
467 lines
16 KiB
Plaintext
467 lines
16 KiB
Plaintext
/**
|
||
* 家书 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)
|
||
}
|
||
} |