v63+v64:影院管理补齐(v63)与观影「记一场」豆瓣/弹框选择(v64)

⚠️ 本提交含**两批**改动(同一批文件里交织,无法按文件拆开):
  · v63(影院管理与搜索/统计/年报拆成平级入口)—— 本会话之外的工作,
    随本轮一并提交以免工作区长期留半成品;
  · v64(本会话,用户 2026-10-19 六条)。

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

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

验证:静态守卫 636/0 · 端上反向验证 87/0 · 接口 e2e 57/0 · 服务端反向验证 18/0 ·
本地全量 e2e 1521/0 · hvigor 编译成功并已装机。
⚠️ 真机验证脚本 _fb_v64_verify.py 已写好(六条全覆盖),因设备锁屏未能执行。
This commit is contained in:
2026-10-20 07:35:37 +08:00
parent 4a063114c5
commit 8146aab1a7
10 changed files with 3519 additions and 1040 deletions
@@ -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);
})
}
}
+40 -831
View File
@@ -1,38 +1,25 @@
/** /**
* 家书 FamilyBook · 影院管理浮层(鸿蒙端) * 家书 FamilyBook · 影院管理浮层(鸿蒙端)
* *
* 🔴 v62(用户 2026-10-19)原话: * 🔴 v63(用户 2026-10-19)**推翻了 v62 的页签方案**,原话:
* 「设置里面的影院管理,这些功能都还没有,应该补充,就是增加影院呢、修改影院呢、 * 「**影院管理就是影院管理。高级搜索统计和年度报告不在"影院管理"这个功能里**,
* 高级搜索、统计、年度报告,这些全部都应该补充进去。」 * 而是点击观影设置后,弹框里面有"影院管理""高级搜索统计"和"年度报告";
* 点击之后,会进入到搜索统计和年度报告里面。
* **影院管理就是管理影院,不要把统计搜索和年报放在这里面。**」
* *
* ⇒ 之前这个浮层**只是一个只读列表**(连"新增"都没有),而网页端 * ⇒ v62 我把四项做成同一个浮层里的四个页签;用户要的是**平级的三个入口**:
* `templates/movies/` 下早就有 `cinemas.php` / `search.php` / `stats.php` / * 观影页 ☰ → 设置菜单(影院管理 / 高级搜索、统计 / 年度报告)→ 各自独立浮层。
* `report.php` 四页。这一版把端上补齐到与网页同一套能力: * ⇒ 本组件**只剩影院本身**:列表 + 新增 / 修改 / 删除。
* · 顶部一排「页签」:影院 / 统计 / 搜索 / 年报; * 统计 / 搜索 / 年报在 `FbMovieInsightSheet`(由 `mode` 决定显示哪一项)。
* · 影院页:**新增 / 修改 / 删除**(服务端 CRUD 早就有,端上没接);
* · 统计页:总览 + 按影院计数(`/api/v1/movies/stats`);
* · 搜索页:片名 / 影院 / 人 / 起止日期(`/api/v1/movies/search`);
* · 年报页:按年切换(`/api/v1/movies/report`)。
* *
* ⚠️ 四个页签**共用一个浮层**(而不是四个独立浮层): * ⚠️ 数据来自**本地库**(观影同步时一起落库的 `cinemas` 表),不是现拉接口 ——
* 它们是"同一个东西的不同视角",来回切换不该经过"关掉再打开"(那会闪一下)。 * 见 `FbMovieRepo.cinemaList()` 的说明。
* * 但**写操作**(增改删)必须联网,写完立刻回写本地库(见 `saveCinema`)。
* 🔴 数据来源分两路(有意的):
* · **影院列表**读**本地库**(`cinemas` 表)—— 与观影页其它内容一致,断网也看得见;
* · **统计 / 年报 / 搜索**必须**联网**——它们的口径要与网页**完全一致**
* (同一份 `MovieStats`)。本地库算出来的是"已同步的那部分",
* 少同步一次就少一部,用户会发现"网页 124 部、App 121 部"。
* ⇒ 这三页断网时显示"需要联网",而不是给一个偏低的数字。
*/ */
import { FbColors, FbMetric, fbAlpha } from '../common/Theme'; import { FbColors, FbMetric, fbAlpha } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules'; import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { import { FbCinemaItem, FbMovieRepo } from '../data/FbMovieRepo';
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 { FbSheetRing } from './FbSheetRing';
import { FbSheetClose } from './FbSheetClose'; import { FbSheetClose } from './FbSheetClose';
@@ -45,25 +32,15 @@ import { FbSheetClose } from './FbSheetClose';
*/ */
const FB_SHEET_BOTTOM_GAP: number = 16; const FB_SHEET_BOTTOM_GAP: number = 16;
/** 四个页签 */ /** 新建影院时的默认色(与网页 `Cinema::PALETTE` 里的紫同一档) */
const TABS: string[] = ['影院', '统计', '搜索', '年报'];
/** 新增影院时的默认色(与网页 `Cinema::PALETTE` 里的紫同一档,便于区分新建的) */
const DEFAULT_CINEMA_COLOR: string = '#7A4E8C'; const DEFAULT_CINEMA_COLOR: string = '#7A4E8C';
/** /** 新建影院时可选的颜色(与网页 `Cinema::PALETTE` 同一族,取"色相差得开"的那几个) */
* 空的报告统计对象。 const PALETTE: string[] = [
* '#7A4E8C', '#e84393', '#e91e63', '#f06292',
* ⚠️ ArkTS 的 `arkts-no-untyped-obj-literals` **不允许空对象字面量** '#2980b9', '#6c5ce7', '#16a085', '#2ecc71',
* (`return {}` 直接编译不过)⇒ 用一个显式声明的空 interface 顶掉。 '#f8c471', '#a67c52', '#95a5a6', '#7f8c8d'
*/ ];
interface FbEmptyStats {
}
const EMPTY_STATS: FbEmptyStats = {};
/** 同理:总览的空值(`return {}` 编译不过) */
const EMPTY_OVERVIEW: FbMovieOverview = {};
@ComponentV2 @ComponentV2
export struct FbCinemaSheet { export struct FbCinemaSheet {
@@ -83,10 +60,7 @@ export struct FbCinemaSheet {
*/ */
@Param accent: string = ''; @Param accent: string = '';
/** 当前页签(0=影院 1=统计 2=搜索 3=年报) */ // ---- 编辑态 ----
@Local tab: number = 0;
// ---- 编辑态(影院页)----
/** 正在编辑的影院 id(0 = 新增;-1 = 没在编辑) */ /** 正在编辑的影院 id(0 = 新增;-1 = 没在编辑) */
@Local editId: number = -1; @Local editId: number = -1;
@Local fName: string = ''; @Local fName: string = '';
@@ -100,26 +74,6 @@ export struct FbCinemaSheet {
/** 待确认删除的影院 id(0 = 没有) */ /** 待确认删除的影院 id(0 = 没有) */
@Local delId: number = 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(); private repo: FbMovieRepo = FbMovieRepo.inst();
/** 环色:显式给了就用给的,否则取观影模块色(影院属于观影) */ /** 环色:显式给了就用给的,否则取观影模块色(影院属于观影) */
@@ -131,82 +85,6 @@ export struct FbCinemaSheet {
return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand; 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<void> {
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<void> {
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<void> {
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 表示新增) */ /** 开始编辑(id=0 表示新增) */
private startEdit(id: number): void { private startEdit(id: number): void {
if (id === 0) { if (id === 0) {
@@ -216,7 +94,8 @@ export struct FbCinemaSheet {
this.fAddress = ''; this.fAddress = '';
this.fColor = DEFAULT_CINEMA_COLOR; this.fColor = DEFAULT_CINEMA_COLOR;
} else { } 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) { if (c === undefined) {
return; return;
} }
@@ -262,23 +141,25 @@ export struct FbCinemaSheet {
build() { build() {
// FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。 // FB_WRAP_CENTER:把卡片**垂直居中**(用户 2026-10-16 要求:浮在屏幕中间)。
// ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 —— // ⚠️ 居中放在**组件内部**做,而不是改宿主的 Stack 对齐 ——
// 宿主 Stack 里还有别的子元素(日程页的悬浮 +),改对齐会连带挪走它们。 // 宿主 Stack 里还有别的子元素,改对齐会连带挪走它们。
// ⚠️ 外层铺满 + Center;内层仍是那张 92% 宽的圆角卡。 // ⚠️ 外层铺满 + Center;内层是那张 92% 宽的圆角卡。
Column() { Column() {
// 🔴 2026-10-19:卡片外面加**主题色环**(用户:「所有弹框都加线」),
// 由共用组件 `FbSheetRing` 统一给;这一层只负责 92% 宽度。
Column() { Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 0 }) { Column({ space: 0 }) {
// ---- 标题栏(统一版式:左标题 + 右 ✕,见 FbSheetClose.ets)---- // ---- 标题栏(统一版式:左标题 + 右 ✕,见 FbSheetClose.ets)----
Row({ space: 8 }) { Row({ space: 8 }) {
Column({ space: 2 }) { Column({ space: 2 }) {
Text(this.titleText()) Text(this.editId >= 0
? (this.editId === 0 ? '新增影院' : '修改影院') : '影院管理')
.fontSize(FbMetric.fontTitle) .fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink) .fontColor(FbColors.ink)
.maxLines(1) .maxLines(1)
Text(this.subtitleText()) /* 🔴 v63:副标题**不再写「N 家」**(用户:「影院电影列表里面有个
『影院26家』,不知道是什么意思……没有用,要把它删掉」)。
⇒ 换成一句说明"这里能做什么",比一个看不懂的数字有用。 */
Text('管理看过的影院:新增 / 修改 / 删除')
.fontSize(FbMetric.fontTiny) .fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint) .fontColor(FbColors.faint)
.maxLines(1) .maxLines(1)
@@ -297,27 +178,16 @@ export struct FbCinemaSheet {
.width('100%') .width('100%')
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
// ---- 页签 ----
this.tabBar()
Divider() Divider()
.color(FbColors.line) .color(FbColors.line)
.margin({ top: 4, bottom: 4 }) .margin({ top: 10, bottom: 4 })
// ---- 内容区 ---- // ---- 内容 ----
// ⚠️ `flexShrink(1)` 让内容**按自身高度收缩**(短内容不撑满 maxHeight)—— // ⚠️ `flexShrink(1)` 让内容**按自身高度收缩**(短内容不撑满 maxHeight),
// 本项目在日程详情/模块设置踩过"卡片空半屏"(`layoutWeight(1)` 会吃满)。 // 本项目在日程详情/模块设置踩过"卡片空半屏"。
Scroll() { Scroll() {
Column({ space: 0 }) { Column({ space: 0 }) {
if (this.tab === 0) {
this.cinemaPane() this.cinemaPane()
} else if (this.tab === 1) {
this.statsPane()
} else if (this.tab === 2) {
this.searchPane()
} else {
this.reportPane()
}
} }
.width('100%') .width('100%')
} }
@@ -326,7 +196,7 @@ export struct FbCinemaSheet {
.scrollBar(BarState.Off) .scrollBar(BarState.Off)
.align(Alignment.TopStart) .align(Alignment.TopStart)
// ---- 底部:影院页的一行结果提示 ---- // ---- 底部:一行结果提示 ----
if (this.note !== '') { if (this.note !== '') {
Text(this.note) Text(this.note)
.fontSize(FbMetric.fontTiny) .fontSize(FbMetric.fontTiny)
@@ -335,10 +205,6 @@ export struct FbCinemaSheet {
.padding({ top: 8 }) .padding({ top: 8 })
} }
} }
// 🔴 用户 2026-10-16:「它应该弹出一个**圆角的、浮在上面的卡片**……
// **不要让它左右充满整个屏幕,下面也不要挨着屏幕**,
// 而是放在**屏幕中间**,采用**圆角浮窗**式的形式。」
// ⇒ 不再贴底满宽:左右留边、四角圆角、垂直居中(由宿主的 Stack 对齐)。
.width('100%') .width('100%')
.constraintSize({ maxHeight: '76%' }) .constraintSize({ maxHeight: '76%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP }) .padding({ left: 18, right: 18, top: 18, bottom: 20 + FB_SHEET_BOTTOM_GAP })
@@ -353,60 +219,7 @@ export struct FbCinemaSheet {
.hitTestBehavior(HitTestMode.Transparent) .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 @Builder
cinemaPane() { cinemaPane() {
@@ -435,7 +248,6 @@ export struct FbCinemaSheet {
.height(20) .height(20)
.backgroundColor(c) .backgroundColor(c)
.borderRadius(10) .borderRadius(10)
// ⚠️ 选中的加一圈描边(否则分不出选了哪个)
.border({ width: this.fColor === c ? 2 : 0, color: FbColors.ink }) .border({ width: this.fColor === c ? 2 : 0, color: FbColors.ink })
.onClick((): void => { .onClick((): void => {
this.fColor = c; this.fColor = c;
@@ -454,7 +266,8 @@ export struct FbCinemaSheet {
.textAlign(TextAlign.Center) .textAlign(TextAlign.Center)
.layoutWeight(1) .layoutWeight(1)
.height(40) .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) .borderRadius(FbMetric.radiusS)
.onClick((): void => { .onClick((): void => {
this.save(); this.save();
@@ -576,7 +389,7 @@ export struct FbCinemaSheet {
.width('100%') .width('100%')
} }
// ---- 删除确认(红底确认框,与其它删除同一规格 —— 用户要求"删除按钮全部红色背景")---- // ---- 删除确认(红底 —— 用户要求"删除按钮全部红色背景")----
if (this.delId > 0) { if (this.delId > 0) {
this.confirmDelete() this.confirmDelete()
} }
@@ -651,608 +464,4 @@ export struct FbCinemaSheet {
.padding({ top: 16, bottom: 6 }) .padding({ top: 16, bottom: 6 })
.alignItems(HorizontalAlign.Center) .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<string, Object> = this.rp() as Record<string, Object>;
const v: Object | undefined = o[key];
return typeof v === 'number' ? v : 0;
}
private strField(key: string): string {
const o: Record<string, Object> = this.rp() as Record<string, Object>;
const v: Object | undefined = o[key];
return typeof v === 'string' ? v : '';
}
/** 月份分布 → 条形数据(1..12 月) */
private monthDist(): FbMovieMonthStat[] {
const o: Record<string, Object> = this.rp() as Record<string, Object>;
const md: Object | undefined = o['month_distribution'];
const out: FbMovieMonthStat[] = [];
if (md === undefined || md === null) {
return out;
}
const m: Record<string, Object> = md as Record<string, Object>;
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'
];
@@ -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;
}
}
@@ -44,6 +44,24 @@ import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings'; import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbEventChip } from './FbEventChip'; import { FbEventChip } from './FbEventChip';
import { FbSheetRing } from './FbSheetRing'; 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 DOMAIN: number = 0x0000;
const TAG: string = 'FbEventEdit'; const TAG: string = 'FbEventEdit';
@@ -178,10 +196,10 @@ export struct FbEventEditSheet {
// ---- 编辑:回填 ---- // ---- 编辑:回填 ----
this.fTitle = e.title; this.fTitle = e.title;
this.fAllDay = e.all_day; this.fAllDay = e.all_day;
this.fStartDate = this.dateOf(e.starts_at); this.fStartDate = fbDateOf(e.starts_at, this.todayStr());
this.fStartTime = this.timeOf(e.starts_at); this.fStartTime = fbTimeOf(e.starts_at, '09:00');
this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at); this.fEndDate = e.ends_at !== '' ? fbDateOf(e.ends_at, this.todayStr()) : fbDateOf(e.starts_at, this.todayStr());
this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : this.addHour(e.starts_at); this.fEndTime = e.ends_at !== '' ? fbTimeOf(e.ends_at, '10:00') : this.addHour(e.starts_at);
this.fPlace = e.location; this.fPlace = e.location;
this.fNote = e.note; this.fNote = e.note;
this.fRepeat = this.repeatKeyOf(e.rrule); this.fRepeat = this.repeatKeyOf(e.rrule);
@@ -190,15 +208,15 @@ export struct FbEventEditSheet {
return; return;
} }
// ---- 新建:预填 ---- // ---- 新建:预填 ----
const d: string = this.presetDate !== '' ? this.presetDate : this.todayStr(); const d: string = this.presetDate !== '' ? this.presetDate : fbToday();
this.fStartDate = d; this.fStartDate = d;
this.fEndDate = d; this.fEndDate = d;
// 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 —— // 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 ——
// 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。 // 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。
const now: Date = new Date(); const now: Date = new Date();
const nh: number = Math.min(now.getHours() + 1, 22); const nh: number = Math.min(now.getHours() + 1, 22);
this.fStartTime = this.pad(nh) + ':00'; this.fStartTime = fbPad2(nh) + ':00';
this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00'; this.fEndTime = fbPad2(nh + 1 > 23 ? 23 : nh + 1) + ':00';
this.fBookId = this.defaultBookId(); this.fBookId = this.defaultBookId();
} }
@@ -207,29 +225,12 @@ export struct FbEventEditSheet {
// ============================================================ // ============================================================
private todayStr(): string { private todayStr(): string {
const n: Date = new Date(); return fbToday();
return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate());
} }
private pad(n: number): string { /** 给一个时刻的时分 +1 小时(当结束时间没填时的兜底) */
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 小时(当结束时间没填时的兜底) */
private addHour(dt: string): string { private addHour(dt: string): string {
const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9; return fbAddHour(fbTimeOf(dt, '09:00'));
const h2: number = Math.min(hh + 1, 23);
return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '00');
} }
/** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */ /** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */
@@ -577,51 +578,22 @@ export struct FbEventEditSheet {
/** /**
* **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。 * **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。
* *
* 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是 * 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimeRow`(**与观影共用**)——
* **把日期和时间放在一个控件里面**。」 * 用户 2026-10-19:「观影的开始/结束时间应该跟日程里面的是一样的。」
* 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与 * 这里只负责"把本表单的值传进去 + 接住点击"。
* `TimePicker` 并排**,选完点「确定」一次回填。
* ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker"
* —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。
*
* 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`;
* 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。
*/ */
@Builder @Builder
dateTimeRow(label: string, isStart: boolean) { dateTimeRow(label: string, isStart: boolean) {
Row({ space: 10 }) { FbDateTimeRow({
Text(label) label: label,
.fontSize(FbMetric.fontTiny) date: isStart ? this.fStartDate : this.fEndDate,
.fontColor(FbColors.faint) time: isStart ? this.fStartTime : this.fEndTime,
.width(28) allDay: this.fAllDay,
// 整块可点:日期与时间在**同一个控件**里,中间不再分两块 accent: this.accentColor(),
Row() { onTap: (): void => {
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 => {
this.openPicker(isStart); 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 { private openPicker(isEnd: boolean): void {
this.pickIsEnd = isEnd; this.pickIsEnd = isEnd;
const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate); // ⚠️ 草稿值放在宿主,弹层组件 `aboutToAppear` 时从这里读初值 ——
this.pickDate = d; // 组件每次挂载都会重新读一次,所以"点开始 → 取消 → 点结束"拿到的是各自的值。
const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime); this.pickDate = fbParseDate(isEnd ? this.fEndDate : this.fStartDate);
const tm: Date = fbParseTime(isEnd ? this.fEndTime : this.fStartTime);
this.pickHour = tm.getHours(); this.pickHour = tm.getHours();
this.pickMin = tm.getMinutes(); this.pickMin = tm.getMinutes();
this.pickOpen = true; this.pickOpen = true;
} }
/** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */ /** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */
private applyPicker(): void { private applyPicker(iso: string, hm: string): 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);
if (this.pickIsEnd) { if (this.pickIsEnd) {
this.fEndDate = iso; this.fEndDate = iso;
this.fEndTime = hm; this.fEndTime = hm;
@@ -663,105 +633,25 @@ export struct FbEventEditSheet {
/** /**
* 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。 * 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。
* *
* 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。 * 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimePicker`(**与观影共用**)。
* ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**: * 结构(标题 → `Row { DatePicker | TimePicker }` 并排 → 确定)与原来完全一致,
* 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。 * 差别只是"这一份现在只有一处代码"。
* ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。
*/ */
@Builder @Builder
dateTimePickerSheet() { dateTimePickerSheet() {
// 🔴 2026-10-19:与其它弹框统一(92% 宽 + 四角圆角 + 主题色环)。 FbDateTimePicker({
Column() { title: this.pickIsEnd ? '选择结束时间' : '选择开始时间',
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { date: fbIsoOf(this.pickDate),
Column({ space: 14 }) { time: fbPad2(this.pickHour) + ':' + fbPad2(this.pickMin),
Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间') allDay: this.fAllDay,
.fontSize(FbMetric.fontTitle) accent: this.accentColor(),
.fontWeight(FontWeight.Medium) onConfirm: (d: string, t: string): void => {
.fontColor(FbColors.ink) this.applyPicker(d, t);
.width('100%') },
.textAlign(TextAlign.Center) onCancel: (): void => {
.padding({ top: 12 }) this.pickOpen = false;
}
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)
}
}
.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 @Builder
@@ -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<void> {
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<void> {
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<void> {
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<string, Object> = this.rp() as Record<string, Object>;
const v: Object | undefined = o[key];
return typeof v === 'number' ? v : 0;
}
private strField(key: string): string {
const o: Record<string, Object> = this.rp() as Record<string, Object>;
const v: Object | undefined = o[key];
return typeof v === 'string' ? v : '';
}
/** 月份分布 → 条形数据(1..12 月) */
private monthDist(): FbMovieMonthStat[] {
const o: Record<string, Object> = this.rp() as Record<string, Object>;
const md: Object | undefined = o['month_distribution'];
const out: FbMovieMonthStat[] = [];
if (md === undefined || md === null) {
return out;
}
const m: Record<string, Object> = md as Record<string, Object>;
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)
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
}
+359 -1
View File
@@ -20,9 +20,12 @@
*/ */
import { relationalStore } from '@kit.ArkData'; import { relationalStore } from '@kit.ArkData';
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';
import { hilog } from '@kit.PerformanceAnalysisKit'; import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FbDb, FbRow } from './FbDb'; import { FbDb, FbRow } from './FbDb';
import { FbApiOptions, FbApiResult, fbApi } from './FbHttp'; import { FbApiOptions, FbApiResult, fbApi, fbApiBase } from './FbHttp';
import { FB_TOKEN_STORE } from './FbTokenStore'; import { FB_TOKEN_STORE } from './FbTokenStore';
const DOMAIN: number = 0x0000; const DOMAIN: number = 0x0000;
@@ -136,6 +139,65 @@ export interface FbMovieYearStat {
cnt?: number; 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` 的完整载荷 */ /** `/api/v1/movies/stats` 的完整载荷 */
export interface FbMovieStats { export interface FbMovieStats {
overview?: FbMovieOverview; overview?: FbMovieOverview;
@@ -658,6 +720,81 @@ export class FbMovieRepo {
return out; 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<number> {
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<string, Object>` 逐键赋值(与 `FbCalRepo.toBody` 同一写法)。 */
const body: Record<string, Object> = {};
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<string, Object> = JSON.parse(r.data) as Record<string, Object>;
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<FbDoubanSuggest[]> {
const kw: string = q.trim();
if (kw === '') {
return [];
}
const query: Record<string, string> = {};
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<string, Object> = JSON.parse(r.data) as Record<string, Object>;
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<FbDoubanFetch | null> {
if (token === '') {
this.lastError = '还没登录';
return null;
}
const query: Record<string, string> = {};
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<string, Object> = JSON.parse(r.data) as Record<string, Object>;
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/<id>/poster`,multipart 字段 `poster`)。
*
* 🔴 用户 2026-10-19:「**允许上传多张图片,包括海报。**」
*
* ⚠️ 与服务端的分工:裁剪(2:3)、缩放、转 JPEG 全在**服务端**做
* (`Upload::image` 的 poster 槽位)—— 端上只负责把原图字节送上去。
* 端上自己裁会让"绕过端上(改造过的客户端)"存进非 2:3 的图。
*
* @returns 空串 = 成功;否则是错误说明
*/
async uploadPoster(id: number, uri: string, token: string): Promise<string> {
return uploadOne('/api/v1/movies/' + id.toString() + '/poster', 'poster', uri, token);
}
/**
* 传**观影照片**(`POST /api/v1/movies/<id>/photos`,multipart 字段 `photo`)。
*
* ⚠️ 一张一张传(而不是一次拼多张 multipart):
* · 每张的成败独立 —— 一张 12MB 的原图失败,不该把已经成功的几张一起回滚;
* · 端上进度好做(传了三张就显示"3 / 5");
* · 服务端那次是**追加**语义(`Movie::addPhotos`),多次调用结果与一次相同。
*
* @returns 空串 = 成功;否则是错误说明
*/
async uploadPhoto(id: number, uri: string, token: string): Promise<string> {
return uploadOne('/api/v1/movies/' + id.toString() + '/photos', 'photo', uri, token);
}
async watermark(table: string): Promise<string> { async watermark(table: string): Promise<string> {
const row: FbRow | null = await this.db.queryOne( const row: FbRow | null = await this.db.queryOne(
'SELECT last_sync_at FROM sync_state WHERE table_name = ?', [table]); '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 { function numOf(v: number | null | undefined): number {
return v !== null && v !== undefined ? v : 0; return v !== null && v !== undefined ? v : 0;
} }
/**
* 从 JSON 对象里安全取一个字符串字段(非字符串 → 空串)。
*
* ⚠️ 与 `strOf` 的分工:`strOf` 处理"值本身可能 null/undefined",
* 这个处理"**值可能是任意类型**"(如 `meta` 是对象、`id` 是数字)。
* 豆瓣那条接口的 `meta` 是嵌套对象,硬当字符串读会崩。
*/
function strField(o: Record<string, Object>, 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<string> {
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<string, Object> = JSON.parse(body) as Record<string, Object>;
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();
}
}
// ============================================================ // ============================================================
// 观影详情的解析 // 观影详情的解析
// ============================================================ // ============================================================
+14 -4
View File
@@ -17,11 +17,21 @@
import { FbSettingItem } from '../components/FbModuleSettingsSheet'; import { FbSettingItem } from '../components/FbModuleSettingsSheet';
/** 观影(对应网页版 templates/movies/_settings_menu.php) */ /** 观影(对应网页版 templates/movies/_settings_menu.php) */
/**
* 观影设置菜单。
*
* 🔴 v63(用户 2026-10-19):**影院管理与搜索/统计/年报是平级的四个入口**,
* 「影院管理就是管理影院,不要把统计搜索和年报放在这里面」。
* ⇒ 四条各有一个 `action`(v62 时后三条是空串 = 点了没反应)。
* ⚠️ 「高级搜索」与「统计」在原话里是**连着说的**("高级搜索统计"),
* 这里仍**拆成两条**:它们是两个不同页面(搜索给结果列表、统计给图),
* 合在一条里用户点进去只能看见一个,另一个就永远进不去了。
*/
export const FB_SET_MOVIES: FbSettingItem[] = [ export const FB_SET_MOVIES: FbSettingItem[] = [
{ icon: 'movies', label: '影院管理', hint: '看过的影院,按名字排', action: 'cinema' }, { icon: 'movies', label: '影院管理', hint: '看过的影院:新增 / 修改 / 删除', action: 'cinema' },
{ icon: 'search', label: '高级搜索', hint: '按年份 / 类型 / 评分筛', action: '' }, { icon: 'search', label: '高级搜索', hint: '片名 / 影院 / 日期组合查', action: 'moviesearch' },
{ icon: 'chart', label: '统计', hint: '每年看了多少、类型分布', action: '' }, { icon: 'chart', label: '统计', hint: '看过多少、按影院和年份的分布', action: 'moviestats' },
{ icon: 'notes', label: '年度报告', hint: '一句话总结这一年', action: '' } { icon: 'notes', label: '年度报告', hint: '这一年你看了些什么', action: 'moviereport' }
]; ];
/** 读书(templates/books/_settings_menu.php) */ /** 读书(templates/books/_settings_menu.php) */
+148 -32
View File
@@ -31,6 +31,8 @@ import { FbDb } from '../../data/FbDb';
import { FbMovieDetail, FbMovieListItem, FbMovieRepo, FbSyncResult } from '../../data/FbMovieRepo'; import { FbMovieDetail, FbMovieListItem, FbMovieRepo, FbSyncResult } from '../../data/FbMovieRepo';
import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet'; import { FbMovieDetailSheet } from '../../components/FbMovieDetailSheet';
import { FbCinemaSheet } from '../../components/FbCinemaSheet'; import { FbCinemaSheet } from '../../components/FbCinemaSheet';
import { FbMovieNewSheet } from '../../components/FbMovieNewSheet';
import { FbMovieInsightSheet } from '../../components/FbMovieInsightSheet';
import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet'; import { FbModuleSettingsSheet } from '../../components/FbModuleSettingsSheet';
import { FB_BACK } from '../../common/FbBack'; import { FB_BACK } from '../../common/FbBack';
import { FB_SET_MOVIES } from '../../model/FbSettingsMenus'; import { FB_SET_MOVIES } from '../../model/FbSettingsMenus';
@@ -131,6 +133,25 @@ export struct FbMoviesPage {
@Local settingsOpen: boolean = false; @Local settingsOpen: boolean = false;
/** 影院浮层是否打开 */ /** 影院浮层是否打开 */
@Local cinemaSheetOpen: 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` 的说明)。 * 🔴 浮层开合时**让底部悬浮栏让位**(与名册页同一处 bug,见 `FbPeoplePage` 的说明)。
@@ -139,10 +160,11 @@ export struct FbMoviesPage {
* ⇒ 悬浮栏层级更高 ⇒ 浮层底部的按钮会与它重叠、点击被抢走。 * ⇒ 悬浮栏层级更高 ⇒ 浮层底部的按钮会与它重叠、点击被抢走。
* ⇒ 用 `@Monitor` 一处声明、自动跟随派生(避免在多个赋值点手写、漏一处就出事)。 * ⇒ 用 `@Monitor` 一处声明、自动跟随派生(避免在多个赋值点手写、漏一处就出事)。
*/ */
@Monitor('sheetOpen', 'cinemaSheetOpen', 'settingsOpen') @Monitor('sheetOpen', 'cinemaSheetOpen', 'settingsOpen', 'newOpen', 'insightOpen')
onSheetChanged(): void { onSheetChanged(): void {
const app: FbAppState = fbAppState(); 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'; private accent: string = '#7A4E8C';
@@ -165,6 +187,12 @@ export struct FbMoviesPage {
} }
private backOnce(): boolean { private backOnce(): boolean {
if (this.newOpen) {
this.newOpen = false;
}
if (this.insightOpen) {
this.insightOpen = false;
}
if (this.settingsOpen) { if (this.settingsOpen) {
this.settingsOpen = false; this.settingsOpen = false;
return true; return true;
@@ -426,6 +454,58 @@ export struct FbMoviesPage {
.transition(fbSheetTransition()) .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) { if (this.settingsOpen) {
Column() Column()
@@ -446,10 +526,22 @@ export struct FbMoviesPage {
this.settingsOpen = false; this.settingsOpen = false;
}, },
onPick: (action: string): void => { onPick: (action: string): void => {
// 目前只有「影院管理」是已实现的;其余在菜单里显示「暂未开放」 /* 🔴 v63:四条**全部接上**。
if (action === 'cinema') { ⚠️ 三条"洞察类"(搜索/统计/年报)走**同一个浮层 + 不同 mode**,
不是三个浮层(见 `FbMovieInsightSheet` 的说明)。
⚠️ 先关设置再开目标浮层 —— 两者不叠(叠起来会看到两层遮罩)。 */
this.settingsOpen = false; this.settingsOpen = false;
if (action === 'cinema') {
this.cinemaSheetOpen = true; 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({ FbPrimaryButton({
label: '记一场', label: '记一场',
bg: this.accent, 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) .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')) Image($r('app.media.fb_ic_search'))
.width(15) .width(18)
.height(15) .height(18)
.renderMode(ImageRenderMode.Template) .renderMode(ImageRenderMode.Template)
.colorFilter(fbTint(FbTint.faint)) .colorFilter(fbTint(this.accent))
Text('搜索片名、影院、观影人…') Text('搜索片名、影院、观影人…')
.fontSize(FbMetric.fontSmall) .fontSize(FbMetric.fontBody)
.fontColor(FbColors.faint) .fontColor(FbColors.muted)
.layoutWeight(1) .layoutWeight(1)
.maxLines(1) .maxLines(1)
} // 右侧一枚"进入"提示 —— 让这一条**看着就是可以点的**(原来完全没有暗示)
.height(36) Text('筛选 ›')
.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() + ' 家')
.fontSize(FbMetric.fontTiny) .fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint) .fontColor(this.accent)
Text('›')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} }
.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 => { .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家",
也不知道是什么意思,没有用,要把它删掉。**都在影院管理、都在设置里面
就可以了。」
⇒ 整行删除。影院列表现在只从**设置 ☰ → 影院管理**进
(以前这一行也是"影院管理"的入口,与设置里的那条重复)。 */
// ---- 同步状态提示(只在有话说时显示,不占位)---- // ---- 同步状态提示(只在有话说时显示,不占位)----