From 8146aab1a75f9a9b0ea40fcba5e075e667d9ea53 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 20 Oct 2026 07:35:37 +0800 Subject: [PATCH] =?UTF-8?q?v63+v64=EF=BC=9A=E5=BD=B1=E9=99=A2=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=A1=A5=E9=BD=90=EF=BC=88v63=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=E8=A7=82=E5=BD=B1=E3=80=8C=E8=AE=B0=E4=B8=80=E5=9C=BA=E3=80=8D?= =?UTF-8?q?=E8=B1=86=E7=93=A3/=E5=BC=B9=E6=A1=86=E9=80=89=E6=8B=A9?= =?UTF-8?q?=EF=BC=88v64=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 本提交含**两批**改动(同一批文件里交织,无法按文件拆开): · 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 已写好(六条全覆盖),因设备锁屏未能执行。 --- .../main/ets/components/FbCinemaPickSheet.ets | 240 ++++ .../src/main/ets/components/FbCinemaSheet.ets | 875 +------------- .../main/ets/components/FbDateTimeField.ets | 317 +++++ .../main/ets/components/FbEventEditSheet.ets | 230 +--- .../ets/components/FbMovieInsightSheet.ets | 921 +++++++++++++++ .../main/ets/components/FbMovieNewSheet.ets | 1041 +++++++++++++++++ .../main/ets/components/FbPersonPickSheet.ets | 377 ++++++ entry/src/main/ets/data/FbMovieRepo.ets | 360 +++++- entry/src/main/ets/model/FbSettingsMenus.ets | 18 +- .../main/ets/pages/movies/FbMoviesPage.ets | 180 ++- 10 files changed, 3519 insertions(+), 1040 deletions(-) create mode 100644 entry/src/main/ets/components/FbCinemaPickSheet.ets create mode 100644 entry/src/main/ets/components/FbDateTimeField.ets create mode 100644 entry/src/main/ets/components/FbMovieInsightSheet.ets create mode 100644 entry/src/main/ets/components/FbMovieNewSheet.ets create mode 100644 entry/src/main/ets/components/FbPersonPickSheet.ets diff --git a/entry/src/main/ets/components/FbCinemaPickSheet.ets b/entry/src/main/ets/components/FbCinemaPickSheet.ets new file mode 100644 index 0000000..e30d325 --- /dev/null +++ b/entry/src/main/ets/components/FbCinemaPickSheet.ets @@ -0,0 +1,240 @@ +/** + * 家书 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() { + // 整屏遮罩 + 居中卡片(与全应用浮层同一形态) + Column() { + 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%') + .justifyContent(FlexAlign.Center) + .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); + }) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbCinemaSheet.ets b/entry/src/main/ets/components/FbCinemaSheet.ets index d16a086..44e15e0 100644 --- a/entry/src/main/ets/components/FbCinemaSheet.ets +++ b/entry/src/main/ets/components/FbCinemaSheet.ets @@ -1,38 +1,25 @@ /** * 家书 FamilyBook · 影院管理浮层(鸿蒙端) * - * 🔴 v62(用户 2026-10-19)原话: - * 「设置里面的影院管理,这些功能都还没有,应该补充,就是增加影院呢、修改影院呢、 - * 高级搜索、统计、年度报告,这些全部都应该补充进去。」 + * 🔴 v63(用户 2026-10-19)**推翻了 v62 的页签方案**,原话: + * 「**影院管理就是影院管理。高级搜索统计和年度报告不在"影院管理"这个功能里**, + * 而是点击观影设置后,弹框里面有"影院管理""高级搜索统计"和"年度报告"; + * 点击之后,会进入到搜索统计和年度报告里面。 + * **影院管理就是管理影院,不要把统计搜索和年报放在这里面。**」 * - * ⇒ 之前这个浮层**只是一个只读列表**(连"新增"都没有),而网页端 - * `templates/movies/` 下早就有 `cinemas.php` / `search.php` / `stats.php` / - * `report.php` 四页。这一版把端上补齐到与网页同一套能力: - * · 顶部一排「页签」:影院 / 统计 / 搜索 / 年报; - * · 影院页:**新增 / 修改 / 删除**(服务端 CRUD 早就有,端上没接); - * · 统计页:总览 + 按影院计数(`/api/v1/movies/stats`); - * · 搜索页:片名 / 影院 / 人 / 起止日期(`/api/v1/movies/search`); - * · 年报页:按年切换(`/api/v1/movies/report`)。 + * ⇒ v62 我把四项做成同一个浮层里的四个页签;用户要的是**平级的三个入口**: + * 观影页 ☰ → 设置菜单(影院管理 / 高级搜索、统计 / 年度报告)→ 各自独立浮层。 + * ⇒ 本组件**只剩影院本身**:列表 + 新增 / 修改 / 删除。 + * 统计 / 搜索 / 年报在 `FbMovieInsightSheet`(由 `mode` 决定显示哪一项)。 * - * ⚠️ 四个页签**共用一个浮层**(而不是四个独立浮层): - * 它们是"同一个东西的不同视角",来回切换不该经过"关掉再打开"(那会闪一下)。 - * - * 🔴 数据来源分两路(有意的): - * · **影院列表**读**本地库**(`cinemas` 表)—— 与观影页其它内容一致,断网也看得见; - * · **统计 / 年报 / 搜索**必须**联网**——它们的口径要与网页**完全一致** - * (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分", - * 少同步一次就少一部,用户会发现"网页 124 部、App 121 部"。 - * ⇒ 这三页断网时显示"需要联网",而不是给一个偏低的数字。 + * ⚠️ 数据来自**本地库**(观影同步时一起落库的 `cinemas` 表),不是现拉接口 —— + * 见 `FbMovieRepo.cinemaList()` 的说明。 + * 但**写操作**(增改删)必须联网,写完立刻回写本地库(见 `saveCinema`)。 */ import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; -import { - FbApiMovieSearchRow, FbCinemaItem, FbMovieCinemaStat, FbMovieMonthStat, - FbMovieOverview, FbMovieReport, FbMovieRepo, FbMovieSearchParams, FbMovieStats, - FbMovieYearStat -} from '../data/FbMovieRepo'; -import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { FbCinemaItem, FbMovieRepo } from '../data/FbMovieRepo'; import { FbSheetRing } from './FbSheetRing'; import { FbSheetClose } from './FbSheetClose'; @@ -45,25 +32,15 @@ import { FbSheetClose } from './FbSheetClose'; */ const FB_SHEET_BOTTOM_GAP: number = 16; -/** 四个页签 */ -const TABS: string[] = ['影院', '统计', '搜索', '年报']; - -/** 新增影院时的默认色(与网页 `Cinema::PALETTE` 里的紫同一档,便于区分新建的) */ +/** 新建影院时的默认色(与网页 `Cinema::PALETTE` 里的紫同一档) */ const DEFAULT_CINEMA_COLOR: string = '#7A4E8C'; -/** - * 空的报告统计对象。 - * - * ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals` **不允许空对象字面量** - * (`return {}` 直接编译不过)⇒ 用一个显式声明的空 interface 顶掉。 - */ -interface FbEmptyStats { -} - -const EMPTY_STATS: FbEmptyStats = {}; - -/** 同理:总览的空值(`return {}` 编译不过) */ -const EMPTY_OVERVIEW: FbMovieOverview = {}; +/** 新建影院时可选的颜色(与网页 `Cinema::PALETTE` 同一族,取"色相差得开"的那几个) */ +const PALETTE: string[] = [ + '#7A4E8C', '#e84393', '#e91e63', '#f06292', + '#2980b9', '#6c5ce7', '#16a085', '#2ecc71', + '#f8c471', '#a67c52', '#95a5a6', '#7f8c8d' +]; @ComponentV2 export struct FbCinemaSheet { @@ -83,10 +60,7 @@ export struct FbCinemaSheet { */ @Param accent: string = ''; - /** 当前页签(0=影院 1=统计 2=搜索 3=年报) */ - @Local tab: number = 0; - - // ---- 编辑态(影院页)---- + // ---- 编辑态 ---- /** 正在编辑的影院 id(0 = 新增;-1 = 没在编辑) */ @Local editId: number = -1; @Local fName: string = ''; @@ -100,26 +74,6 @@ export struct FbCinemaSheet { /** 待确认删除的影院 id(0 = 没有) */ @Local delId: number = 0; - // ---- 统计 / 年报 / 搜索的加载态 ---- - @Local statsLoading: boolean = false; - @Local stats: FbMovieStats | null = null; - @Local statsError: string = ''; - - @Local reportLoading: boolean = false; - @Local report: FbMovieReport | null = null; - @Local reportError: string = ''; - /** 年报年份(0 = 用服务端返回的默认年) */ - @Local reportYear: number = 0; - - @Local searchLoading: boolean = false; - @Local searchRows: FbApiMovieSearchRow[] = []; - @Local searchError: string = ''; - /** 搜索条件 */ - @Local sTitle: string = ''; - @Local sCinemaId: number = 0; - @Local sFrom: string = ''; - @Local sTo: string = ''; - private repo: FbMovieRepo = FbMovieRepo.inst(); /** 环色:显式给了就用给的,否则取观影模块色(影院属于观影) */ @@ -131,82 +85,6 @@ export struct FbCinemaSheet { return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; } - /** 切页签时按需拉数据(进统计/年报/搜索才打接口,不在打开浮层就打三个) */ - private switchTab(i: number): void { - this.tab = i; - this.note = ''; - if (i === 1 && this.stats === null) { - this.loadStats(); - } - if (i === 3 && this.report === null) { - this.loadReport(0); - } - } - - private async loadStats(): Promise { - const token: string = FB_TOKEN_STORE.get(); - if (token === '') { - this.statsError = '还没登录'; - return; - } - this.statsLoading = true; - this.statsError = ''; - const r: FbMovieStats | null = await this.repo.stats(token); - this.statsLoading = false; - if (r === null) { - this.statsError = '统计需要联网(本地库只有已同步的一部分,算不准)'; - return; - } - this.stats = r; - } - - private async loadReport(year: number): Promise { - const token: string = FB_TOKEN_STORE.get(); - if (token === '') { - this.reportError = '还没登录'; - return; - } - this.reportLoading = true; - this.reportError = ''; - const r: FbMovieReport | null = await this.repo.report(token, year); - this.reportLoading = false; - if (r === null) { - this.reportError = '年度报告需要联网'; - return; - } - this.report = r; - const y: number | undefined = r.year; - this.reportYear = y !== undefined ? y : year; - } - - private async doSearch(): Promise { - const token: string = FB_TOKEN_STORE.get(); - if (token === '') { - this.searchError = '还没登录'; - return; - } - const p: FbMovieSearchParams = { - title: this.sTitle.trim(), - cinemaId: this.sCinemaId, - personId: 0, - dateFrom: this.sFrom.trim(), - dateTo: this.sTo.trim() - }; - // ⚠️ 与网页同规则:条件全空就不查(否则等于"列出所有观影",不是搜索) - if (p.title === '' && p.cinemaId <= 0 && p.dateFrom === '' && p.dateTo === '') { - this.searchError = '先设一个条件再搜(片名 / 影院 / 日期)'; - this.searchRows = []; - return; - } - this.searchLoading = true; - this.searchError = ''; - this.searchRows = await this.repo.search(token, p); - this.searchLoading = false; - if (this.searchRows.length === 0) { - this.searchError = '没有符合条件的观影记录'; - } - } - /** 开始编辑(id=0 表示新增) */ private startEdit(id: number): void { if (id === 0) { @@ -216,7 +94,8 @@ export struct FbCinemaSheet { this.fAddress = ''; this.fColor = DEFAULT_CINEMA_COLOR; } else { - const c: FbCinemaItem | undefined = this.cinemas.find((x: FbCinemaItem): boolean => x.id === id); + const c: FbCinemaItem | undefined = + this.cinemas.find((x: FbCinemaItem): boolean => x.id === id); if (c === undefined) { return; } @@ -262,23 +141,25 @@ export struct FbCinemaSheet { build() { // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— - // 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 - // ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 + // 宿主 Stack 里还有别的子元素,改对齐会连带挪走它们。 + // ⚠️ 外层铺满 + Center;内层是那张 92% 宽的圆角卡。 Column() { - // 🔴 2026-10-19:卡片外面加**主题色环**(用户:「所有弹框都加线」), - // 由共用组件 `FbSheetRing` 统一给;这一层只负责 92% 宽度。 Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 0 }) { // ---- 标题栏(统一版式:左标题 + 右 ✕,见 FbSheetClose.ets)---- Row({ space: 8 }) { Column({ space: 2 }) { - Text(this.titleText()) + Text(this.editId >= 0 + ? (this.editId === 0 ? '新增影院' : '修改影院') : '影院管理') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .maxLines(1) - Text(this.subtitleText()) + /* 🔴 v63:副标题**不再写「N 家」**(用户:「影院电影列表里面有个 + 『影院26家』,不知道是什么意思……没有用,要把它删掉」)。 + ⇒ 换成一句说明"这里能做什么",比一个看不懂的数字有用。 */ + Text('管理看过的影院:新增 / 修改 / 删除') .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .maxLines(1) @@ -297,27 +178,16 @@ export struct FbCinemaSheet { .width('100%') .alignItems(VerticalAlign.Center) - // ---- 页签 ---- - this.tabBar() - Divider() .color(FbColors.line) - .margin({ top: 4, bottom: 4 }) + .margin({ top: 10, bottom: 4 }) - // ---- 内容区 ---- - // ⚠️ `flexShrink(1)` 让内容**按自身高度收缩**(短内容不撑满 maxHeight)—— - // 本项目在日程详情/模块设置踩过"卡片空半屏"(`layoutWeight(1)` 会吃满)。 + // ---- 内容 ---- + // ⚠️ `flexShrink(1)` 让内容**按自身高度收缩**(短内容不撑满 maxHeight), + // 本项目在日程详情/模块设置踩过"卡片空半屏"。 Scroll() { Column({ space: 0 }) { - if (this.tab === 0) { - this.cinemaPane() - } else if (this.tab === 1) { - this.statsPane() - } else if (this.tab === 2) { - this.searchPane() - } else { - this.reportPane() - } + this.cinemaPane() } .width('100%') } @@ -326,7 +196,7 @@ export struct FbCinemaSheet { .scrollBar(BarState.Off) .align(Alignment.TopStart) - // ---- 底部:影院页的一行结果提示 ---- + // ---- 底部:一行结果提示 ---- if (this.note !== '') { Text(this.note) .fontSize(FbMetric.fontTiny) @@ -335,10 +205,6 @@ export struct FbCinemaSheet { .padding({ top: 8 }) } } - // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… - // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, - // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 - // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 .width('100%') .constraintSize({ maxHeight: '76%' }) .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) @@ -353,60 +219,7 @@ export struct FbCinemaSheet { .hitTestBehavior(HitTestMode.Transparent) } - /** 标题(随页签变,用户一眼知道自己在哪一页) */ - private titleText(): string { - if (this.tab === 1) { - return '观影统计' - } - if (this.tab === 2) { - return '高级搜索' - } - if (this.tab === 3) { - return '年度报告' - } - return this.editId >= 0 ? (this.editId === 0 ? '新增影院' : '修改影院') : '影院管理'; - } - - private subtitleText(): string { - if (this.tab === 1) { - return '与网页端口径一致(需要联网)'; - } - if (this.tab === 2) { - return '片名 / 影院 / 日期组合查'; - } - if (this.tab === 3) { - return '以我为中心,只算我参与过的场次'; - } - return this.cinemas.length.toString() + ' 家 · 按名字排序'; - } - - /** 四个页签(选中用主题色实底 + 白字,与顶栏分段同一套观感) */ - @Builder - tabBar() { - Row({ space: 4 }) { - ForEach(TABS, (t: string, i: number) => { - Text(t) - .fontSize(FbMetric.fontSmall) - .fontColor(this.tab === i ? Color.White : FbColors.muted) - .fontWeight(this.tab === i ? FontWeight.Medium : FontWeight.Normal) - .textAlign(TextAlign.Center) - .layoutWeight(1) - .height(32) - .backgroundColor(this.tab === i ? this.accentColor() : Color.Transparent) - .borderRadius(FbMetric.radiusPill) - .onClick((): void => { - this.switchTab(i); - }) - }, (t: string): string => 'cintab_' + t) - } - .width('100%') - .padding(3) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusPill) - .margin({ top: 10 }) - } - - // ==================== ① 影院:增 / 改 / 删 ==================== + // ==================== 影院:增 / 改 / 删 ==================== @Builder cinemaPane() { @@ -435,7 +248,6 @@ export struct FbCinemaSheet { .height(20) .backgroundColor(c) .borderRadius(10) - // ⚠️ 选中的加一圈描边(否则分不出选了哪个) .border({ width: this.fColor === c ? 2 : 0, color: FbColors.ink }) .onClick((): void => { this.fColor = c; @@ -454,7 +266,8 @@ export struct FbCinemaSheet { .textAlign(TextAlign.Center) .layoutWeight(1) .height(40) - .backgroundColor(this.saving ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) + .backgroundColor(this.saving + ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) .borderRadius(FbMetric.radiusS) .onClick((): void => { this.save(); @@ -576,7 +389,7 @@ export struct FbCinemaSheet { .width('100%') } - // ---- 删除确认(红底确认框,与其它删除同一规格 —— 用户要求"删除按钮全部红色背景")---- + // ---- 删除确认(红底 —— 用户要求"删除按钮全部红色背景")---- if (this.delId > 0) { this.confirmDelete() } @@ -651,608 +464,4 @@ export struct FbCinemaSheet { .padding({ top: 16, bottom: 6 }) .alignItems(HorizontalAlign.Center) } - - // ==================== ② 统计 ==================== - - @Builder - statsPane() { - if (this.statsLoading) { - this.loadingRow('正在取统计…') - } else if (this.statsError !== '') { - this.errorRow(this.statsError, (): void => { - this.loadStats(); - }) - } else if (this.stats !== null) { - // ---- 总览 ---- - this.sectionTitle('总览') - Row({ space: 0 }) { - this.statCell('看过', this.ov().movies) - this.statCell('影院', this.ov().cinemas) - this.statCell('参与的人', this.ov().persons) - this.statCell('人次', this.ov().viewership) - } - .width('100%') - .padding({ top: 6, bottom: 10 }) - - if (this.spanText() !== '') { - Text(this.spanText()) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - } - - // ---- 按影院 ---- - if (this.cinemaStats().length > 0) { - this.sectionTitle('按影院') - Column({ space: 6 }) { - ForEach(this.cinemaStats(), (c: FbMovieCinemaStat) => { - this.barRow( - c.name !== undefined && c.name !== '' ? c.name : '(未命名)', - c.cnt !== undefined ? c.cnt : 0, - this.maxCinemaCount(), - c.color !== undefined && c.color !== '' ? c.color : this.accentColor()) - }, (c: FbMovieCinemaStat): string => 'cs_' + (c.id !== undefined ? c.id : 0).toString()) - } - .width('100%') - .padding({ top: 4 }) - } - - // ---- 按年 ---- - if (this.yearStats().length > 0) { - this.sectionTitle('按年') - Column({ space: 6 }) { - ForEach(this.yearStats(), (y: FbMovieYearStat) => { - this.barRow( - (y.year !== undefined ? y.year.toString() : '') + ' 年', - y.cnt !== undefined ? y.cnt : 0, - this.maxYearCount(), - this.accentColor()) - }, (y: FbMovieYearStat): string => 'ys_' + (y.year !== undefined ? y.year : 0).toString()) - } - .width('100%') - .padding({ top: 4 }) - } - - // ---- 按月(近 12 个月)---- - if (this.monthStats().length > 0) { - this.sectionTitle('近 12 个月') - Column({ space: 6 }) { - ForEach(this.monthStats(), (m: FbMovieMonthStat) => { - this.barRow( - m.label !== undefined && m.label !== '' - ? m.label : (m.ym !== undefined ? m.ym : ''), - m.cnt !== undefined ? m.cnt : 0, - this.maxMonthCount(), - this.accentColor()) - }, (m: FbMovieMonthStat): string => - 'ms_' + (m.ym !== undefined ? m.ym : (m.label !== undefined ? m.label : ''))) - } - .width('100%') - .padding({ top: 4 }) - } - } - } - - /** 总览的安全取值 */ - private ov(): FbMovieOverview { - if (this.stats === null || this.stats.overview === undefined) { - return EMPTY_OVERVIEW; - } - return this.stats.overview; - } - - private spanText(): string { - const o: FbMovieOverview = this.ov(); - const f: string | null | undefined = o.first_date; - const l: string | null | undefined = o.last_date; - if (f === undefined || f === null || f === '') { - return ''; - } - return '从 ' + f + ' 到 ' + (l !== undefined && l !== null && l !== '' ? l : '今'); - } - - private cinemaStats(): FbMovieCinemaStat[] { - return this.stats !== null && this.stats.by_cinema !== undefined ? this.stats.by_cinema : []; - } - - private yearStats(): FbMovieYearStat[] { - return this.stats !== null && this.stats.by_year !== undefined ? this.stats.by_year : []; - } - - private monthStats(): FbMovieMonthStat[] { - return this.stats !== null && this.stats.by_month !== undefined ? this.stats.by_month : []; - } - - private maxCinemaCount(): number { - let m: number = 1; - for (const c of this.cinemaStats()) { - const n: number = c.cnt !== undefined ? c.cnt : 0; - if (n > m) { - m = n; - } - } - return m; - } - - private maxYearCount(): number { - let m: number = 1; - for (const y of this.yearStats()) { - const n: number = y.cnt !== undefined ? y.cnt : 0; - if (n > m) { - m = n; - } - } - return m; - } - - private maxMonthCount(): number { - let m: number = 1; - for (const y of this.monthStats()) { - const n: number = y.cnt !== undefined ? y.cnt : 0; - if (n > m) { - m = n; - } - } - return m; - } - - /** 总览的一格(大数字 + 小标签) */ - @Builder - statCell(label: string, value: number | undefined) { - Column({ space: 2 }) { - Text(value !== undefined ? value.toString() : '0') - .fontSize(22) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - } - - /** - * 一条横向条形统计(名字 + 条 + 数字)。 - * - * ⚠️ 条的宽度用 `layoutWeight` 按**比例**分配,而不是直接给百分比字符串: - * ArkTS 里 `width('62%')` 要拼字符串,且宽度为 0 的条会塌成一条线。 - * 这里用"按 max 归一后的整数份数"再 `layoutWeight`,0 的时候 - * 仍保留一个极小的占位(否则那一行看起来缺了一块)。 - */ - @Builder - barRow(label: string, value: number, max: number, color: string) { - Row({ space: 8 }) { - Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .width(72) - Row() { - Row() - .width(Math.max(4, Math.round((value / max) * 100))) - .height(8) - .backgroundColor(color) - .borderRadius(4) - Blank() - } - .layoutWeight(1) - .height(8) - .alignItems(VerticalAlign.Center) - - Text(value.toString()) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.ink) - .width(30) - .textAlign(TextAlign.End) - } - .width('100%') - .alignItems(VerticalAlign.Center) - } - - // ==================== ③ 高级搜索 ==================== - - @Builder - searchPane() { - Column({ space: 8 }) { - this.field('片名', '含这几个字就算命中', this.sTitle, (v: string): void => { - this.sTitle = v; - }) - // 影院:从本地影院列表里选(不额外打接口) - Row({ space: 8 }) { - Text('影院') - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(52) - Scroll() { - Row({ space: 6 }) { - Text('不限') - .fontSize(FbMetric.fontTiny) - .fontColor(this.sCinemaId === 0 ? Color.White : FbColors.muted) - .padding({ left: 10, right: 10, top: 5, bottom: 5 }) - .backgroundColor(this.sCinemaId === 0 ? this.accentColor() : FbColors.inputBg) - .borderRadius(FbMetric.radiusPill) - .onClick((): void => { - this.sCinemaId = 0; - }) - ForEach(this.cinemas, (c: FbCinemaItem) => { - Text(c.name) - .fontSize(FbMetric.fontTiny) - .fontColor(this.sCinemaId === c.id ? Color.White : FbColors.muted) - .maxLines(1) - .padding({ left: 10, right: 10, top: 5, bottom: 5 }) - .backgroundColor(this.sCinemaId === c.id ? this.accentColor() : FbColors.inputBg) - .borderRadius(FbMetric.radiusPill) - .onClick((): void => { - this.sCinemaId = c.id; - }) - }, (c: FbCinemaItem): string => 'sch_' + c.id.toString()) - } - } - .scrollable(ScrollDirection.Horizontal) - .scrollBar(BarState.Off) - .layoutWeight(1) - } - .width('100%') - .alignItems(VerticalAlign.Center) - - this.field('从', 'YYYY-MM-DD(可空)', this.sFrom, (v: string): void => { - this.sFrom = v; - }) - this.field('到', 'YYYY-MM-DD(可空)', this.sTo, (v: string): void => { - this.sTo = v; - }) - - Row({ space: 8 }) { - Text(this.searchLoading ? '搜索中…' : '搜索') - .fontSize(FbMetric.fontSmall) - .fontColor(Color.White) - .textAlign(TextAlign.Center) - .layoutWeight(1) - .height(40) - .backgroundColor(this.searchLoading - ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) - .borderRadius(FbMetric.radiusS) - .onClick((): void => { - this.doSearch(); - }) - Text('清空') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.muted) - .textAlign(TextAlign.Center) - .layoutWeight(1) - .height(40) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .onClick((): void => { - this.sTitle = ''; - this.sCinemaId = 0; - this.sFrom = ''; - this.sTo = ''; - this.searchRows = []; - this.searchError = ''; - }) - } - .width('100%') - .margin({ top: 2 }) - } - .width('100%') - .padding({ top: 12 }) - .alignItems(HorizontalAlign.Start) - - // ---- 结果 ---- - if (this.searchError !== '') { - Text(this.searchError) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width('100%') - .padding({ top: 12, bottom: 8 }) - } - if (this.searchRows.length > 0) { - this.sectionTitle('命中 ' + this.searchRows.length.toString() + ' 条') - Column({ space: 0 }) { - ForEach(this.searchRows, (r: FbApiMovieSearchRow) => { - Column({ space: 0 }) { - Row({ space: 10 }) { - Column() - .width(4) - .height(34) - .backgroundColor(r.cinema_color !== null && r.cinema_color !== undefined - && r.cinema_color !== '' ? r.cinema_color : this.accentColor()) - .borderRadius(2) - Column({ space: 3 }) { - Text(r.title !== undefined && r.title !== '' ? r.title : '未命名影片') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Row({ space: 6 }) { - Text(this.dateOf(r.starts_at)) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - if (r.cinema_name !== null && r.cinema_name !== undefined && r.cinema_name !== '') { - Text(r.cinema_name) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .maxLines(1) - .layoutWeight(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .width('100%') - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - } - .width('100%') - .padding({ top: 10, bottom: 10 }) - .alignItems(VerticalAlign.Center) - - Divider().strokeWidth(0.5).color(FbColors.line) - } - .width('100%') - }, (r: FbApiMovieSearchRow): string => 'srch_' + (r.id !== undefined ? r.id : 0).toString()) - } - .width('100%') - } - } - - /** `2026-10-19 19:30:00` → `2026-10-19` */ - private dateOf(v: string | undefined | null): string { - if (v === undefined || v === null || v.length < 10) { - return ''; - } - return v.substring(0, 10); - } - - // ==================== ④ 年度报告 ==================== - - @Builder - reportPane() { - if (this.reportLoading) { - this.loadingRow('正在生成年度报告…') - } else if (this.reportError !== '') { - this.errorRow(this.reportError, (): void => { - this.loadReport(this.reportYear); - }) - } else if (this.report !== null) { - // ---- 年份切换 ---- - if (this.reportYears().length > 1) { - Scroll() { - Row({ space: 6 }) { - ForEach(this.reportYears(), (y: number) => { - Text(y.toString() + ' 年') - .fontSize(FbMetric.fontTiny) - .fontColor(this.reportYear === y ? Color.White : FbColors.muted) - .padding({ left: 12, right: 12, top: 5, bottom: 5 }) - .backgroundColor(this.reportYear === y ? this.accentColor() : FbColors.inputBg) - .borderRadius(FbMetric.radiusPill) - .onClick((): void => { - this.loadReport(y); - }) - }, (y: number): string => 'ry_' + y.toString()) - } - } - .scrollable(ScrollDirection.Horizontal) - .scrollBar(BarState.Off) - .width('100%') - .padding({ top: 10, bottom: 2 }) - } - - // ---- 大数 ---- - Row({ space: 0 }) { - this.statCell('看了', this.numField('total')) - this.statCell('观影天数', this.numField('days')) - this.statCell('平均每月', this.numField('avg_per_month')) - } - .width('100%') - .padding({ top: 12, bottom: 10 }) - - // ---- 几条"最" ---- - this.reportLine('最常去的影院', this.strField('top_cinema'), - this.numField('top_cinema_count'), '次') - this.reportLine('一起看最多的人', this.strField('top_person'), - this.numField('top_person_count'), '场') - this.reportLine('最多人一起看的', this.strField('most_viewers_movie'), - this.numField('most_viewers_count'), '人') - - // ---- 首末场 ---- - /* ⚠️ `@Builder` 里**不能写 `const` 声明**(编译器报 "Only UI component - syntax can be written here")—— 只能直接调方法。 - ⇒ 判空/拼接都收进 `reportSpan()` 里,返回空串就不渲染这行。 */ - if (this.reportSpan() !== '') { - this.sectionTitle('这一年') - Text(this.reportSpan()) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.muted) - .padding({ top: 2 }) - } - - // ---- 月份分布 ---- - if (this.monthDist().length > 0) { - this.sectionTitle('每月分布') - Column({ space: 6 }) { - ForEach(this.monthDist(), (m: FbMovieMonthStat) => { - this.barRow( - m.label !== undefined ? m.label : '', - m.cnt !== undefined ? m.cnt : 0, - this.maxMonthDist(), - this.accentColor()) - }, (m: FbMovieMonthStat): string => 'rd_' + (m.label !== undefined ? m.label : '')) - } - .width('100%') - .padding({ top: 4 }) - } - - if (this.numField('total') === 0) { - Text('这一年(' + this.reportYear.toString() + ')没有你参与的观影记录。') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) - .width('100%') - .padding({ top: 24, bottom: 16 }) - .textAlign(TextAlign.Center) - } - } - } - - private reportYears(): number[] { - if (this.report === null || this.report.years === undefined) { - return []; - } - return this.report.years; - } - - private rp(): Object { - if (this.report === null || this.report.stats === undefined) { - // ⚠️ ArkTS 不允许空对象字面量 ⇒ 用一个显式声明的空 interface - return EMPTY_STATS; - } - return this.report.stats as Object; - } - - /** 年报告里的「第一场 … 最后一场 …」;没有首场时返回空串(不渲染) */ - private reportSpan(): string { - const f: string = this.strField('first_date'); - if (f === '') { - return ''; - } - const l: string = this.strField('last_date'); - return '第一场 ' + f + ' · 最后一场 ' + (l !== '' ? l : '—'); - } - - /** 取一个数值字段(JSON 解析出来的是弱类型,统一在这里兜底) */ - private numField(key: string): number { - const o: Record = this.rp() as Record; - const v: Object | undefined = o[key]; - return typeof v === 'number' ? v : 0; - } - - private strField(key: string): string { - const o: Record = this.rp() as Record; - const v: Object | undefined = o[key]; - return typeof v === 'string' ? v : ''; - } - - /** 月份分布 → 条形数据(1..12 月) */ - private monthDist(): FbMovieMonthStat[] { - const o: Record = this.rp() as Record; - const md: Object | undefined = o['month_distribution']; - const out: FbMovieMonthStat[] = []; - if (md === undefined || md === null) { - return out; - } - const m: Record = md as Record; - for (let i = 1; i <= 12; i++) { - const v: Object | undefined = m[i.toString()]; - const it: FbMovieMonthStat = {}; - it.label = i.toString() + ' 月'; - it.cnt = typeof v === 'number' ? v : 0; - out.push(it); - } - return out; - } - - private maxMonthDist(): number { - let m: number = 1; - for (const it of this.monthDist()) { - const n: number = it.cnt !== undefined ? it.cnt : 0; - if (n > m) { - m = n; - } - } - return m; - } - - /** - * 报告里的一条「最 XX」。 - * - * ⚠️ `total` 字段在端上是**弱类型**(服务端可能给 int), - * 所以统一走 `numField/strField` 兜底,不直接下标取值。 - */ - @Builder - reportLine(label: string, value: string, count: number, unit: string) { - if (value !== '' && value !== '无') { - Row({ space: 8 }) { - Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(96) - Text(value) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - if (count > 0) { - Text(count.toString() + ' ' + unit) - .fontSize(FbMetric.fontTiny) - .fontColor(this.accentColor()) - } - } - .width('100%') - .padding({ top: 6, bottom: 6 }) - .alignItems(VerticalAlign.Center) - } - } - - // ==================== 小组件 ==================== - - @Builder - sectionTitle(t: string) { - Text(t) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width('100%') - .padding({ top: 14, bottom: 2 }) - } - - @Builder - loadingRow(t: string) { - Row({ space: 8 }) { - LoadingProgress().width(18).height(18).color(FbColors.faint) - Text(t) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) - } - .width('100%') - .justifyContent(FlexAlign.Center) - .padding({ top: 40, bottom: 40 }) - } - - /** 失败 + 一个「重试」(用户最烦"点了没反应",给一条出路) */ - @Builder - errorRow(msg: string, retry: () => void) { - Column({ space: 10 }) { - Text(msg) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) - .textAlign(TextAlign.Center) - Text('重试') - .fontSize(FbMetric.fontSmall) - .fontColor(this.accentColor()) - .height(34) - .padding({ left: 16, right: 16 }) - .borderRadius(FbMetric.radiusS) - .backgroundColor(fbAlpha(this.accentColor(), 0.10)) - .textAlign(TextAlign.Center) - .onClick((): void => { - retry(); - }) - } - .width('100%') - .padding({ top: 32, bottom: 32 }) - .alignItems(HorizontalAlign.Center) - } -} - -/** - * 新建影院时可选的颜色(与网页 `Cinema::PALETTE` 同一族,取前若干个常用的)。 - * - * ⚠️ 不是**全部**调色板(那有 40 个,竖排一片看不出差别)—— - * 这里挑的是"色相差得开"的那几个,用户一眼能分。 - */ -const PALETTE: string[] = [ - '#7A4E8C', '#e84393', '#e91e63', '#f06292', - '#2980b9', '#6c5ce7', '#16a085', '#2ecc71', - '#f8c471', '#a67c52', '#95a5a6', '#7f8c8d' -]; \ No newline at end of file +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbDateTimeField.ets b/entry/src/main/ets/components/FbDateTimeField.ets new file mode 100644 index 0000000..fee0432 --- /dev/null +++ b/entry/src/main/ets/components/FbDateTimeField.ets @@ -0,0 +1,317 @@ +/** + * 家书 FamilyBook · 「日期 + 时间」选择(鸿蒙端,**日程与观影共用**) + * + * 🔴 v64(用户 2026-10-19)原话: + * 「开始时间和结束时间,应该都是**时间选择框**才对,跟那个日程里面的是一样的, + * 保持一致。」 + * + * ⇒ 观影的「记一场」原来把开始/结束做成了**两个纯文本输入框** + * (要用户手打 `2026-10-19 19:30` 这种串,还得自己记得格式), + * 而日程那边早就有一个"点开 → 日期与时间并排滚轮 → 确定回填"的控件。 + * 用户要求两处**同一种控件**。 + * + * ⚠️ 为什么不复制一份到观影里(这是本文件存在的唯一理由): + * 复制 = 两份实现,改一处忘一处。本项目已经为"两份实现必然漂移" + * 栽过好几次(弹框描边环、关闭按钮、地址格式化都是)。所以抽成共用组件: + * · `FbDateTimeRow` —— 表单里的那一整行(显示 `10月16日 周五 20:00`) + * · `FbDateTimePicker` —— 点开后的弹层(日期与时间**并排** + 确定/取消) + * · 一组 `fb*` 换算函数 —— `cnDate` / `parseDate` / `parseTime` 等, + * 格式化规则也只有这一份("10月16日 周五"这种写法两处必须一模一样) + * + * ⚠️ 为什么**弹层不放进 `FbDateTimeRow` 内部**: + * Row 一定位于浮层卡片**内部**(卡片才 92% 宽),弹层若渲染在 Row 里, + * 就只能撑到卡片的尺寸 ⇒ 变成"卡中卡",与全应用"居中大卡 + 整屏遮罩" + * 的浮层形态不一致(真机上看着像被挤扁了)。 + * ⇒ 弹层由**宿主页面**放到它自己的 `Stack` 里(与 `editCard` 同级、叠在它上面), + * 这样它跟其它浮层长得一样。宿主只需要管一个 `pickOpen` 布尔。 + * + * ⚠️ 弹层内部用**草稿**(`draftDate`/`draftHour`/`draftMin`):点了「取消」不写回。 + * 若直接改宿主的真实值,"取消"就变成不可撤销了。 + */ + +import { FbColors, FbMetric, FB_MASK, FbTint } from '../common/Theme'; +import { FbSheetRing } from './FbSheetRing'; + +// ============================================================ +// 换算 / 格式化(**全应用唯一一份**) +// ============================================================ + +/** 补零到两位 */ +export function fbPad2(n: number): string { + return n < 10 ? ('0' + n.toString()) : n.toString(); +} + +/** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,与时刻并排也不长) */ +export function fbCnDate(iso: string): string { + if (iso.length < 10) { + return iso; + } + const y: number = parseInt(iso.substring(0, 4), 10); + const m: number = parseInt(iso.substring(5, 7), 10); + const d: number = parseInt(iso.substring(8, 10), 10); + const w: string[] = ['日', '一', '二', '三', '四', '五', '六']; + return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()]; +} + +/** 今天 `YYYY-MM-DD` */ +export function fbToday(): string { + const n: Date = new Date(); + return fbPad2(n.getFullYear()) + '-' + fbPad2(n.getMonth() + 1) + '-' + fbPad2(n.getDate()); +} + +/** `YYYY-MM-DD` → `Date`(解析不出给"今天") */ +export function fbParseDate(s: string): Date { + if (s.length < 10) { + return new Date(); + } + const y: number = parseInt(s.substring(0, 4), 10); + const m: number = parseInt(s.substring(5, 7), 10) - 1; + const d: number = parseInt(s.substring(8, 10), 10); + return new Date(y, m, d); +} + +/** `HH:mm` → `Date`(只取时分;解析不出给 09:00) */ +export function fbParseTime(s: string): Date { + const out: Date = new Date(); + if (s.length >= 5) { + out.setHours(parseInt(s.substring(0, 2), 10)); + out.setMinutes(parseInt(s.substring(3, 5), 10)); + } else { + out.setHours(9); + out.setMinutes(0); + } + return out; +} + +/** `YYYY-MM-DD HH:MM:SS` → 日期段 `YYYY-MM-DD` */ +export function fbDateOf(dt: string, fallback: string): string { + return dt.length >= 10 ? dt.substring(0, 10) : fallback; +} + +/** `YYYY-MM-DD HH:MM:SS` → `HH:mm` */ +export function fbTimeOf(dt: string, fallback: string): string { + return dt.length >= 16 ? dt.substring(11, 16) : fallback; +} + +/** `Date` 的年月日 → `YYYY-MM-DD` */ +export function fbIsoOf(d: Date): string { + return fbPad2(d.getFullYear()) + '-' + fbPad2(d.getMonth() + 1) + '-' + fbPad2(d.getDate()); +} + +/** `HH:mm` 往后推 1 小时(结束时间没填时的兜底) */ +export function fbAddHour(time: string): string { + const hh: number = time.length >= 5 ? parseInt(time.substring(0, 2), 10) : 9; + const mm: string = time.length >= 5 ? time.substring(3, 5) : '00'; + return fbPad2(Math.min(hh + 1, 23)) + ':' + mm; +} + +/** 日期 + 时分拼成服务端认的 `YYYY-MM-DD HH:MM`(秒由服务端补) */ +export function fbJoinDateTime(date: string, time: string): string { + return date + ' ' + time; +} + +// ============================================================ +// ① 表单里的那一行 +// ============================================================ + +@ComponentV2 +export struct FbDateTimeRow { + /** 左侧标签(「开始」/「结束」) */ + @Param label: string = '开始'; + /** 日期段 `YYYY-MM-DD` */ + @Param date: string = ''; + /** 时分 `HH:mm`(`allDay` 为真时不显示) */ + @Param time: string = ''; + /** 全天:只显示日期(日程用) */ + @Param allDay: boolean = false; + /** 主题色(右侧 `›` 与文字对齐无关,留着给以后的强调态用) */ + @Param accent: string = ''; + /** 点这一行(宿主把它接到"打开弹层") */ + @Param onTap: () => void = (): void => { + }; + + build() { + Row({ space: 10 }) { + Text(this.label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(28) + + // 整块可点:日期与时间在**同一个控件**里,中间不再分两块 + Row() { + Text(fbCnDate(this.date)) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + if (!this.allDay) { + Text(' ' + this.time) + .fontSize(FbMetric.fontSmall) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + } + } + .layoutWeight(1) + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.onTap(); + }) + + // 右侧小箭头:暗示"点开会选" + Text('›') + .fontSize(FbMetric.fontBody) + .fontColor(FbColors.faint) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } +} + +// ============================================================ +// ② 点开后的弹层 +// ============================================================ + +@ComponentV2 +export struct FbDateTimePicker { + /** 弹层标题(「选择开始时间」/「选择结束时间」) */ + @Param title: string = '选择时间'; + /** 打开时的初始日期 */ + @Param date: string = ''; + /** 打开时的初始时分 */ + @Param time: string = '09:00'; + /** 全天:只画日期滚轮 */ + @Param allDay: boolean = false; + /** 主题色(环色 + 确定按钮底色) */ + @Param accent: string = ''; + /** 可选日期范围(默认 2000~2035:观影可能是很多年前看的老片子) */ + @Param minDate: string = '2000-01-01'; + @Param maxDate: string = '2035-12-31'; + + /** 「确定」:回填(日期 `YYYY-MM-DD`、时分 `HH:mm`) */ + @Event onConfirm: (date: string, time: string) => void = (): void => { + }; + /** 「取消」/ 点遮罩 */ + @Event onCancel: () => void = (): void => { + }; + + // ---- 草稿(取消则丢弃,不碰宿主的值)---- + @Local draftDate: Date = new Date(); + @Local draftHour: number = 9; + @Local draftMin: number = 0; + + aboutToAppear(): void { + this.draftDate = fbParseDate(this.date); + const tm: Date = fbParseTime(this.time); + this.draftHour = tm.getHours(); + this.draftMin = tm.getMinutes(); + } + + build() { + Column() { + // 遮罩:整屏铺一层,点它 = 取消。 + // ⚠️ 必须**有**遮罩:没有的话,点弹层外面会直接点到下面那张编辑卡片上 + // (真机上表现为"随手一点,表单被改了")。 + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .onClick((): void => { + this.onCancel(); + }) + + // ---- 居中卡片 ---- + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 14 }) { + Text(this.title) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 12 }) + + Row({ space: 6 }) { + DatePicker({ + start: fbParseDate(this.minDate), + end: fbParseDate(this.maxDate), + selected: this.draftDate + }) + .onDateChange((v: Date): void => { + this.draftDate = v; + }) + .textStyle({ color: FbColors.faint, font: { size: 14 } }) + .selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } }) + .disappearTextStyle({ color: FbColors.faint, font: { size: 14 } }) + .layoutWeight(1) + .height(210) + + if (!this.allDay) { + TimePicker({ selected: new Date(2000, 0, 1, this.draftHour, this.draftMin) }) + .useMilitaryTime(true) + .onChange((v: TimePickerResult): void => { + // ⚠️ `hour`/`minute` 是**数字**;存草稿数字,拼 `HH:MM` 时再补零 + this.draftHour = v.hour; + this.draftMin = v.minute; + }) + .textStyle({ color: FbColors.faint, font: { size: 14 } }) + .selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } }) + .disappearTextStyle({ color: FbColors.faint, font: { size: 14 } }) + .layoutWeight(1) + .height(210) + } + } + .width('100%') + + Row({ space: 10 }) { + Text('取消') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .height(44) + .padding({ left: 18, right: 18 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onCancel(); + }) + + Text(this.allDay ? '确定日期' : '确定') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(44) + .backgroundColor(this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onConfirm(fbIsoOf(this.draftDate), + fbPad2(this.draftHour) + ':' + fbPad2(this.draftMin)); + }) + } + .width('100%') + } + .width('100%') + .padding({ left: 18, right: 18, top: 8, bottom: 18 }) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + // ⚠️ 透明命中:让点击"卡片以外的区域"落到上面那层遮罩上(点遮罩关掉) + .hitTestBehavior(HitTestMode.Transparent) + } + + /** 环色/按钮底色(没传 accent 时回落到品牌色的 hex) */ + private accentColor(): string { + // ⚠️ 用 `FbTint.brand`(hex)而不是 `FbColors.brand`: + // `FbColors` 那一套是 `Resource`,参与不了 `.backgroundColor(string)`; + // 本组件要与别的浮层行为一致(环色/按钮底色都是可运算的 hex 串)。 + return this.accent !== '' ? this.accent : FbTint.brand; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbEventEditSheet.ets b/entry/src/main/ets/components/FbEventEditSheet.ets index 18c4d19..1b32ca7 100644 --- a/entry/src/main/ets/components/FbEventEditSheet.ets +++ b/entry/src/main/ets/components/FbEventEditSheet.ets @@ -44,6 +44,24 @@ import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FB_CAL_SETTINGS } from '../data/FbCalSettings'; import { FbEventChip } from './FbEventChip'; import { FbSheetRing } from './FbSheetRing'; +/* 🔴 v64(用户 2026-10-19):「观影的开始/结束时间应该跟**日程里面的是一样的**。」 + ⇒ 「日期 + 时间」那一套控件抽成了共用组件(见该文件头),日程与观影都用它。 + ⚠️ 本文件里原来的 `dateTimeRow` / `dateTimePickerSheet` / `cnDate` / + `parseDate` / `parseTime` / `pad` 全部删掉,改调这里的 —— 两份实现必然漂移, + 用户这次点名的正是"两边不一致"。 */ +import { + FbDateTimeRow, + FbDateTimePicker, + fbCnDate, + fbPad2, + fbToday, + fbParseDate, + fbParseTime, + fbDateOf, + fbTimeOf, + fbIsoOf, + fbAddHour +} from './FbDateTimeField'; const DOMAIN: number = 0x0000; const TAG: string = 'FbEventEdit'; @@ -178,10 +196,10 @@ export struct FbEventEditSheet { // ---- 编辑:回填 ---- this.fTitle = e.title; this.fAllDay = e.all_day; - this.fStartDate = this.dateOf(e.starts_at); - this.fStartTime = this.timeOf(e.starts_at); - this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at); - this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : this.addHour(e.starts_at); + this.fStartDate = fbDateOf(e.starts_at, this.todayStr()); + this.fStartTime = fbTimeOf(e.starts_at, '09:00'); + this.fEndDate = e.ends_at !== '' ? fbDateOf(e.ends_at, this.todayStr()) : fbDateOf(e.starts_at, this.todayStr()); + this.fEndTime = e.ends_at !== '' ? fbTimeOf(e.ends_at, '10:00') : this.addHour(e.starts_at); this.fPlace = e.location; this.fNote = e.note; this.fRepeat = this.repeatKeyOf(e.rrule); @@ -190,15 +208,15 @@ export struct FbEventEditSheet { return; } // ---- 新建:预填 ---- - const d: string = this.presetDate !== '' ? this.presetDate : this.todayStr(); + const d: string = this.presetDate !== '' ? this.presetDate : fbToday(); this.fStartDate = d; this.fEndDate = d; // 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 —— // 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。 const now: Date = new Date(); const nh: number = Math.min(now.getHours() + 1, 22); - this.fStartTime = this.pad(nh) + ':00'; - this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00'; + this.fStartTime = fbPad2(nh) + ':00'; + this.fEndTime = fbPad2(nh + 1 > 23 ? 23 : nh + 1) + ':00'; this.fBookId = this.defaultBookId(); } @@ -207,29 +225,12 @@ export struct FbEventEditSheet { // ============================================================ private todayStr(): string { - const n: Date = new Date(); - return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate()); + return fbToday(); } - private pad(n: number): string { - return n < 10 ? ('0' + n.toString()) : n.toString(); - } - - /** 从 `YYYY-MM-DD HH:MM:SS` 取日期段 */ - private dateOf(dt: string): string { - return dt.length >= 10 ? dt.substring(0, 10) : this.todayStr(); - } - - /** 从 `YYYY-MM-DD HH:MM:SS` 取时间段的 `HH:mm` */ - private timeOf(dt: string): string { - return dt.length >= 16 ? dt.substring(11, 16) : '09:00'; - } - - /** 给一个时间的时分 +1 小时(当结束时间没填时的兜底) */ + /** 给一个时刻的时分 +1 小时(当结束时间没填时的兜底) */ private addHour(dt: string): string { - const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9; - const h2: number = Math.min(hh + 1, 23); - return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '00'); + return fbAddHour(fbTimeOf(dt, '09:00')); } /** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */ @@ -577,51 +578,22 @@ export struct FbEventEditSheet { /** * **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。 * - * 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是 - * **把日期和时间放在一个控件里面**。」 - * 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与 - * `TimePicker` 并排**,选完点「确定」一次回填。 - * ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker" - * —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。 - * - * 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`; - * 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。 + * 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimeRow`(**与观影共用**)—— + * 用户 2026-10-19:「观影的开始/结束时间应该跟日程里面的是一样的。」 + * 这里只负责"把本表单的值传进去 + 接住点击"。 */ @Builder dateTimeRow(label: string, isStart: boolean) { - Row({ space: 10 }) { - Text(label) - .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - .width(28) - // 整块可点:日期与时间在**同一个控件**里,中间不再分两块 - Row() { - Text(this.cnDate(isStart ? this.fStartDate : this.fEndDate)) - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.ink) - if (!this.fAllDay) { - Text(' ' + (isStart ? this.fStartTime : this.fEndTime)) - .fontSize(FbMetric.fontSmall) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - } - } - .layoutWeight(1) - .height(42) - .padding({ left: 12, right: 12 }) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .alignItems(VerticalAlign.Center) - .onClick((): void => { + FbDateTimeRow({ + label: label, + date: isStart ? this.fStartDate : this.fEndDate, + time: isStart ? this.fStartTime : this.fEndTime, + allDay: this.fAllDay, + accent: this.accentColor(), + onTap: (): void => { this.openPicker(isStart); - }) - // 右侧小箭头:暗示"点开会选"(与 SyncCalendar 的行样式一致) - Text('›') - .fontSize(FbMetric.fontBody) - .fontColor(FbColors.faint) - } - .width('100%') - .alignItems(VerticalAlign.Center) + } + }) } /** @@ -632,19 +604,17 @@ export struct FbEventEditSheet { */ private openPicker(isEnd: boolean): void { this.pickIsEnd = isEnd; - const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate); - this.pickDate = d; - const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime); + // ⚠️ 草稿值放在宿主,弹层组件 `aboutToAppear` 时从这里读初值 —— + // 组件每次挂载都会重新读一次,所以"点开始 → 取消 → 点结束"拿到的是各自的值。 + this.pickDate = fbParseDate(isEnd ? this.fEndDate : this.fStartDate); + const tm: Date = fbParseTime(isEnd ? this.fEndTime : this.fStartTime); this.pickHour = tm.getHours(); this.pickMin = tm.getMinutes(); this.pickOpen = true; } /** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */ - private applyPicker(): void { - const iso: string = this.pad(this.pickDate.getFullYear()) + '-' - + this.pad(this.pickDate.getMonth() + 1) + '-' + this.pad(this.pickDate.getDate()); - const hm: string = this.pad(this.pickHour) + ':' + this.pad(this.pickMin); + private applyPicker(iso: string, hm: string): void { if (this.pickIsEnd) { this.fEndDate = iso; this.fEndTime = hm; @@ -663,105 +633,25 @@ export struct FbEventEditSheet { /** * 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。 * - * 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。 - * ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**: - * 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。 - * ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。 + * 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimePicker`(**与观影共用**)。 + * 结构(标题 → `Row { DatePicker | TimePicker }` 并排 → 确定)与原来完全一致, + * 差别只是"这一份现在只有一处代码"。 */ @Builder dateTimePickerSheet() { - // 🔴 2026-10-19:与其它弹框统一(92% 宽 + 四角圆角 + 主题色环)。 - Column() { - FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { - Column({ space: 14 }) { - Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间') - .fontSize(FbMetric.fontTitle) - .fontWeight(FontWeight.Medium) - .fontColor(FbColors.ink) - .width('100%') - .textAlign(TextAlign.Center) - .padding({ top: 12 }) - - Row({ space: 6 }) { - DatePicker({ - start: new Date('2000-01-01'), - end: new Date('2035-12-31'), - selected: this.pickDate - }) - .onDateChange((v: Date): void => { - this.pickDate = v; - }) - .textStyle({ color: FbColors.faint, font: { size: 14 } }) - .selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } }) - .disappearTextStyle({ color: FbColors.faint, font: { size: 14 } }) - .layoutWeight(1) - .height(210) - - if (!this.fAllDay) { - TimePicker({ selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) }) - .useMilitaryTime(true) - .onChange((v: TimePickerResult): void => { - // ⚠️ `hour`/`minute` 是**数字**;这里存草稿数字,拼 `HH:MM` 时再补零 - this.pickHour = v.hour; - this.pickMin = v.minute; - }) - .textStyle({ color: FbColors.faint, font: { size: 14 } }) - .selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } }) - .disappearTextStyle({ color: FbColors.faint, font: { size: 14 } }) - .layoutWeight(1) - .height(210) - } + FbDateTimePicker({ + title: this.pickIsEnd ? '选择结束时间' : '选择开始时间', + date: fbIsoOf(this.pickDate), + time: fbPad2(this.pickHour) + ':' + fbPad2(this.pickMin), + allDay: this.fAllDay, + accent: this.accentColor(), + onConfirm: (d: string, t: string): void => { + this.applyPicker(d, t); + }, + onCancel: (): void => { + this.pickOpen = false; } - .width('100%') - - Button(this.fAllDay ? '确定日期' : '确定') - .width('100%') - .height(44) - .borderRadius(FbMetric.radiusS) - .fontSize(FbMetric.fontBody) - .backgroundColor('#4A7C82') - .fontColor(Color.White) - .onClick((): void => { - this.applyPicker(); - }) - } - .width('100%') - .padding({ left: 18, right: 18, top: 8, bottom: 20 }) - .alignItems(HorizontalAlign.Start) - } - } - .width('92%') - } - - /** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */ - private cnDate(iso: string): string { - if (iso.length < 10) { - return iso; - } - const y: number = parseInt(iso.substring(0, 4), 10); - const m: number = parseInt(iso.substring(5, 7), 10); - const d: number = parseInt(iso.substring(8, 10), 10); - const w: string[] = ['日', '一', '二', '三', '四', '五', '六']; - return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()]; - } - - private parseDate(s: string): Date { - if (s.length < 10) { - return new Date(); - } - const y: number = parseInt(s.substring(0, 4), 10); - const m: number = parseInt(s.substring(5, 7), 10) - 1; - const d: number = parseInt(s.substring(8, 10), 10); - return new Date(y, m, d); - } - - private parseTime(s: string): Date { - const out: Date = new Date(); - if (s.length >= 5) { - out.setHours(parseInt(s.substring(0, 2), 10)); - out.setMinutes(parseInt(s.substring(3, 5), 10)); - } - return out; + }) } @Builder diff --git a/entry/src/main/ets/components/FbMovieInsightSheet.ets b/entry/src/main/ets/components/FbMovieInsightSheet.ets new file mode 100644 index 0000000..ec3706e --- /dev/null +++ b/entry/src/main/ets/components/FbMovieInsightSheet.ets @@ -0,0 +1,921 @@ +/** + * 家书 FamilyBook · 观影「高级搜索 / 统计 / 年度报告」浮层(鸿蒙端) + * + * 🔴 v63(用户 2026-10-19)**推翻了 v62 的页签方案**,原话: + * 「**影院管理就是影院管理。高级搜索统计和年度报告不在"影院管理"这个功能里**, + * 而是点击观影设置后,弹框里面有"影院管理""高级搜索统计"和"年度报告"; + * 点击之后,会进入到搜索统计和年度报告里面。」 + * + * ⇒ v62 我把四项塞进同一个浮层的四个页签里;用户要的是**平级的独立入口**。 + * ⇒ 影院(列表+增改删)留在 `FbCinemaSheet`; + * 这一支负责另外三项,且**由 `mode` 决定显示哪一项**(同一份代码三种形态): + * `search` —— 片名 / 影院 / 起止日期(`/api/v1/movies/search`) + * `stats` —— 总览 + 按影院/按年/按月(`/api/v1/movies/stats`) + * `report` —— 按年切换的年度报告(`/api/v1/movies/report`) + * + * ⚠️ 三项都必须**联网**——它们的口径要与网页**完全一致**(同一份 `MovieStats`)。 + * 本地库算出来的是"已同步的那部分",少同步一次就少一部, + * 用户会发现"网页 124 部、App 121 部" ⇒ 断网时明说"需要联网", + * 而不是给一个偏低的数字。 + * + * ⚠️ 三种形态共用一个组件(而不是拆三个文件): + * 它们共享全部取值兜底(`numField` / `strField` / `barRow` / `sectionTitle` + * / 加载态 / 错误态),拆开就是把这些抄三遍。 + */ + +import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; +import { + FbApiMovieSearchRow, FbCinemaItem, FbMovieCinemaStat, FbMovieMonthStat, + FbMovieOverview, FbMovieReport, FbMovieRepo, FbMovieSearchParams, FbMovieStats, + FbMovieYearStat +} from '../data/FbMovieRepo'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; + +/** + * 贴底卡片的手势区缓冲(vp)。 + * + * 🔴 应用开了全屏布局且**隐藏了系统手势条**(见 EntryAbility)—— + * 手势区不再有任何视觉提示,贴底的卡片必须自己让出这一段, + * 否则最后一排按钮会被"从下往上滑"误触。 + */ +const FB_SHEET_BOTTOM_GAP: number = 16; + +/** + * 空值常量。 + * + * ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals` **不允许空对象字面量** + * (`return {}` 直接编译不过)⇒ 用一个显式声明的空 interface + 常量顶掉。 + */ +interface FbEmptyStats { +} + +interface FbEmptyOverview { +} + +const EMPTY_STATS: FbEmptyStats = {}; +const EMPTY_OVERVIEW: FbEmptyOverview = {}; + +@ComponentV2 +export struct FbMovieInsightSheet { + /** + * 这一支要显示哪一项:`search` / `stats` / `report`。 + * + * 🔴 用参数而不是三个组件:三者共享全部取值兜底与加载/错误态 + * (见文件头说明)。⚠️ 设置菜单点哪一项就传哪个值。 + */ + @Param mode: string = 'stats'; + /** 影院列表(**搜索页的筛选项**要用;由页面传入,只读) */ + @Param cinemas: FbCinemaItem[] = []; + /** 关闭回调 */ + @Event onClose: () => void = (): void => { + }; + + /** + * 卡片外圈描边环的颜色(hex,'' = 用观影模块色)。 + * + * ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。 + */ + @Param accent: string = ''; + + /** + * 🔴 v63:不再有页签 —— 本组件只显示 `mode` 那一项。 + * `aboutToAppear` 按 mode 拉对应数据(不在构造时就打三个接口)。 + */ + @Local inited: boolean = false; + + // ---- 统计 / 年报 / 搜索的加载态 ---- + @Local statsLoading: boolean = false; + @Local stats: FbMovieStats | null = null; + @Local statsError: string = ''; + + @Local reportLoading: boolean = false; + @Local report: FbMovieReport | null = null; + @Local reportError: string = ''; + /** 年报年份(0 = 用服务端返回的默认年) */ + @Local reportYear: number = 0; + + @Local searchLoading: boolean = false; + @Local searchRows: FbApiMovieSearchRow[] = []; + @Local searchError: string = ''; + /** 搜索条件 */ + @Local sTitle: string = ''; + @Local sCinemaId: number = 0; + @Local sFrom: string = ''; + @Local sTo: string = ''; + + 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; + } + + aboutToAppear(): void { + // 🔴 按 mode 拉数据(三种形态各只打一个接口) + if (this.inited) { + return; + } + this.inited = true; + if (this.mode === 'stats') { + this.loadStats(); + } else if (this.mode === 'report') { + this.loadReport(0); + } + } + + private async loadStats(): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.statsError = '还没登录'; + return; + } + this.statsLoading = true; + this.statsError = ''; + const r: FbMovieStats | null = await this.repo.stats(token); + this.statsLoading = false; + if (r === null) { + this.statsError = '统计需要联网(本地库只有已同步的一部分,算不准)'; + return; + } + this.stats = r; + } + + private async loadReport(year: number): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.reportError = '还没登录'; + return; + } + this.reportLoading = true; + this.reportError = ''; + const r: FbMovieReport | null = await this.repo.report(token, year); + this.reportLoading = false; + if (r === null) { + this.reportError = '年度报告需要联网'; + return; + } + this.report = r; + const y: number | undefined = r.year; + this.reportYear = y !== undefined ? y : year; + } + + private async doSearch(): Promise { + const token: string = FB_TOKEN_STORE.get(); + if (token === '') { + this.searchError = '还没登录'; + return; + } + const p: FbMovieSearchParams = { + title: this.sTitle.trim(), + cinemaId: this.sCinemaId, + personId: 0, + dateFrom: this.sFrom.trim(), + dateTo: this.sTo.trim() + }; + // ⚠️ 与网页同规则:条件全空就不查(否则等于"列出所有观影",不是搜索) + if (p.title === '' && p.cinemaId <= 0 && p.dateFrom === '' && p.dateTo === '') { + this.searchError = '先设一个条件再搜(片名 / 影院 / 日期)'; + this.searchRows = []; + return; + } + this.searchLoading = true; + this.searchError = ''; + this.searchRows = await this.repo.search(token, p); + this.searchLoading = false; + if (this.searchRows.length === 0) { + this.searchError = '没有符合条件的观影记录'; + } + } + + build() { + // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 + // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— + // 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 + // ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 + Column() { + // 🔴 2026-10-19:卡片外面加**主题色环**(用户:「所有弹框都加线」), + // 由共用组件 `FbSheetRing` 统一给;这一层只负责 92% 宽度。 + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 0 }) { + // ---- 标题栏(统一版式:左标题 + 右 ✕,见 FbSheetClose.ets)---- + Row({ space: 8 }) { + Column({ space: 2 }) { + Text(this.titleText()) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .maxLines(1) + Text(this.subtitleText()) + .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: 4, bottom: 4 }) + + // ---- 内容区 ---- + // ⚠️ `flexShrink(1)` 让内容**按自身高度收缩**(短内容不撑满 maxHeight)—— + // 本项目在日程详情/模块设置踩过"卡片空半屏"(`layoutWeight(1)` 会吃满)。 + Scroll() { + Column({ space: 0 }) { + if (this.mode === 'stats') { + this.statsPane() + } else if (this.mode === 'search') { + this.searchPane() + } else { + this.reportPane() + } + } + .width('100%') + } + .flexShrink(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + + } + // 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**…… + // **不要让它左右充满整个屏幕,下面也不要挨着屏幕**, + // 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」 + // ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。 + .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) + } + + /** 标题(由 `mode` 决定,用户一眼知道自己在看哪一项) */ + private titleText(): string { + if (this.mode === 'search') { + return '高级搜索' + } + if (this.mode === 'report') { + return '年度报告' + } + return '观影统计' + } + + private subtitleText(): string { + if (this.mode === 'search') { + return '片名 / 影院 / 日期组合查'; + } + if (this.mode === 'report') { + return '以我为中心,只算我参与过的场次'; + } + return '与网页端口径一致(需要联网)'; + } + + // ==================== ② 统计 ==================== + + @Builder + statsPane() { + if (this.statsLoading) { + this.loadingRow('正在取统计…') + } else if (this.statsError !== '') { + this.errorRow(this.statsError, (): void => { + this.loadStats(); + }) + } else if (this.stats !== null) { + // ---- 总览 ---- + this.sectionTitle('总览') + Row({ space: 0 }) { + this.statCell('看过', this.ov().movies) + this.statCell('影院', this.ov().cinemas) + this.statCell('参与的人', this.ov().persons) + this.statCell('人次', this.ov().viewership) + } + .width('100%') + .padding({ top: 6, bottom: 10 }) + + if (this.spanText() !== '') { + Text(this.spanText()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + + // ---- 按影院 ---- + if (this.cinemaStats().length > 0) { + this.sectionTitle('按影院') + Column({ space: 6 }) { + ForEach(this.cinemaStats(), (c: FbMovieCinemaStat) => { + this.barRow( + c.name !== undefined && c.name !== '' ? c.name : '(未命名)', + c.cnt !== undefined ? c.cnt : 0, + this.maxCinemaCount(), + c.color !== undefined && c.color !== '' ? c.color : this.accentColor()) + }, (c: FbMovieCinemaStat): string => 'cs_' + (c.id !== undefined ? c.id : 0).toString()) + } + .width('100%') + .padding({ top: 4 }) + } + + // ---- 按年 ---- + if (this.yearStats().length > 0) { + this.sectionTitle('按年') + Column({ space: 6 }) { + ForEach(this.yearStats(), (y: FbMovieYearStat) => { + this.barRow( + (y.year !== undefined ? y.year.toString() : '') + ' 年', + y.cnt !== undefined ? y.cnt : 0, + this.maxYearCount(), + this.accentColor()) + }, (y: FbMovieYearStat): string => 'ys_' + (y.year !== undefined ? y.year : 0).toString()) + } + .width('100%') + .padding({ top: 4 }) + } + + // ---- 按月(近 12 个月)---- + if (this.monthStats().length > 0) { + this.sectionTitle('近 12 个月') + Column({ space: 6 }) { + ForEach(this.monthStats(), (m: FbMovieMonthStat) => { + this.barRow( + m.label !== undefined && m.label !== '' + ? m.label : (m.ym !== undefined ? m.ym : ''), + m.cnt !== undefined ? m.cnt : 0, + this.maxMonthCount(), + this.accentColor()) + }, (m: FbMovieMonthStat): string => + 'ms_' + (m.ym !== undefined ? m.ym : (m.label !== undefined ? m.label : ''))) + } + .width('100%') + .padding({ top: 4 }) + } + } + } + + /** 总览的安全取值 */ + private ov(): FbMovieOverview { + if (this.stats === null || this.stats.overview === undefined) { + return EMPTY_OVERVIEW; + } + return this.stats.overview; + } + + private spanText(): string { + const o: FbMovieOverview = this.ov(); + const f: string | null | undefined = o.first_date; + const l: string | null | undefined = o.last_date; + if (f === undefined || f === null || f === '') { + return ''; + } + return '从 ' + f + ' 到 ' + (l !== undefined && l !== null && l !== '' ? l : '今'); + } + + private cinemaStats(): FbMovieCinemaStat[] { + return this.stats !== null && this.stats.by_cinema !== undefined ? this.stats.by_cinema : []; + } + + private yearStats(): FbMovieYearStat[] { + return this.stats !== null && this.stats.by_year !== undefined ? this.stats.by_year : []; + } + + private monthStats(): FbMovieMonthStat[] { + return this.stats !== null && this.stats.by_month !== undefined ? this.stats.by_month : []; + } + + private maxCinemaCount(): number { + let m: number = 1; + for (const c of this.cinemaStats()) { + const n: number = c.cnt !== undefined ? c.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + private maxYearCount(): number { + let m: number = 1; + for (const y of this.yearStats()) { + const n: number = y.cnt !== undefined ? y.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + private maxMonthCount(): number { + let m: number = 1; + for (const y of this.monthStats()) { + const n: number = y.cnt !== undefined ? y.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + /** 总览的一格(大数字 + 小标签) */ + @Builder + statCell(label: string, value: number | undefined) { + Column({ space: 2 }) { + Text(value !== undefined ? value.toString() : '0') + .fontSize(22) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + /** + * 一条横向条形统计(名字 + 条 + 数字)。 + * + * ⚠️ 条的宽度用 `layoutWeight` 按**比例**分配,而不是直接给百分比字符串: + * ArkTS 里 `width('62%')` 要拼字符串,且宽度为 0 的条会塌成一条线。 + * 这里用"按 max 归一后的整数份数"再 `layoutWeight`,0 的时候 + * 仍保留一个极小的占位(否则那一行看起来缺了一块)。 + */ + @Builder + barRow(label: string, value: number, max: number, color: string) { + Row({ space: 8 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width(72) + Row() { + Row() + .width(Math.max(4, Math.round((value / max) * 100))) + .height(8) + .backgroundColor(color) + .borderRadius(4) + Blank() + } + .layoutWeight(1) + .height(8) + .alignItems(VerticalAlign.Center) + + Text(value.toString()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .width(30) + .textAlign(TextAlign.End) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + // ==================== ③ 高级搜索 ==================== + + @Builder + searchPane() { + Column({ space: 8 }) { + this.field('片名', '含这几个字就算命中', this.sTitle, (v: string): void => { + this.sTitle = v; + }) + // 影院:从本地影院列表里选(不额外打接口) + Row({ space: 8 }) { + Text('影院') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(52) + Scroll() { + Row({ space: 6 }) { + Text('不限') + .fontSize(FbMetric.fontTiny) + .fontColor(this.sCinemaId === 0 ? Color.White : FbColors.muted) + .padding({ left: 10, right: 10, top: 5, bottom: 5 }) + .backgroundColor(this.sCinemaId === 0 ? this.accentColor() : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.sCinemaId = 0; + }) + ForEach(this.cinemas, (c: FbCinemaItem) => { + Text(c.name) + .fontSize(FbMetric.fontTiny) + .fontColor(this.sCinemaId === c.id ? Color.White : FbColors.muted) + .maxLines(1) + .padding({ left: 10, right: 10, top: 5, bottom: 5 }) + .backgroundColor(this.sCinemaId === c.id ? this.accentColor() : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.sCinemaId = c.id; + }) + }, (c: FbCinemaItem): string => 'sch_' + c.id.toString()) + } + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + this.field('从', 'YYYY-MM-DD(可空)', this.sFrom, (v: string): void => { + this.sFrom = v; + }) + this.field('到', 'YYYY-MM-DD(可空)', this.sTo, (v: string): void => { + this.sTo = v; + }) + + Row({ space: 8 }) { + Text(this.searchLoading ? '搜索中…' : '搜索') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + .backgroundColor(this.searchLoading + ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.doSearch(); + }) + Text('清空') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(40) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.sTitle = ''; + this.sCinemaId = 0; + this.sFrom = ''; + this.sTo = ''; + this.searchRows = []; + this.searchError = ''; + }) + } + .width('100%') + .margin({ top: 2 }) + } + .width('100%') + .padding({ top: 12 }) + .alignItems(HorizontalAlign.Start) + + // ---- 结果 ---- + if (this.searchError !== '') { + Text(this.searchError) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 12, bottom: 8 }) + } + if (this.searchRows.length > 0) { + this.sectionTitle('命中 ' + this.searchRows.length.toString() + ' 条') + Column({ space: 0 }) { + ForEach(this.searchRows, (r: FbApiMovieSearchRow) => { + Column({ space: 0 }) { + Row({ space: 10 }) { + Column() + .width(4) + .height(34) + .backgroundColor(r.cinema_color !== null && r.cinema_color !== undefined + && r.cinema_color !== '' ? r.cinema_color : this.accentColor()) + .borderRadius(2) + Column({ space: 3 }) { + Text(r.title !== undefined && r.title !== '' ? r.title : '未命名影片') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Row({ space: 6 }) { + Text(this.dateOf(r.starts_at)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + if (r.cinema_name !== null && r.cinema_name !== undefined && r.cinema_name !== '') { + Text(r.cinema_name) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + .layoutWeight(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .padding({ top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + + Divider().strokeWidth(0.5).color(FbColors.line) + } + .width('100%') + }, (r: FbApiMovieSearchRow): string => 'srch_' + (r.id !== undefined ? r.id : 0).toString()) + } + .width('100%') + } + } + + /** `2026-10-19 19:30:00` → `2026-10-19` */ + private dateOf(v: string | undefined | null): string { + if (v === undefined || v === null || v.length < 10) { + return ''; + } + return v.substring(0, 10); + } + + // ==================== ④ 年度报告 ==================== + + @Builder + reportPane() { + if (this.reportLoading) { + this.loadingRow('正在生成年度报告…') + } else if (this.reportError !== '') { + this.errorRow(this.reportError, (): void => { + this.loadReport(this.reportYear); + }) + } else if (this.report !== null) { + // ---- 年份切换 ---- + if (this.reportYears().length > 1) { + Scroll() { + Row({ space: 6 }) { + ForEach(this.reportYears(), (y: number) => { + Text(y.toString() + ' 年') + .fontSize(FbMetric.fontTiny) + .fontColor(this.reportYear === y ? Color.White : FbColors.muted) + .padding({ left: 12, right: 12, top: 5, bottom: 5 }) + .backgroundColor(this.reportYear === y ? this.accentColor() : FbColors.inputBg) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.loadReport(y); + }) + }, (y: number): string => 'ry_' + y.toString()) + } + } + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off) + .width('100%') + .padding({ top: 10, bottom: 2 }) + } + + // ---- 大数 ---- + Row({ space: 0 }) { + this.statCell('看了', this.numField('total')) + this.statCell('观影天数', this.numField('days')) + this.statCell('平均每月', this.numField('avg_per_month')) + } + .width('100%') + .padding({ top: 12, bottom: 10 }) + + // ---- 几条"最" ---- + this.reportLine('最常去的影院', this.strField('top_cinema'), + this.numField('top_cinema_count'), '次') + this.reportLine('一起看最多的人', this.strField('top_person'), + this.numField('top_person_count'), '场') + this.reportLine('最多人一起看的', this.strField('most_viewers_movie'), + this.numField('most_viewers_count'), '人') + + // ---- 首末场 ---- + /* ⚠️ `@Builder` 里**不能写 `const` 声明**(编译器报 "Only UI component + syntax can be written here")—— 只能直接调方法。 + ⇒ 判空/拼接都收进 `reportSpan()` 里,返回空串就不渲染这行。 */ + if (this.reportSpan() !== '') { + this.sectionTitle('这一年') + Text(this.reportSpan()) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.muted) + .padding({ top: 2 }) + } + + // ---- 月份分布 ---- + if (this.monthDist().length > 0) { + this.sectionTitle('每月分布') + Column({ space: 6 }) { + ForEach(this.monthDist(), (m: FbMovieMonthStat) => { + this.barRow( + m.label !== undefined ? m.label : '', + m.cnt !== undefined ? m.cnt : 0, + this.maxMonthDist(), + this.accentColor()) + }, (m: FbMovieMonthStat): string => 'rd_' + (m.label !== undefined ? m.label : '')) + } + .width('100%') + .padding({ top: 4 }) + } + + if (this.numField('total') === 0) { + Text('这一年(' + this.reportYear.toString() + ')没有你参与的观影记录。') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 24, bottom: 16 }) + .textAlign(TextAlign.Center) + } + } + } + + private reportYears(): number[] { + if (this.report === null || this.report.years === undefined) { + return []; + } + return this.report.years; + } + + private rp(): Object { + if (this.report === null || this.report.stats === undefined) { + // ⚠️ ArkTS 不允许空对象字面量 ⇒ 用一个显式声明的空 interface + return EMPTY_STATS; + } + return this.report.stats as Object; + } + + /** 年报告里的「第一场 … 最后一场 …」;没有首场时返回空串(不渲染) */ + private reportSpan(): string { + const f: string = this.strField('first_date'); + if (f === '') { + return ''; + } + const l: string = this.strField('last_date'); + return '第一场 ' + f + ' · 最后一场 ' + (l !== '' ? l : '—'); + } + + /** 取一个数值字段(JSON 解析出来的是弱类型,统一在这里兜底) */ + private numField(key: string): number { + const o: Record = this.rp() as Record; + const v: Object | undefined = o[key]; + return typeof v === 'number' ? v : 0; + } + + private strField(key: string): string { + const o: Record = this.rp() as Record; + const v: Object | undefined = o[key]; + return typeof v === 'string' ? v : ''; + } + + /** 月份分布 → 条形数据(1..12 月) */ + private monthDist(): FbMovieMonthStat[] { + const o: Record = this.rp() as Record; + const md: Object | undefined = o['month_distribution']; + const out: FbMovieMonthStat[] = []; + if (md === undefined || md === null) { + return out; + } + const m: Record = md as Record; + for (let i = 1; i <= 12; i++) { + const v: Object | undefined = m[i.toString()]; + const it: FbMovieMonthStat = {}; + it.label = i.toString() + ' 月'; + it.cnt = typeof v === 'number' ? v : 0; + out.push(it); + } + return out; + } + + private maxMonthDist(): number { + let m: number = 1; + for (const it of this.monthDist()) { + const n: number = it.cnt !== undefined ? it.cnt : 0; + if (n > m) { + m = n; + } + } + return m; + } + + /** + * 报告里的一条「最 XX」。 + * + * ⚠️ `total` 字段在端上是**弱类型**(服务端可能给 int), + * 所以统一走 `numField/strField` 兜底,不直接下标取值。 + */ + @Builder + reportLine(label: string, value: string, count: number, unit: string) { + if (value !== '' && value !== '无') { + Row({ space: 8 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(96) + Text(value) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + if (count > 0) { + Text(count.toString() + ' ' + unit) + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + } + .width('100%') + .padding({ top: 6, bottom: 6 }) + .alignItems(VerticalAlign.Center) + } + } + + /** + * 一行输入(标签 + 输入框)。**搜索页的三个条件都用它**。 + * + * ⚠️ 之前它被误删过:我以为它只给"影院管理"用 —— 其实搜索页的 + * 片名/从/到也用它(删了之后编译立刻报 `Property 'field' does not exist`)。 + */ + @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 + sectionTitle(t: string) { + Text(t) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .padding({ top: 14, bottom: 2 }) + } + + @Builder + loadingRow(t: string) { + Row({ space: 8 }) { + LoadingProgress().width(18).height(18).color(FbColors.faint) + Text(t) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + } + .width('100%') + .justifyContent(FlexAlign.Center) + .padding({ top: 40, bottom: 40 }) + } + + /** 失败 + 一个「重试」(用户最烦"点了没反应",给一条出路) */ + @Builder + errorRow(msg: string, retry: () => void) { + Column({ space: 10 }) { + Text(msg) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.faint) + .textAlign(TextAlign.Center) + Text('重试') + .fontSize(FbMetric.fontSmall) + .fontColor(this.accentColor()) + .height(34) + .padding({ left: 16, right: 16 }) + .borderRadius(FbMetric.radiusS) + .backgroundColor(fbAlpha(this.accentColor(), 0.10)) + .textAlign(TextAlign.Center) + .onClick((): void => { + retry(); + }) + } + .width('100%') + .padding({ top: 32, bottom: 32 }) + .alignItems(HorizontalAlign.Center) + } +} diff --git a/entry/src/main/ets/components/FbMovieNewSheet.ets b/entry/src/main/ets/components/FbMovieNewSheet.ets new file mode 100644 index 0000000..9001c1b --- /dev/null +++ b/entry/src/main/ets/components/FbMovieNewSheet.ets @@ -0,0 +1,1041 @@ +/** + * 家书 FamilyBook · 「记一场」新建观影浮层(鸿蒙端) + * + * 🔴 用户 2026-10-19 这一轮的四条(本文件是主要落点): + * + * ① 「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。 + * 这个在网页端是直接实现了的,我们的手机端也必须同步实现。」 + * ⇒ 片名输入框**边打边出候选**(320ms 防抖,与网页端同款),点候选即抓; + * 旁边一颗「自动获取」按钮按当前片名抓。抓到的东西与网页端**同一份**: + * · 简介 = 服务端拼好的 `summary`(含导演/主演/年份/类型/片长/国家/评分 + 剧情); + * · 海报 = 服务端已经落盘的 `poster_key`(走 poster 槽位管线裁成 2:3)。 + * ⚠️ 只填空白的(用户已经写过的不覆盖),这是网页端的既定语义。 + * + * ② 「开始时间和结束时间,应该都是**时间选择框**才对,跟那个日程里面的是一样的。」 + * ⇒ 两个纯文本输入框 → `FbDateTimeRow`(**与日程共用同一个组件**)。 + * + * ③ 「**影院都应该是弹框选择**,同看的人也应该是弹框选择,这种选择方式太难找了。 + * 而且这个弹框选择要跟网页端的一样,在上面有个筛选框,就是当输入人的拼音的 + * 首字母的时候,可以把这个人给筛选出来。」 + * ⇒ 影院:`FbCinemaPickSheet`(顶部搜索框,匹配地方+名称); + * 同看人:`FbPersonPickSheet`(顶部搜索框,**拼音首字母子序列**匹配, + * 多选"暂存 + 确定"两段式)。 + * ⚠️ 原来那两排横向胶囊/头像全部删掉 —— 用户点名的就是"太难找了"。 + * + * ④ 「**允许上传多张图片,包括海报。**」 + * ⇒ 海报 1 张 + 观影照片**多张**,用系统图片选择器(可多选), + * 在浮层里出缩略图预览、可逐张删。 + * ⚠️ 图片是**记录建完之后**逐个上传的(先建记录再传图): + * 建记录失败不会留下孤儿图;某张图失败也**不挡记录** + * (与网页端"图片上传失败不挡保存"同一取舍)。 + * + * ⑤ 「添加电影这里,**地点不需要**,因为影院里面就有。」 + * ⇒ 「地点」这个输入框**删掉**(`location` 字段仍然存在于服务端, + * 网页端与历史数据都还在用,只是端上不再提供这个输入口)。 + * + * ⚠️ 片名与时间是**必填**(服务端 `movies` 分支就校验这两条), + * 前端先拦一道,免得用户填完一堆才被服务端打回。 + * + * ⚠️ 走 `FbMovieRepo.createMovie()`(`POST /api/v1/movies`)—— + * 写完**立刻重拉列表回写本地库**,否则刚记的这场要等下次同步才出现。 + */ + +import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; +import { FbCinemaItem, FbDoubanFetch, FbDoubanSuggest, FbMovieDraft, FbMovieRepo } from '../data/FbMovieRepo'; +import { FbRosterPerson, FbPersonRepo } from '../data/FbPersonRepo'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; +import { fbApiBase, fbImageUrl } from '../data/FbHttp'; +import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose, FbSheetTitleBar } from './FbSheetClose'; +import { FbCinemaPickSheet } from './FbCinemaPickSheet'; +import { FbPersonPickSheet } from './FbPersonPickSheet'; +import { + FbDateTimeRow, + FbDateTimePicker, + fbPad2, + fbToday, + fbJoinDateTime +} from './FbDateTimeField'; +import { picker } from '@kit.CoreFileKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbMovieNew'; + +const FB_SHEET_BOTTOM_GAP: number = 16; + +/** + * 片名联想的防抖(毫秒)—— **与网页端同值**(`templates/movies/form.php` 里是 320)。 + * + * ⚠️ 为什么要防抖:中文输入法下每敲一个字母都会触发 `onChange`, + * 不防抖就是对豆瓣发十几次请求(还可能被限流)。 + * ⚠️ 与网页端同值是有意的:两边的手感应当一致,将来改也要一起改。 + */ +const FB_SUGGEST_DEBOUNCE_MS: number = 320; + +@ComponentV2 +export struct FbMovieNewSheet { + /** 影院列表(本地库;给"选影院"用) */ + @Param cinemas: FbCinemaItem[] = []; + /** 关闭回调 */ + @Event onClose: () => void = (): void => { + }; + /** 新建成功后通知页面刷新列表 */ + @Event onCreated: () => void = (): void => { + }; + /** 环色(hex,'' = 观影模块色) */ + @Param accent: string = ''; + /** 登录者 id(**默认把"我"选进同看人**,与网页端 v38 同口径;0 = 不知道) */ + @Param meId: number = 0; + + // ---- 表单字段 ---- + @Local title: string = ''; + /** 开始时间(`YYYY-MM-DD` + `HH:mm` 分开存,便于给日期时间控件用) */ + @Local startDate: string = ''; + @Local startTime: string = ''; + @Local endDate: string = ''; + @Local endTime: string = ''; + @Local cinemaId: number = 0; + @Local info: string = ''; + @Local note: string = ''; + /** 豆瓣抓到的海报键(保存时随记录提交;端上另选的海报走单独上传) */ + @Local doubanPosterKey: string = ''; + /** 豆瓣抓到的元数据 JSON(存进库,详情页另有展示) */ + @Local doubanMeta: string = ''; + + /** 名册(可选同看人;懒加载,进浮层才读本地库) */ + @Local roster: FbRosterPerson[] = []; + @Local picked: number[] = []; + + // ---- 豆瓣联想 ---- + @Local suggests: FbDoubanSuggest[] = []; + @Local suggestOpen: boolean = false; + @Local fetching: boolean = false; + private suggestTimer: number = -1; + + // ---- 媒体(端上选的图)---- + /** 海报:端上选的本地 URI('' = 没选) */ + @Local posterUri: string = ''; + /** 观影照片:本地 URI 列表(可多张) */ + @Local photoUris: string[] = []; + + // ---- 子浮层 ---- + @Local cinemaPickOpen: boolean = false; + @Local personPickOpen: boolean = false; + @Local pickOpen: boolean = false; + /** 正在选的是"结束"(false = 开始) */ + @Local pickIsEnd: boolean = false; + + // ---- 状态 ---- + @Local saving: boolean = false; + @Local note2: string = ''; + @Local bad: boolean = false; + + private repo: FbMovieRepo = FbMovieRepo.inst(); + + aboutToAppear(): void { + // 默认给"今天 + 下一个整点":记一场电影通常就是"刚刚看的", + // 给个空的让用户从头选日期是最烦的。 + const now: Date = new Date(); + const d: string = fbToday(); + this.startDate = d; + this.endDate = d; + const nh: number = now.getHours(); + this.startTime = fbPad2(nh) + ':' + fbPad2(now.getMinutes() < 30 ? 0 : 30); + // 结束 = 开始 + 2 小时(一部电影的常见时长),封顶 23:59 + this.endTime = fbPad2(Math.min(nh + 2, 23)) + ':' + fbPad2(now.getMinutes() < 30 ? 0 : 30); + // 同看人默认带上"我"(与网页端 v38 一致:自己不用选就该在) + if (this.meId > 0) { + this.picked = [this.meId]; + } + this.loadRoster(); + } + + aboutToDisappear(): void { + this.clearSuggestTimer(); + } + + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('movies'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + + private token(): string { + return FB_TOKEN_STORE.get(); + } + + private async loadRoster(): Promise { + try { + this.roster = await FbPersonRepo.inst().list(); + } catch (err) { + hilog.warn(DOMAIN, TAG, '读名册失败(同看人不可选):%{public}s', JSON.stringify(err)); + } + } + + private pickedNames(): string { + const out: string[] = []; + for (const id of this.picked) { + const p: FbRosterPerson | undefined = + this.roster.find((x: FbRosterPerson): boolean => x.id === id); + if (p !== undefined) { + out.push(p.shown !== '' ? p.shown : p.name); + } + } + return out.join('、'); + } + + private cinemaLabel(): string { + if (this.cinemaId <= 0) { + return '不记录影院'; + } + const c: FbCinemaItem | undefined = + this.cinemas.find((x: FbCinemaItem): boolean => x.id === this.cinemaId); + if (c === undefined) { + return '不记录影院'; + } + return c.place !== '' ? (c.place + ' · ' + c.name) : c.name; + } + + private cinemaColor(): string { + const c: FbCinemaItem | undefined = + this.cinemas.find((x: FbCinemaItem): boolean => x.id === this.cinemaId); + return c !== undefined && c.color !== '' ? c.color : ''; + } + + // ============================================================ + // 豆瓣:联想 + 抓取 + // ============================================================ + + private clearSuggestTimer(): void { + if (this.suggestTimer >= 0) { + clearTimeout(this.suggestTimer); + this.suggestTimer = -1; + } + } + + /** + * 片名变了 → 重排一次联想(防抖)。 + * + * ⚠️ 必须 `clearTimeout` 上一次:中文输入法下连续输入会排出一串定时器, + * 不清理就是对豆瓣发一串请求。 + */ + private scheduleSuggest(): void { + this.clearSuggestTimer(); + const q: string = this.title.trim(); + if (q === '') { + this.suggests = []; + this.suggestOpen = false; + return; + } + this.suggestTimer = setTimeout((): void => { + this.suggestTimer = -1; + this.doSuggest(q); + }, FB_SUGGEST_DEBOUNCE_MS); + } + + private async doSuggest(q: string): Promise { + const list: FbDoubanSuggest[] = await this.repo.doubanSuggest(q, this.token()); + // ⚠️ 期间用户可能又改了片名 ⇒ 只有"当前片名 == 这次请求的片名"才渲染 + // (否则会把过期的候选画到新片名下面,看着像联想错了) + if (this.title.trim() !== q) { + return; + } + this.suggests = list; + this.suggestOpen = list.length > 0; + } + + /** 点候选 → 按豆瓣 id 抓(与网页端 `fetchById` 同一条路) */ + private async pickSuggest(it: FbDoubanSuggest): Promise { + this.suggestOpen = false; + await this.fetchDouban(it.id !== undefined ? it.id : '', this.title); + } + + /** 「自动获取」按钮 → 按当前片名抓(与网页端 `fetchBtn` 同一条路) */ + private async fetchByTitle(): Promise { + const q: string = this.title.trim(); + if (q === '') { + this.bad = true; + this.note2 = '先写个片名,再点「自动获取」'; + return; + } + this.suggestOpen = false; + await this.fetchDouban('', q); + } + + /** + * 抓详情 + 海报。 + * + * ⚠️ **只填空白的**(与网页端 `apply()` 完全同一条语义): + * 用户已经写过的简介**不能被豆瓣覆盖** —— 那是他自己记的东西。 + * 但要说清楚"抓到了、没覆盖",否则用户会以为抓取坏了。 + */ + private async fetchDouban(doubanId: string, q: string): Promise { + if (this.fetching) { + return; + } + this.fetching = true; + this.bad = false; + this.note2 = '正在从豆瓣获取…'; + const res: FbDoubanFetch | null = await this.repo.doubanFetch(this.token(), doubanId, q); + this.fetching = false; + if (res === null) { + this.bad = true; + this.note2 = this.repo.lastError !== '' ? this.repo.lastError : '抓取失败,请稍后再试'; + return; + } + // ---- 片名:豆瓣的正规名回填(用户打的可能是简称)---- + if (res.title !== '') { + this.title = res.title; + } + // ---- 简介:只填空白的 ---- + let infoFilled: boolean = false; + const summary: string = res.summary.trim(); + if (summary !== '' && this.info.trim() === '') { + this.info = summary; + infoFilled = true; + } + // ---- 海报 ---- + if (res.posterKey !== '') { + this.doubanPosterKey = res.posterKey; + // 豆瓣海报**优先于**端上选的图?不 —— 用户自己选的更权威。 + // 但如果他还没选,就把抓来的海报当预览(保存时用键,不重传图)。 + } + if (res.meta !== '') { + this.doubanMeta = res.meta; + } + // ---- 结果提示(把"抓到了什么、有没有覆盖"说清楚)---- + if (summary === '' && res.posterKey === '') { + this.bad = false; + this.note2 = '豆瓣上没有这部电影的简介和海报'; + } else if (summary !== '' && !infoFilled) { + this.bad = false; + this.note2 = '已获取。简介里已有你写的内容,没有覆盖它'; + } else { + this.bad = false; + this.note2 = res.posterKey !== '' ? '已获取片名、简介与海报' : '已获取片名与简介(豆瓣没有海报)'; + } + } + + // ============================================================ + // 图片选择(海报 1 张 / 观影照片多张) + // ============================================================ + + /** 选一张海报(系统图片选择器,单选) */ + private async pickPoster(): Promise { + try { + const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); + opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + opts.maxSelectNumber = 1; + const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); + const sel: picker.PhotoSelectResult = await p.select(opts); + if (sel.photoUris.length > 0) { + this.posterUri = sel.photoUris[0]; + // ⚠️ 端上选了图 ⇒ 豆瓣那张键就**不再用了**(否则保存时两个来源打架: + // 服务端会先被 poster_key 设置、再被上传的海报覆盖,白跑一趟) + this.doubanPosterKey = ''; + } + } catch (err) { + hilog.warn(DOMAIN, TAG, '选海报取消/失败:%{public}s', JSON.stringify(err)); + } + } + + /** + * 选观影照片(**可多选** —— 用户明确要求"允许上传多张图片")。 + * + * ⚠️ `maxSelectNumber` 给 9:单次最多 9 张是系统选择器的常见上限, + * 也给用户一个"一次别传太多"的软约束;想传更多可以再点一次 + * (这里是**追加**,不是替换)。 + */ + private async pickPhotos(): Promise { + try { + const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions(); + opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + opts.maxSelectNumber = 9; + const p: picker.PhotoViewPicker = new picker.PhotoViewPicker(); + const sel: picker.PhotoSelectResult = await p.select(opts); + if (sel.photoUris.length > 0) { + this.photoUris = this.photoUris.concat(sel.photoUris); + } + } catch (err) { + hilog.warn(DOMAIN, TAG, '选照片取消/失败:%{public}s', JSON.stringify(err)); + } + } + + /** 删一张已经选好的观影照片 */ + private removePhoto(i: number): void { + const next: string[] = this.photoUris.slice(); + next.splice(i, 1); + this.photoUris = next; + } + + // ============================================================ + // 保存 + // ============================================================ + + private async submit(): Promise { + if (this.saving) { + return; + } + if (this.title.trim() === '') { + this.bad = true; + this.note2 = '片名不能为空'; + return; + } + const token: string = this.token(); + if (token === '') { + this.bad = true; + this.note2 = '还没登录'; + return; + } + // ---- 时间:日期 + 时分拼成服务端认的格式 ---- + const startsAt: string = fbJoinDateTime(this.startDate, this.startTime); + const endsAt: string = fbJoinDateTime(this.endDate, this.endTime); + + const d: FbMovieDraft = { + title: this.title, + startsAt: startsAt, + endsAt: endsAt, + // 🔴 用户:「地点不需要,因为影院里面就有」⇒ 端上不再提供这个入口, + // 但仍把字段带上(服务端 `location` 可空,网页端与历史数据还在用)。 + location: '', + note: this.note, + info: this.info, + cinemaId: this.cinemaId, + attendees: this.picked, + posterKey: this.doubanPosterKey + }; + this.saving = true; + this.bad = false; + this.note2 = '正在保存…'; + + const id: number = await this.repo.createMovie(d, token); + if (id <= 0) { + this.saving = false; + this.bad = true; + this.note2 = this.repo.lastError !== '' ? this.repo.lastError : '保存失败'; + return; + } + + /* ---- 图片:记录已经建好了,逐个传(失败不挡记录)---- + ⚠️ 这一步的顺序很重要:**先建记录再传图**。 + 反过来(先传图再建记录)的话,建记录失败就会留下一堆 + "没人认领的图"(服务端 `/movies//…` 需要 id,孤儿图连删都找不到)。 + ⚠️ 失败**不回滚记录**:一条记录因为一张 12MB 的原图没传上去, + 不该让用户重新填一遍表单(与网页端"图片上传失败不挡保存"同一取舍)。 */ + const warns: string[] = []; + if (this.posterUri !== '') { + this.note2 = '正在上传海报…'; + const e: string = await this.repo.uploadPoster(id, this.posterUri, token); + if (e !== '') { + warns.push('海报:' + e); + } + } + let done: number = 0; + for (const uri of this.photoUris) { + done++; + this.note2 = '正在上传照片 ' + done.toString() + ' / ' + this.photoUris.length.toString() + '…'; + const e: string = await this.repo.uploadPhoto(id, uri, token); + if (e !== '') { + warns.push('第 ' + done.toString() + ' 张照片:' + e); + } + } + + this.saving = false; + if (warns.length > 0) { + // ⚠️ 记录**已经存下了**,所以这里用"已记下这一场,但有 N 项没成功" + // 而不是"保存失败" —— 后者会让用户以为白填了、再填一遍(重复记录)。 + this.bad = true; + this.note2 = '已记下这一场,但' + warns.join(';'); + this.onCreated(); + return; + } + this.note2 = '已记下这一场'; + this.onCreated(); + this.onClose(); + } + + // ============================================================ + // 视图 + // ============================================================ + + build() { + // 🔴 外层必须是 `Stack`:本组件要同时显示**四层** + // (编辑卡片 + 影院选择 + 人员选择 + 日期时间选择),build() 只能有一个根节点。 + Stack({ alignContent: Alignment.Center }) { + // ---- 第 1 层:主卡片 ---- + Column() { + Column() { + FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { + Column({ space: 0 }) { + // ---- 标题栏(统一版式:左标题 + 右 ✕)---- + FbSheetTitleBar({ + title: '记一场电影', + subtitle: '片名和时间必填,其余可以留空', + accent: this.accentColor(), + onClose: (): void => { + this.onClose(); + } + }) + + Divider() + .color(FbColors.line) + .margin({ top: 10, bottom: 4 }) + + // ---- 表单 ---- + Scroll() { + Column({ space: 10 }) { + this.titleField() + if (this.suggestOpen) { + this.suggestList() + } + + FbDateTimeRow({ + label: '开始', + date: this.startDate, + time: this.startTime, + accent: this.accentColor(), + onTap: (): void => { + this.pickIsEnd = false; + this.pickOpen = true; + } + }) + FbDateTimeRow({ + label: '结束', + date: this.endDate, + time: this.endTime, + accent: this.accentColor(), + onTap: (): void => { + this.pickIsEnd = true; + this.pickOpen = true; + } + }) + + this.cinemaField() + this.personField() + this.posterField() + this.photoField() + + this.areaField('简介', '剧情简介(可空,点「自动获取」从豆瓣填)', this.info, + (v: string): void => { + this.info = v; + }) + this.areaField('备注', '想记的话(可空)', this.note, + (v: string): void => { + this.note = v; + }) + } + .width('100%') + .padding({ top: 6, bottom: 4 }) + } + .flexShrink(1) + .width('100%') + .scrollBar(BarState.Off) + .align(Alignment.TopStart) + + // ---- 结果提示 ---- + if (this.note2 !== '') { + Text(this.note2) + .fontSize(FbMetric.fontTiny) + .fontColor(this.bad ? FbColors.red : FbColors.muted) + .width('100%') + .maxLines(3) + .padding({ top: 8 }) + } + + // ---- 底部:保存(关闭统一在右上角 ✕)---- + Row({ space: 10 }) { + Text(this.saving ? '保存中…' : '记下这一场') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(44) + .backgroundColor(this.saving + ? fbAlpha(this.accentColor(), 0.5) : this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.submit(); + }) + } + .width('100%') + .margin({ top: 12 }) + } + .width('100%') + .constraintSize({ maxHeight: '86%' }) + .padding({ left: 18, right: 18, top: 18, bottom: 16 + FB_SHEET_BOTTOM_GAP }) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) + + // ---- 第 2 层:影院选择(用户:「影院都应该是弹框选择」)---- + if (this.cinemaPickOpen) { + FbCinemaPickSheet({ + cinemas: this.cinemas, + selected: this.cinemaId, + accent: this.accentColor(), + onPick: (id: number): void => { + this.cinemaId = id; + this.cinemaPickOpen = false; + }, + onClose: (): void => { + this.cinemaPickOpen = false; + } + }) + } + + // ---- 第 3 层:同看的人(拼音首字母筛选 + 暂存/确定)---- + if (this.personPickOpen) { + FbPersonPickSheet({ + people: this.roster, + selected: this.picked, + title: '选择观影人', + accent: this.accentColor(), + onConfirm: (ids: number[]): void => { + this.picked = ids; + this.personPickOpen = false; + }, + onClose: (): void => { + this.personPickOpen = false; + } + }) + } + + // ---- 第 4 层:日期 + 时间(**与日程同一个组件**)---- + if (this.pickOpen) { + FbDateTimePicker({ + title: this.pickIsEnd ? '选择结束时间' : '选择开始时间', + date: this.pickIsEnd ? this.endDate : this.startDate, + time: this.pickIsEnd ? this.endTime : this.startTime, + accent: this.accentColor(), + onConfirm: (d: string, t: string): void => { + if (this.pickIsEnd) { + this.endDate = d; + this.endTime = t; + } else { + this.startDate = d; + this.startTime = t; + } + this.pickOpen = false; + }, + onCancel: (): void => { + this.pickOpen = false; + } + }) + } + } + .width('100%') + .height('100%') + } + + // ============================================================ + // 各字段 + // ============================================================ + + /** + * 片名(带豆瓣联想 + 「自动获取」)。 + * + * ⚠️ 这是整个表单**唯一**一个"带网络行为"的输入框: + * 边打边联想(防抖 320ms)+ 一键抓简介与海报。 + */ + @Builder + titleField() { + Column({ space: 6 }) { + Row({ space: 8 }) { + Row({ space: 2 }) { + Text('片名') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + Text('*') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.red) + } + .width(48) + .alignItems(VerticalAlign.Center) + + TextInput({ text: this.title, placeholder: '看了哪一部?' }) + .placeholderColor(FbColors.faint) + .placeholderFont({ size: FbMetric.fontTiny }) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(40) + .layoutWeight(1) + .padding({ left: 10, right: 10 }) + .onChange((v: string): void => { + this.title = v; + this.scheduleSuggest(); + }) + + // 「自动获取」:一次把片名/简介/海报都抓回来(与网页端那颗按钮同名同义) + Text(this.fetching ? '获取中' : '自动获取') + .fontSize(FbMetric.fontTiny) + .fontColor(this.fetching ? FbColors.faint : Color.White) + .textAlign(TextAlign.Center) + .height(40) + .padding({ left: 10, right: 10 }) + .backgroundColor(this.fetching ? FbColors.inputBg : this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.fetchByTitle(); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + /** + * 联想候选下拉(片名输入框下面那一段)。 + * + * ⚠️ 每条都要点得到(真机上小文字热区很低)⇒ 行高给 48、整行可点。 + * ⚠️ 缩略图地址已经是 **API 代理形式**(`/api/v1/movies/douban?action=thumb&…`)—— + * 豆瓣有防盗链,客户端直连一律 403;这里补上域名与令牌才能加载。 + */ + @Builder + suggestList() { + Column({ space: 2 }) { + ForEach(this.suggests, (it: FbDoubanSuggest) => { + Row({ space: 10 }) { + if (it.thumb !== undefined && it.thumb !== '') { + Image(fbApiBase() + it.thumb + '&token=' + encodeURIComponent(this.token())) + .width(30) + .height(42) + .borderRadius(3) + .objectFit(ImageFit.Cover) + .alt($r('app.media.fb_ic_movies')) + } + Column({ space: 2 }) { + Text(it.title !== undefined && it.title !== '' ? it.title : '(无片名)') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.suggestMeta(it)) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .height(48) + .padding({ left: 8, right: 8 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.pickSuggest(it); + }) + }, (it: FbDoubanSuggest): string => 'ds_' + (it.id !== undefined ? it.id : '0')) + } + .width('100%') + .constraintSize({ maxHeight: 200 }) + .padding({ left: 56 }) + } + + /** 候选的第二行:副标题 · 年份 · 类型(与网页端拼法一致,只有有值的才出现) */ + private suggestMeta(it: FbDoubanSuggest): string { + const bits: string[] = []; + const sub: string = it.sub_title !== undefined ? it.sub_title : ''; + const title: string = it.title !== undefined ? it.title : ''; + if (sub !== '' && sub !== title) { + bits.push(sub); + } + if (it.year !== undefined && it.year !== '') { + bits.push(it.year); + } + if (it.kind !== undefined && it.kind !== '') { + bits.push(it.kind); + } + return bits.join(' · '); + } + + /** + * 影院(**点开弹框选**)。 + * + * 🔴 用户:「影院都应该是弹框选择,这种选择方式太难找了。」 + * ⇒ 原来那排"横向胶囊"删掉,改成一行可点的框(显示当前选中)+ 弹出列表。 + */ + @Builder + cinemaField() { + Row({ space: 8 }) { + Text('影院') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(48) + + Row({ space: 8 }) { + if (this.cinemaColor() !== '') { + Text('') + .width(12) + .height(12) + .borderRadius(6) + .backgroundColor(this.cinemaColor()) + } + Text(this.cinemaLabel()) + .fontSize(FbMetric.fontSmall) + .fontColor(this.cinemaId > 0 ? FbColors.ink : FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text('选择') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + .layoutWeight(1) + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.cinemaPickOpen = true; + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + /** + * 同看的人(**点开弹框选**,带拼音首字母筛选)。 + * + * 🔴 用户:「同看的人也应该是弹框选择,这种选择方式太难找了。」 + * ⇒ 原来那排"只列前 40 个的横向头像"删掉 —— 名册上千人, + * 要找的人排在后面就永远找不到(这正是用户说的"太难找")。 + */ + @Builder + personField() { + Row({ space: 8 }) { + Text('同看的人') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(48) + + Row({ space: 8 }) { + Text(this.picked.length > 0 + ? ('已选 ' + this.picked.length.toString() + ' 人:' + this.pickedNames()) + : '还没有选人') + .fontSize(FbMetric.fontSmall) + .fontColor(this.picked.length > 0 ? FbColors.ink : FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + .flexShrink(1) + Text('选择') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + .layoutWeight(1) + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + if (this.roster.length === 0) { + this.bad = true; + this.note2 = '名册还没同步,同看人稍后可以在网页端补'; + return; + } + this.personPickOpen = true; + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + /** + * 海报(1 张;可从豆瓣抓、也可自己选)。 + * + * 🔴 用户:「允许上传多张图片,**包括海报**。」 + */ + @Builder + posterField() { + Column({ space: 6 }) { + Row({ space: 8 }) { + Text('海报') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(48) + + Row({ space: 8 }) { + if (this.posterUri !== '') { + // 端上自己选的(本地文件,直接可显示) + Image(this.posterUri) + .width(42) + .height(60) + .borderRadius(4) + .objectFit(ImageFit.Cover) + Text('已选 1 张') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + .layoutWeight(1) + } else if (this.doubanPosterKey !== '') { + // 豆瓣抓来的(走 media.php 代理,补域名 + 令牌) + Image(fbImageUrl(this.doubanPosterKey, this.token())) + .width(42) + .height(60) + .borderRadius(4) + .objectFit(ImageFit.Cover) + Column({ space: 2 }) { + Text('豆瓣海报') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.ink) + Text('保存后生效') + .fontSize(10) + .fontColor(FbColors.faint) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } else { + Text('还没有海报') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .layoutWeight(1) + } + + Text(this.posterUri !== '' || this.doubanPosterKey !== '' ? '换一张' : '上传') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + .onClick((): void => { + this.pickPoster(); + }) + } + .layoutWeight(1) + .height(60) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + Text('海报可以点「自动获取」由豆瓣带回来,也可以自己传一张(竖版效果最好)') + .fontSize(10) + .fontColor(FbColors.faint) + .padding({ left: 56 }) + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + /** + * 观影照片(**多张**)。 + * + * 🔴 用户:「允许上传多张图片,包括海报。」 + * ⇒ 可一次选多张、也可以多次追加;已选的以缩略图网格列出,可逐张删。 + */ + @Builder + photoField() { + Column({ space: 6 }) { + Row({ space: 8 }) { + Text('观影照片') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(48) + + Row({ space: 8 }) { + Text(this.photoUris.length > 0 + ? ('已选 ' + this.photoUris.length.toString() + ' 张') + : '票根、合影、随手拍都可以放这儿') + .fontSize(FbMetric.fontSmall) + .fontColor(this.photoUris.length > 0 ? FbColors.ink : FbColors.muted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text('选照片') + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + } + .layoutWeight(1) + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .alignItems(VerticalAlign.Center) + .onClick((): void => { + this.pickPhotos(); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + if (this.photoUris.length > 0) { + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.photoUris, (uri: string, i: number) => { + Stack({ alignContent: Alignment.TopEnd }) { + Image(uri) + .width(60) + .height(60) + .borderRadius(4) + .objectFit(ImageFit.Cover) + Text('✕') + .fontSize(10) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .width(18) + .height(18) + .backgroundColor('#B0000000') + .borderRadius(9) + .margin({ top: 2, right: 2 }) + .onClick((): void => { + this.removePhoto(i); + }) + } + .width(60) + .height(60) + .margin({ right: 8, bottom: 8 }) + }, (uri: string, i: number): string => 'ph_' + i.toString() + '_' + uri) + } + .width('100%') + .padding({ left: 56 }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + /** 一行多行文本输入(简介 / 备注) */ + @Builder + areaField(label: string, placeholder: string, value: string, onChange: (v: string) => void) { + Row({ space: 8 }) { + Text(label) + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width(48) + + TextArea({ text: value, placeholder: placeholder }) + .placeholderColor(FbColors.faint) + .placeholderFont({ size: FbMetric.fontTiny }) + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.ink) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .height(72) + .layoutWeight(1) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .onChange((v: string): void => { + onChange(v); + }) + } + .width('100%') + .alignItems(VerticalAlign.Top) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/FbPersonPickSheet.ets b/entry/src/main/ets/components/FbPersonPickSheet.ets new file mode 100644 index 0000000..6daf08d --- /dev/null +++ b/entry/src/main/ets/components/FbPersonPickSheet.ets @@ -0,0 +1,377 @@ +/** + * 家书 FamilyBook · 「选择同看的人」弹框(鸿蒙端) + * + * 🔴 v64(用户 2026-10-19)原话: + * 「**同看的人也应该是弹框选择**,这种选择方式太难找了。而且这个弹框选择要跟 + * 网页端的一样,**在上面有个筛选框,就是当输入人的拼音的首字母的时候, + * 可以把这个人给筛选出来**。」 + * + * ⇒ 观影「记一场」原来把同看人做成一排**只列前 40 个的横向头像**(名册上千人, + * 要找的人排在后面就永远找不到)。网页端(`templates/_person_picker.php`)是: + * **点「+ 添加观影人」→ 弹窗 → 顶部搜索框(名字 / 拼音首字母)→ + * 点人头像进"暂存区"(弹窗不关)→ 全选完点「确定」写回。** + * 这里按同一形态做,并保留网页端的两条关键语义: + * + * ① **暂存 + 确定**(v24 第 18 批用户明确要求的两段式): + * 点一个人 = 进弹窗上半部分的暂存区、**弹窗不关**、可以接着搜下一个; + * 全部选完点「确定」才写回。旧行为(点一个 = 立刻生效并关窗)选 5 个人 + * 要开合 5 次 —— 这正是用户这次说"太难找了"的由来之一。 + * ② **"输入即筛选"用拼音首字母的子序列匹配**: + * 「杨永全」的首字母是 `yyq`,用户很自然会打 `y` / `yy` / `yq` —— + * 用**子序列**(不是前缀、不是子串)才都能命中。 + * ⚠️ 与名册页(`FbPeoplePage.ets::isSubsequence`)和服务端 + * (`Pinyin::isSubsequence`)**必须同一套规则** —— + * 否则会出现"本地搜得到、服务端搜不到"这种最难查的差异。 + * + * ⚠️ 默认带上"我"(与网页端 v38 同口径): + * 网页端新增观影时会把登录者**预置进已选**("自己不用选择就应该已经选上了")。 + * 这里由调用方(`FbMovieNewSheet`)把 `selected` 的初值设成 `[meId]`, + * 本组件只负责"把宿主给的那份当初始暂存"——这样"取消"才能还原成调用方的原值。 + */ + +import { FbColors, FbMetric, FB_MASK, fbAlpha, fbTint, FbTint } from '../common/Theme'; +import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; +import { FbRosterPerson } from '../data/FbPersonRepo'; +import { FbAvatar } from './Widgets'; +import { FbSheetRing } from './FbSheetRing'; +import { FbSheetClose } from './FbSheetClose'; +import { fbImageSrc } from '../data/FbAttachQueue'; +import { FB_TOKEN_STORE } from '../data/FbTokenStore'; + +@ComponentV2 +export struct FbPersonPickSheet { + /** 候选人(名册) */ + @Param people: FbRosterPerson[] = []; + /** 打开时已选的人(= 调用方当前的值;点「确定」才写回) */ + @Param selected: number[] = []; + /** 标题(「选择观影人」/「选择同行人」…) */ + @Param title: string = '选择'; + /** 环色(hex,'' = 观影模块色) */ + @Param accent: string = ''; + + /** 「确定」:把暂存的人写回 */ + @Event onConfirm: (ids: number[]) => void = (): void => { + }; + /** 关掉(不改动调用方的值) */ + @Event onClose: () => void = (): void => { + }; + + /** 搜索词(名字 / 拼音首字母) */ + @Local keyword: string = ''; + /** + * **暂存区**(已经点了头像但还没点确定的那批人)。 + * + * ⚠️ 与网页端的关键取舍相同:这里就是"唯一的一份选择状态"。 + * 网页端把暂存挂在 `input.checked` 上而不是另存数组,是为了避免 + * "视觉状态与实际提交状态各一份、必然飘";端上没有表单提交这一步, + * 所以 `picked` 数组本身就是那份唯一状态 —— 但**绝不能**在点击时同步改 + * 调用方的 `selected`(那是"点一个就生效",用户明确不要)。 + */ + @Local picked: number[] = []; + + aboutToAppear(): void { + // 初始暂存 = 调用方给的那份(浅拷贝一份,避免直接改 @Param) + this.picked = this.selected.slice(); + } + + private accentColor(): string { + if (this.accent !== '') { + return this.accent; + } + const m: FbModuleDef | undefined = fbModuleOf('movies'); + return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; + } + + /** 显示名(昵称优先,与名册列表一致) */ + private nameOf(p: FbRosterPerson): string { + return p.shown !== '' ? p.shown : p.name; + } + + /** + * 命中判断:名字子串 **或** 拼音首字母子序列。 + * + * ⚠️ 与服务端 `Pinyin::initials`(把 `name + my_nickname` 拼起来算)同一来源, + * 所以 `p.initials` 这个串里同时含本名与昵称的首字母 —— 直接比即可。 + * ⚠️ 只对**纯字母**查询词做子序列:用户输中文/数字时跟首字母比毫无意义 + * (`indexOf` 一律 -1),白白拖慢列表。 + */ + private matched(p: FbRosterPerson): boolean { + const k: string = this.keyword.trim().toLowerCase(); + if (k === '') { + return true; + } + const name: string = this.nameOf(p).toLowerCase(); + if (name.indexOf(k) >= 0) { + return true; + } + if (p.name.toLowerCase().indexOf(k) >= 0) { + return true; + } + if (/^[a-z]+$/.test(k)) { + return isSubsequence(k, p.initials.toLowerCase()); + } + return false; + } + + private filtered(): FbRosterPerson[] { + return this.people.filter((p: FbRosterPerson): boolean => this.matched(p)); + } + + /** 暂存区里那批人的对象(用来画 chips) */ + private staged(): FbRosterPerson[] { + const out: FbRosterPerson[] = []; + for (const id of this.picked) { + const p: FbRosterPerson | undefined = + this.people.find((x: FbRosterPerson): boolean => x.id === id); + if (p !== undefined) { + out.push(p); + } + } + return out; + } + + /** 点头像:已在暂存里就移除,否则加入(**不关弹窗**) */ + private toggle(id: number): void { + const i: number = this.picked.indexOf(id); + if (i >= 0) { + // ⚠️ 用新数组赋值:`@Local` 只代理第一层,splice 不触发刷新 + const next: number[] = this.picked.slice(); + next.splice(i, 1); + this.picked = next; + return; + } + this.picked = this.picked.concat([id]); + } + + build() { + Column() { + 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(this.title) + .fontSize(FbMetric.fontTitle) + .fontWeight(FontWeight.Medium) + .fontColor(FbColors.ink) + .maxLines(1) + 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) + + // ---- 搜索框(名字 / 拼音首字母)---- + 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: '名字或拼音首字母,如 yyq 找「杨永全」' }) + .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) + + /* ---- 暂存区(用户点过头像、还没点确定的那批人)---- + ⚠️ 这正是用户要的"暂存到选择框的上半部分": + 它让"已经选了谁"始终可见,也不逼用户一次选完。 */ + if (this.picked.length > 0) { + Column({ space: 6 }) { + Row({ space: 6 }) { + Text('已选 ') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + Text(this.picked.length.toString() + ' 人') + .fontSize(FbMetric.fontTiny) + .fontWeight(FontWeight.Medium) + .fontColor(this.accentColor()) + } + .width('100%') + + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.staged(), (p: FbRosterPerson) => { + Row({ space: 4 }) { + Text(this.nameOf(p)) + .fontSize(FbMetric.fontTiny) + .fontColor(this.accentColor()) + .maxLines(1) + Text('✕') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + } + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .margin({ right: 6, bottom: 6 }) + .backgroundColor(fbAlpha(this.accentColor(), 0.10)) + .borderRadius(FbMetric.radiusPill) + .onClick((): void => { + this.toggle(p.id); + }) + }, (p: FbRosterPerson): string => 'st_' + p.id.toString()) + } + .width('100%') + } + .width('100%') + .padding({ top: 2 }) + .alignItems(HorizontalAlign.Start) + } + + // ---- 候选人网格 ---- + Scroll() { + Column() { + if (this.people.length === 0) { + Text('名册还没同步,同看的人稍后可以在网页端补') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 24, bottom: 24 }) + } else if (this.filtered().length === 0) { + Text('没有匹配的人') + .fontSize(FbMetric.fontTiny) + .fontColor(FbColors.faint) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 24, bottom: 24 }) + } else { + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(this.filtered(), (p: FbRosterPerson) => { + Column({ space: 3 }) { + FbAvatar({ + name: this.nameOf(p), + color: p.avatarColor !== '' ? p.avatarColor : '#A84A28', + dia: 44, + fontSizeVp: 17, + imageUrl: p.avatar !== '' + ? fbImageSrc(p.avatar, FB_TOKEN_STORE.get()) : '' + }) + Text(this.nameOf(p)) + .fontSize(10) + .fontColor(this.picked.indexOf(p.id) >= 0 + ? this.accentColor() : FbColors.faint) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width(52) + .textAlign(TextAlign.Center) + } + // ⚠️ 选中态用**一圈主题色描边**(不是换底色 —— 底色是头像自己的色) + .padding(3) + .margin({ right: 6, bottom: 8 }) + .borderRadius(26) + .border({ + width: this.picked.indexOf(p.id) >= 0 ? 2 : 0, + color: this.accentColor() + }) + .onClick((): void => { + this.toggle(p.id); + }) + }, (p: FbRosterPerson): string => 'pp_' + p.id.toString()) + } + .width('100%') + } + } + .width('100%') + } + .constraintSize({ maxHeight: 260 }) + .width('100%') + .scrollBar(BarState.Auto) + .align(Alignment.TopStart) + + // ---- 底部:确定(关闭统一在右上角 ✕)---- + Row({ space: 10 }) { + Text('清除') + .fontSize(FbMetric.fontSmall) + .fontColor(FbColors.muted) + .textAlign(TextAlign.Center) + .height(42) + .padding({ left: 16, right: 16 }) + .backgroundColor(FbColors.inputBg) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.picked = []; + }) + + Text('确定') + .fontSize(FbMetric.fontSmall) + .fontColor(Color.White) + .textAlign(TextAlign.Center) + .layoutWeight(1) + .height(42) + .backgroundColor(this.accentColor()) + .borderRadius(FbMetric.radiusS) + .onClick((): void => { + this.onConfirm(this.picked.slice()); + }) + } + .width('100%') + } + .width('100%') + .padding({ left: 18, right: 18, top: 18, bottom: 18 }) + .alignItems(HorizontalAlign.Start) + } + } + .width('92%') + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .hitTestBehavior(HitTestMode.Transparent) + } +} + +/** + * `q` 是否是 `s` 的**子序列**。 + * + * 🔴 与服务端 `Pinyin::isSubsequence`、名册页 `isSubsequence()` **同一算法** —— + * 三处必须是同一套规则,否则会出现"本地搜得到、服务端搜不到"这种最难查的差异。 + * + * 例:`isSubsequence('yq', 'yyq') === true`(跳过第二个 y);`'qy'` 则为 false。 + */ +function isSubsequence(q: string, s: string): boolean { + if (q === '') { + return true; + } + let i: number = 0; + for (let j: number = 0; j < s.length && i < q.length; j++) { + if (s.charAt(j) === q.charAt(i)) { + i++; + } + } + return i === q.length; +} \ No newline at end of file diff --git a/entry/src/main/ets/data/FbMovieRepo.ets b/entry/src/main/ets/data/FbMovieRepo.ets index 59c1c3b..8bd8081 100644 --- a/entry/src/main/ets/data/FbMovieRepo.ets +++ b/entry/src/main/ets/data/FbMovieRepo.ets @@ -20,9 +20,12 @@ */ import { relationalStore } from '@kit.ArkData'; +import { http } from '@kit.NetworkKit'; +import { fileIo } from '@kit.CoreFileKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; import { FbDb, FbRow } from './FbDb'; -import { FbApiOptions, FbApiResult, fbApi } from './FbHttp'; +import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from './FbHttp'; import { FB_TOKEN_STORE } from './FbTokenStore'; const DOMAIN: number = 0x0000; @@ -136,6 +139,65 @@ export interface FbMovieYearStat { cnt?: number; } +/** + * 「记一场」表单填出来的草稿(→ `createMovie`)。 + * + * ⚠️ 字段名与 `createMovie` 的请求体一一对应(那里再映射成服务端的下划线命名)。 + */ +export interface FbMovieDraft { + /** 片名(必填) */ + title: string; + /** 开始时间 `YYYY-MM-DD HH:MM`(必填;服务端会补秒) */ + startsAt: string; + /** 结束时间(可空) */ + endsAt: string; + /** 地点(服务端字段是 `location`;可空) */ + location: string; + /** 备注(可空) */ + note: string; + /** 剧情简介(可空) */ + info: string; + /** 影院 id(0 = 没选) */ + cinemaId: number; + /** 同看人的 person id 列表 */ + attendees: number[]; + /** + * 海报存储键(`posters/xxx.jpg`)。 + * + * 🔴 v64:**豆瓣抓取来的海报**走的不是"上传文件"那条路 —— + * 服务端抓取时已经把图落盘了,返回一个键;端上保存记录时把它带上即可 + * (与网页端隐藏字段 `poster_key` 完全同一条路,见 `movies/create` 路由)。 + * ⚠️ 端上自己选的图片则走 `uploadPoster()` 单独上传(记录建完之后), + * 两种来源**不会同时用**:抓来的那份只有键、端上选的那份是字节。 + */ + posterKey: string; +} + +/** 豆瓣片名联想的一条候选(`/movies/douban?action=suggest`) */ +export interface FbDoubanSuggest { + id?: string; + title?: string; + sub_title?: string; + year?: string; + kind?: string; + /** 缩略图地址(**已是 API 代理形式**,端上补域名 + token 即可加载) */ + thumb?: string; +} + +/** 豆瓣抓取的结果(`/movies/douban`) */ +export interface FbDoubanFetch { + /** 豆瓣正规片名(回填输入框) */ + title: string; + /** **服务端拼好的**简介全文(含导演/主演/年份/类型/片长/国家/评分 + 剧情) */ + summary: string; + /** 已落盘的海报存储键(`posters/xxx.jpg`;没抓到就是空串) */ + posterKey: string; + /** 海报可访问地址(预览用) */ + posterUrl: string; + /** 豆瓣元数据 JSON 串(原样存进 `douban_meta`,详情页会另有一块展示) */ + meta: string; +} + /** `/api/v1/movies/stats` 的完整载荷 */ export interface FbMovieStats { overview?: FbMovieOverview; @@ -658,6 +720,81 @@ export class FbMovieRepo { return out; } + /** + * **新建一场观影**(用户 2026-10-19:「电影新增一场、新增一个电影, + * 也就是说"记一场"这个功能现在可以开始做了。」) + * + * 🔴 为什么必须联网、且**不等同步**: + * 这是"写"操作 —— 写进去才算数。接口成功后**立刻重拉列表**回写本地库, + * 否则用户刚记的那场要等下次整批同步才出现(看着像没保存)。 + * + * ⚠️ 字段名必须与服务端 `ApiHandler::movieData()` 逐字一致: + * `title` / `starts_at` / `ends_at` / `location` / `note` / `info` / + * `cinema_id` / `attendees`(int[])。 + * ⚠️ `starts_at` 服务端走 `Api::dt()`,要 `YYYY-MM-DD HH:MM:SS` 或 + * `YYYY-MM-DD HH:MM`(端上拼第二种,服务端会补秒)。 + * + * @returns 新建成功返回新 id(>0);失败返回 0,原因见 `lastError` + */ + async createMovie(d: FbMovieDraft, token: string): Promise { + if (token === '') { + this.lastError = '还没登录'; + return 0; + } + if (d.title.trim() === '') { + this.lastError = '片名不能为空'; + return 0; + } + if (d.startsAt.trim() === '') { + this.lastError = '观影时间不能为空'; + return 0; + } + /* ⚠️ 请求体要用**显式声明的 interface**(`arkts-no-untyped-obj-literals`)—— + 这里用 `Record` 逐键赋值(与 `FbCalRepo.toBody` 同一写法)。 */ + const body: Record = {}; + body['title'] = d.title.trim(); + body['starts_at'] = d.startsAt.trim(); + body['ends_at'] = d.endsAt.trim(); + body['location'] = d.location.trim(); + body['note'] = d.note.trim(); + body['info'] = d.info.trim(); + body['cinema_id'] = d.cinemaId; + body['attendees'] = d.attendees; + /* 🔴 v64:豆瓣抓来的海报键随记录一起提交(与网页端隐藏字段同一条路)。 + ⚠️ 只有非空才发 —— 发空串会**清掉**已有的海报(服务端的 `movieData` 认 + "字段存在"为"要改")。 */ + if (d.posterKey !== '') { + body['poster_key'] = d.posterKey; + } + + const opts: FbApiOptions = { method: 'POST', token: token, body: body }; + const r: FbApiResult = await fbApi('/api/v1/movies', opts); + if (!r.ok) { + this.lastError = r.error !== '' ? r.error : '保存失败'; + return 0; + } + let newId: number = 0; + try { + const o: Record = JSON.parse(r.data) as Record; + const v: Object | undefined = o['id']; + newId = typeof v === 'number' ? v : 0; + } catch (err) { + hilog.warn(DOMAIN, TAG, '新建观影回包解析失败:%{public}s', JSON.stringify(err)); + } + this.lastError = ''; + // 🔴 立刻重拉列表回写本地库(见方法说明) + await this.sync(token); + return newId; + } + + /** + * 上一次写操作的错误原因('' = 成功)。 + * + * ⚠️ 不用"返回 string 错误 + 用 id 表示成功"那种混合签名: + * `createMovie` 要**返回新 id**,错误就只好另找地方放(本项目在别处踩过)。 + */ + lastError: string = ''; + /** * 新建 / 修改一家影院(**直接打接口,不等同步**)。 * @@ -810,6 +947,127 @@ export class FbMovieRepo { } } + /* ============================================================ + 🔴 v64:豆瓣自动补充(片名联想 / 一键抓取简介 + 海报) + ============================================================ */ + + /** + * 片名联想(`/api/v1/movies/douban?action=suggest&q=…`)。 + * + * 🔴 用户 2026-10-19:「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。 + * 这个在网页端是直接实现了的,我们的手机端也必须同步实现。」 + * + * ⚠️ 与网页端**同一个上游**(`App\Douban::suggest`),候选的顺序、过滤影人、 + * 限量 8 条的规则完全一致 —— 两边看到的候选不该有差别。 + * ⚠️ 失败**静默**(返回空数组):联想是"打字时的辅助",弹个错误框会 + * 打断输入;真正的错误在用户点「自动获取」时给(与网页端同一取舍)。 + * + * @returns 候选列表(最多 8 条;失败或没搜到都是空数组) + */ + async doubanSuggest(q: string, token: string): Promise { + const kw: string = q.trim(); + if (kw === '') { + return []; + } + const query: Record = {}; + query['action'] = 'suggest'; + query['q'] = kw; + const r: FbApiResult = await fbApi('/api/v1/movies/douban', + { method: 'GET', token: token, query: query }); + if (!r.ok) { + hilog.warn(DOMAIN, TAG, '豆瓣联想失败(静默):%{public}s', r.error); + return []; + } + try { + const o: Record = JSON.parse(r.data) as Record; + const arr: Object[] | undefined = o['items'] as Object[] | undefined; + return arr !== undefined && Array.isArray(arr) ? (arr as FbDoubanSuggest[]) : []; + } catch (err) { + hilog.warn(DOMAIN, TAG, '豆瓣联想解析失败:%{public}s', JSON.stringify(err)); + return []; + } + } + + /** + * 抓取详情 + 简介 + 海报(`/api/v1/movies/douban?id=<豆瓣id>` 或 `?q=<片名>`)。 + * + * 🔴 与网页端 `apply()` 的契约**逐条对齐**: + * · `summary` 是**服务端拼好的**简介全文(含导演/主演/年份/类型/片长/国家/评分 + * + 剧情原文)—— 端上不自己拼,否则两边写进库的文本会不一样; + * · `poster_key` 是**已经落盘**的海报存储键(服务端走 poster 槽位管线裁成 2:3), + * 端上保存记录时把它当 `poster_key` 一起提交即可,不需要自己再传文件。 + * + * ⚠️ 只填空白的(与网页端同一语义):调用方拿到结果后,`info` 已有内容就不覆盖。 + * + * @returns null = 失败(原因见 `lastError`) + */ + async doubanFetch(token: string, doubanId: string, q: string): Promise { + if (token === '') { + this.lastError = '还没登录'; + return null; + } + const query: Record = {}; + if (doubanId !== '') { + query['id'] = doubanId; + } else if (q.trim() !== '') { + query['q'] = q.trim(); + } else { + this.lastError = '先写个片名'; + return null; + } + const r: FbApiResult = await fbApi('/api/v1/movies/douban', + { method: 'GET', token: token, query: query }); + if (!r.ok) { + this.lastError = r.error !== '' ? r.error : '抓取失败,请稍后再试'; + return null; + } + try { + const o: Record = JSON.parse(r.data) as Record; + const out: FbDoubanFetch = { + title: strField(o, 'title'), + summary: strField(o, 'summary'), + posterKey: strField(o, 'poster_key'), + posterUrl: strField(o, 'poster'), + meta: o['meta'] !== undefined ? JSON.stringify(o['meta']) : '' + }; + this.lastError = ''; + return out; + } catch (err) { + hilog.warn(DOMAIN, TAG, '豆瓣抓取解析失败:%{public}s', JSON.stringify(err)); + this.lastError = '返回内容异常'; + return null; + } + } + + /** + * 传一张**海报**(`POST /api/v1/movies//poster`,multipart 字段 `poster`)。 + * + * 🔴 用户 2026-10-19:「**允许上传多张图片,包括海报。**」 + * + * ⚠️ 与服务端的分工:裁剪(2:3)、缩放、转 JPEG 全在**服务端**做 + * (`Upload::image` 的 poster 槽位)—— 端上只负责把原图字节送上去。 + * 端上自己裁会让"绕过端上(改造过的客户端)"存进非 2:3 的图。 + * + * @returns 空串 = 成功;否则是错误说明 + */ + async uploadPoster(id: number, uri: string, token: string): Promise { + return uploadOne('/api/v1/movies/' + id.toString() + '/poster', 'poster', uri, token); + } + + /** + * 传**观影照片**(`POST /api/v1/movies//photos`,multipart 字段 `photo`)。 + * + * ⚠️ 一张一张传(而不是一次拼多张 multipart): + * · 每张的成败独立 —— 一张 12MB 的原图失败,不该把已经成功的几张一起回滚; + * · 端上进度好做(传了三张就显示"3 / 5"); + * · 服务端那次是**追加**语义(`Movie::addPhotos`),多次调用结果与一次相同。 + * + * @returns 空串 = 成功;否则是错误说明 + */ + async uploadPhoto(id: number, uri: string, token: string): Promise { + return uploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token); + } + async watermark(table: string): Promise { const row: FbRow | null = await this.db.queryOne( 'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]); @@ -1059,6 +1317,106 @@ function strOf(v: string | null | undefined): string { function numOf(v: number | null | undefined): number { return v !== null && v !== undefined ? v : 0; } + +/** + * 从 JSON 对象里安全取一个字符串字段(非字符串 → 空串)。 + * + * ⚠️ 与 `strOf` 的分工:`strOf` 处理"值本身可能 null/undefined", + * 这个处理"**值可能是任意类型**"(如 `meta` 是对象、`id` 是数字)。 + * 豆瓣那条接口的 `meta` 是嵌套对象,硬当字符串读会崩。 + */ +function strField(o: Record, key: string): string { + const v: Object | undefined = o[key]; + return typeof v === 'string' ? v : ''; +} + +/** + * 上传一个文件到某个观影子资源(**手工拼 multipart**)。 + * + * 🔴 与 `FbPersonEditSheet.uploadAvatar` 是同一套写法(HarmonyOS 的 `http` + * 没有现成的 multipart 封装,得手写 boundary 与分片)。 + * ⚠️ 抽到这里而不是在表单里再写一遍:那个方法是 `private` 且绑定了 + * "头像"这个字段名/URL,观影这边是两个不同端点、两个字段名 —— + * 复制一份必然漂移(本项目为"两份实现漂移"栽过好几次)。 + * + * ⚠️ 顺序必须是 head(字符串) → 文件(ArrayBuffer) → tail(字符串): + * 服务端按这个顺序解析出一个分片。 + * ⚠️ `filename` 给固定值(`upload.jpg`):服务端**不信客户端文件名** + * (`Upload::image` 自己生成随机名 + finfo 嗅探真实 MIME), + * 这里给什么都不影响结果 —— 但必须给,否则 multipart 头不合法。 + * + * @param path 接口路径(如 `/api/v1/movies/12/photos`) + * @param field 表单字段名(`poster` / `photo`,**必须与服务端读的一致**) + * @param uri picker 给的文件 URI(`file://...`) + * @returns 空串 = 成功;否则是错误说明 + */ +async function uploadOne(path: string, field: string, uri: string, token: string): Promise { + const base: string = fbApiBase(); + if (base === '') { + return '还没有设置服务器地址'; + } + if (token === '') { + return '还没登录'; + } + let buf: ArrayBuffer; + try { + const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); + const stat: fileIo.Stat = fileIo.statSync(file.fd); + buf = new ArrayBuffer(stat.size); + fileIo.readSync(file.fd, buf); + fileIo.closeSync(file); + } catch (err) { + hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err)); + return '读不出这张图'; + } + + const boundary: string = '----FbMovie' + Date.now().toString(); + const head: string = '--' + boundary + '\r\n' + + 'Content-Disposition: form-data; name="' + field + '"; filename="upload.jpg"\r\n' + + 'Content-Type: image/jpeg\r\n\r\n'; + const tail: string = '\r\n--' + boundary + '--\r\n'; + + const req: http.HttpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await req.request(base + path, { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'multipart/form-data; boundary=' + boundary, + 'Authorization': 'Bearer ' + token, + 'Accept': 'application/json' + }, + extraData: [head, buf, tail], + connectTimeout: 20000, + // ⚠️ 读超时给足 60 秒:手机原图可能十几 MB,服务端还要裁/缩/转码, + // 15 秒(业务请求那个值)在这里会**假性失败** —— 图其实存下来了, + // 用户却看到"上传失败",再点一次就变成两张。 + readTimeout: 60000, + expectDataType: http.HttpDataType.STRING + }); + if (resp.responseCode >= 200 && resp.responseCode < 300) { + return ''; + } + const body: string = typeof resp.result === 'string' ? resp.result : ''; + // 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话 + let msg: string = 'HTTP ' + resp.responseCode.toString(); + try { + const o: Record = JSON.parse(body) as Record; + const e: Object | undefined = o['error']; + if (typeof e === 'string' && e !== '') { + msg = e; + } + } catch (err) { + // 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码 + } + return msg; + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e)); + return '网络异常'; + } finally { + req.destroy(); + } +} // ============================================================ // 观影详情的解析 // ============================================================ diff --git a/entry/src/main/ets/model/FbSettingsMenus.ets b/entry/src/main/ets/model/FbSettingsMenus.ets index bf1c8a6..cd335e3 100644 --- a/entry/src/main/ets/model/FbSettingsMenus.ets +++ b/entry/src/main/ets/model/FbSettingsMenus.ets @@ -17,11 +17,21 @@ import { FbSettingItem } from '../components/FbModuleSettingsSheet'; /** 观影(对应网页版 templates/movies/_settings_menu.php) */ +/** + * 观影设置菜单。 + * + * 🔴 v63(用户 2026-10-19):**影院管理与搜索/统计/年报是平级的四个入口**, + * 「影院管理就是管理影院,不要把统计搜索和年报放在这里面」。 + * ⇒ 四条各有一个 `action`(v62 时后三条是空串 = 点了没反应)。 + * ⚠️ 「高级搜索」与「统计」在原话里是**连着说的**("高级搜索统计"), + * 这里仍**拆成两条**:它们是两个不同页面(搜索给结果列表、统计给图), + * 合在一条里用户点进去只能看见一个,另一个就永远进不去了。 + */ export const FB_SET_MOVIES: FbSettingItem[] = [ - { icon: 'movies', label: '影院管理', hint: '看过的影院,按名字排', action: 'cinema' }, - { icon: 'search', label: '高级搜索', hint: '按年份 / 类型 / 评分筛', action: '' }, - { icon: 'chart', label: '统计', hint: '每年看了多少、类型分布', action: '' }, - { icon: 'notes', label: '年度报告', hint: '一句话总结这一年', action: '' } + { icon: 'movies', label: '影院管理', hint: '看过的影院:新增 / 修改 / 删除', action: 'cinema' }, + { icon: 'search', label: '高级搜索', hint: '片名 / 影院 / 日期组合查', action: 'moviesearch' }, + { icon: 'chart', label: '统计', hint: '看过多少、按影院和年份的分布', action: 'moviestats' }, + { icon: 'notes', label: '年度报告', hint: '这一年你看了些什么', action: 'moviereport' } ]; /** 读书(templates/books/_settings_menu.php) */ diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets index b3c243b..678a591 100644 --- a/entry/src/main/ets/pages/movies/FbMoviesPage.ets +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -31,6 +31,8 @@ import { FbDb } from '../../data/FbDb'; import { FbMovieDetail, FbMovieListItem, FbMovieRepo, FbSyncResult } from '../../data/FbMovieRepo'; import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet'; import { FbCinemaSheet } from '../../components/FbCinemaSheet'; +import { FbMovieNewSheet } from '../../components/FbMovieNewSheet'; +import { FbMovieInsightSheet } from '../../components/FbMovieInsightSheet'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FB_BACK } from '../../common/FbBack'; import { FB_SET_MOVIES } from '../../model/FbSettingsMenus'; @@ -131,6 +133,25 @@ export struct FbMoviesPage { @Local settingsOpen: boolean = false; /** 影院浮层是否打开 */ @Local cinemaSheetOpen: boolean = false; + /** + * 「记一场」新建浮层是否打开。 + * + * 🔴 v63(用户 2026-10-19):「电影新增一场、新增一个电影,也就是说 + * "记一场"这个功能现在可以开始做了。」 + * ⇒ 那颗按钮原来**没有回调**(纯摆设),点了什么都不发生。 + */ + @Local newOpen: boolean = false; + /** + * 高级搜索 / 统计 / 年度报告浮层是否打开。 + * + * 🔴 v63:这三项**从「影院管理」里搬出来**,成为设置菜单里的独立入口 + * (用户:「影院管理就是管理影院,不要把统计搜索和年报放在这里面」)。 + * ⚠️ 用**一个** open 位 + 一个 mode 串(三者共用一个组件, + * 见 `FbMovieInsightSheet` 的说明),不开三个 boolean。 + */ + @Local insightOpen: boolean = false; + /** `search` / `stats` / `report`(配合 `insightOpen`) */ + @Local insightMode: string = 'stats'; /** * 🔴 浮层开合时**让底部悬浮栏让位**(与名册页同一处 bug,见 `FbPeoplePage` 的说明)。 @@ -139,10 +160,11 @@ export struct FbMoviesPage { * ⇒ 悬浮栏层级更高 ⇒ 浮层底部的按钮会与它重叠、点击被抢走。 * ⇒ 用 `@Monitor` 一处声明、自动跟随派生(避免在多个赋值点手写、漏一处就出事)。 */ - @Monitor('sheetOpen', 'cinemaSheetOpen', 'settingsOpen') + @Monitor('sheetOpen', 'cinemaSheetOpen', 'settingsOpen', 'newOpen', 'insightOpen') onSheetChanged(): void { const app: FbAppState = fbAppState(); - app.sheetCount = (this.sheetOpen || this.cinemaSheetOpen || this.settingsOpen) ? 1 : 0; + app.sheetCount = (this.sheetOpen || this.cinemaSheetOpen || this.settingsOpen + || this.newOpen || this.insightOpen) ? 1 : 0; } /** 模块主题色(观影 = 紫) */ private accent: string = '#7A4E8C'; @@ -165,6 +187,12 @@ export struct FbMoviesPage { } private backOnce(): boolean { + if (this.newOpen) { + this.newOpen = false; + } + if (this.insightOpen) { + this.insightOpen = false; + } if (this.settingsOpen) { this.settingsOpen = false; return true; @@ -426,6 +454,58 @@ export struct FbMoviesPage { .transition(fbSheetTransition()) } + // ---- 「记一场」新建浮层(用户 v63:「记一场这个功能现在可以开始做了」)---- + if (this.newOpen) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) + .onClick((): void => { + this.newOpen = false; + }) + + FbMovieNewSheet({ + cinemas: this.cinemas, + accent: this.accent, + /* 🔴 v64:「同看的人」默认要带上**我**(与网页端 v38 同口径: + 「自己不用选择就应该已经选上了」)。登录者 id 从全局会话取, + 取不到(0)时浮层就不预置任何人 —— 不会硬塞一个错的 id 进 attendees。 */ + meId: this.app.session.me.id, + onClose: (): void => { + this.newOpen = false; + }, + /* 新建成功 ⇒ 重读本地库(`createMovie` 里已经重拉过接口并落库, + 这里只是把页面的列表刷出来)。 */ + onCreated: (): void => { + this.reloadFromLocal(); + } + }) + .transition(fbSheetTransition()) + } + + // ---- 高级搜索 / 统计 / 年度报告(三个入口共用这一个浮层)---- + if (this.insightOpen) { + Column() + .width('100%') + .height('100%') + .backgroundColor(FB_MASK) + .transition(fbMaskTransition()) + .onClick((): void => { + this.insightOpen = false; + }) + + FbMovieInsightSheet({ + mode: this.insightMode, + cinemas: this.cinemas, + accent: this.accent, + onClose: (): void => { + this.insightOpen = false; + } + }) + .transition(fbSheetTransition()) + } + // ---- 模块设置浮层(三条横线点开的那个)---- if (this.settingsOpen) { Column() @@ -446,10 +526,22 @@ export struct FbMoviesPage { this.settingsOpen = false; }, onPick: (action: string): void => { - // 目前只有「影院管理」是已实现的;其余在菜单里显示「暂未开放」 + /* 🔴 v63:四条**全部接上**。 + ⚠️ 三条"洞察类"(搜索/统计/年报)走**同一个浮层 + 不同 mode**, + 不是三个浮层(见 `FbMovieInsightSheet` 的说明)。 + ⚠️ 先关设置再开目标浮层 —— 两者不叠(叠起来会看到两层遮罩)。 */ + this.settingsOpen = false; if (action === 'cinema') { - this.settingsOpen = false; this.cinemaSheetOpen = true; + } else if (action === 'moviesearch') { + this.insightMode = 'search'; + this.insightOpen = true; + } else if (action === 'moviestats') { + this.insightMode = 'stats'; + this.insightOpen = true; + } else if (action === 'moviereport') { + this.insightMode = 'report'; + this.insightOpen = true; } } }) @@ -481,7 +573,12 @@ export struct FbMoviesPage { FbPrimaryButton({ label: '记一场', bg: this.accent, - icon: $r('app.media.fb_ic_plus') + icon: $r('app.media.fb_ic_plus'), + /* 🔴 v63:**接上回调**(原来这颗按钮没有 `onTap`, + 点了什么都不发生 —— 用户:「记一场这个功能现在可以开始做了」)。 */ + onTap: (): void => { + this.newOpen = true; + } }) // 🔴 设置按钮 = 网页版那种**三条横线**(☰);「影院」也不单独占抽屉位置, // 它是观影模块的一部分,从设置菜单里进(与网页版一致)。 @@ -499,42 +596,61 @@ export struct FbMoviesPage { .alignItems(VerticalAlign.Center) // ---- 搜索条 ---- - Row({ space: 6 }) { + // + // 🔴 v64(用户 2026-10-19):「**电影的这个搜索框,非常不明显,根本看不清。**」 + // + // 原来那版的三个毛病(改前): + // · 底色是 `FbColors.inputBg`(`#F2EDE2`)—— 与页面底色 `paper` 几乎同色, + // 整条**看不出是个框**,像一行普通文字飘在那儿; + // · 图标 15vp + `faint`(`#A8988A`)—— 对比度太低,真机上几乎看不见; + // · 文字也是 `faint` 且只有 `fontSmall`,与背景糊成一片。 + // + // 现在的做法(四件事一起做才"看得清",只做一件仍然发飘): + // ① 底色给**白**(`surface`)—— 与 paper 底形成面与面的区分; + // ② 加一圈**实色描边**(`line`)—— 边界是硬边,不依赖光照假设; + // ③ 图标放大到 18 且改用主题色 —— 它同时也是"可点"的视觉暗示; + // ④ 占位文字从 `faint` 提到 `muted`、字号从 `small` 提到 `body`。 + Row({ space: 8 }) { Image($r('app.media.fb_ic_search')) - .width(15) - .height(15) + .width(18) + .height(18) .renderMode(ImageRenderMode.Template) - .colorFilter(fbTint(FbTint.faint)) + .colorFilter(fbTint(this.accent)) Text('搜索片名、影院、观影人…') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) + .fontSize(FbMetric.fontBody) + .fontColor(FbColors.muted) .layoutWeight(1) .maxLines(1) - } - .height(36) - .padding({ left: 12, right: 12 }) - .backgroundColor(FbColors.inputBg) - .borderRadius(FbMetric.radiusS) - .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 4 }) - .alignItems(VerticalAlign.Center) - - // ---- 影院数(可点:看影院列表)---- - // 🔴 之前这是个**纯文字**(只能看数字);现在点它能看到名单 —— - // Web 端「影院管理」就在观影模块里,这里给它一个入口。 - Row({ space: 6 }) { - Blank() - Text('影院 ' + this.cinemas.length.toString() + ' 家') + // 右侧一枚"进入"提示 —— 让这一条**看着就是可以点的**(原来完全没有暗示) + Text('筛选 ›') .fontSize(FbMetric.fontTiny) - .fontColor(FbColors.faint) - Text('›') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) + .fontColor(this.accent) } - .width('100%') + .height(42) + .padding({ left: 12, right: 12 }) + .backgroundColor(FbColors.surface) + .borderRadius(FbMetric.radiusS) + .border({ width: 1, color: FbColors.line }) + .margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 }) + .alignItems(VerticalAlign.Center) + /* 🔴 v63(用户 2026-10-19):「"搜索"这个功能也可以开始做了, + **现在点搜索还没有反应**。」 + ⇒ 这原来是个**纯展示的假输入框**(一个 Row + 一行占位文字, + 连点击回调都没有 —— 所以点了没有任何反应)。 + 现改成**点它打开「高级搜索」** —— 与设置菜单里那一条是同一个浮层, + 这样"看到搜索框 → 点它 → 就能搜"符合直觉。 + ⚠️ 不在这里做"就地输入过滤":观影搜索是**组合条件 + 要跨影院/日期**, + 就地过滤只能按片名,做出来会让用户以为"搜不到就是没有"。 */ .onClick((): void => { - this.cinemaSheetOpen = true; + this.insightMode = 'search'; + this.insightOpen = true; }) - .padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 6, bottom: 6 }) + + /* 🔴 v63(用户 2026-10-19):「**观影列表里面也有个"影院26家", + 也不知道是什么意思,没有用,要把它删掉。**都在影院管理、都在设置里面 + 就可以了。」 + ⇒ 整行删除。影院列表现在只从**设置 ☰ → 影院管理**进 + (以前这一行也是"影院管理"的入口,与设置里的那条重复)。 */ // ---- 同步状态提示(只在有话说时显示,不占位)----