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
@@ -44,6 +44,24 @@ import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbEventChip } from './FbEventChip';
import { FbSheetRing } from './FbSheetRing';
/* 🔴 v64(用户 2026-10-19):「观影的开始/结束时间应该跟**日程里面的是一样的**。」
⇒ 「日期 + 时间」那一套控件抽成了共用组件(见该文件头),日程与观影都用它。
⚠️ 本文件里原来的 `dateTimeRow` / `dateTimePickerSheet` / `cnDate` /
`parseDate` / `parseTime` / `pad` 全部删掉,改调这里的 —— 两份实现必然漂移,
用户这次点名的正是"两边不一致"。 */
import {
FbDateTimeRow,
FbDateTimePicker,
fbCnDate,
fbPad2,
fbToday,
fbParseDate,
fbParseTime,
fbDateOf,
fbTimeOf,
fbIsoOf,
fbAddHour
} from './FbDateTimeField';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbEventEdit';
@@ -178,10 +196,10 @@ export struct FbEventEditSheet {
// ---- 编辑:回填 ----
this.fTitle = e.title;
this.fAllDay = e.all_day;
this.fStartDate = this.dateOf(e.starts_at);
this.fStartTime = this.timeOf(e.starts_at);
this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at);
this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : this.addHour(e.starts_at);
this.fStartDate = fbDateOf(e.starts_at, this.todayStr());
this.fStartTime = fbTimeOf(e.starts_at, '09:00');
this.fEndDate = e.ends_at !== '' ? fbDateOf(e.ends_at, this.todayStr()) : fbDateOf(e.starts_at, this.todayStr());
this.fEndTime = e.ends_at !== '' ? fbTimeOf(e.ends_at, '10:00') : this.addHour(e.starts_at);
this.fPlace = e.location;
this.fNote = e.note;
this.fRepeat = this.repeatKeyOf(e.rrule);
@@ -190,15 +208,15 @@ export struct FbEventEditSheet {
return;
}
// ---- 新建:预填 ----
const d: string = this.presetDate !== '' ? this.presetDate : this.todayStr();
const d: string = this.presetDate !== '' ? this.presetDate : fbToday();
this.fStartDate = d;
this.fEndDate = d;
// 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 ——
// 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。
const now: Date = new Date();
const nh: number = Math.min(now.getHours() + 1, 22);
this.fStartTime = this.pad(nh) + ':00';
this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00';
this.fStartTime = fbPad2(nh) + ':00';
this.fEndTime = fbPad2(nh + 1 > 23 ? 23 : nh + 1) + ':00';
this.fBookId = this.defaultBookId();
}
@@ -207,29 +225,12 @@ export struct FbEventEditSheet {
// ============================================================
private todayStr(): string {
const n: Date = new Date();
return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate());
return fbToday();
}
private pad(n: number): string {
return n < 10 ? ('0' + n.toString()) : n.toString();
}
/** 从 `YYYY-MM-DD HH:MM:SS` 取日期段 */
private dateOf(dt: string): string {
return dt.length >= 10 ? dt.substring(0, 10) : this.todayStr();
}
/** 从 `YYYY-MM-DD HH:MM:SS` 取时间段的 `HH:mm` */
private timeOf(dt: string): string {
return dt.length >= 16 ? dt.substring(11, 16) : '09:00';
}
/** 给一个时间的时分 +1 小时(当结束时间没填时的兜底) */
/** 给一个时刻的时分 +1 小时(当结束时间没填时的兜底) */
private addHour(dt: string): string {
const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9;
const h2: number = Math.min(hh + 1, 23);
return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '00');
return fbAddHour(fbTimeOf(dt, '09:00'));
}
/** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */
@@ -577,51 +578,22 @@ export struct FbEventEditSheet {
/**
* **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。
*
* 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是
* **把日期和时间放在一个控件里面**。」
* 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与
* `TimePicker` 并排**,选完点「确定」一次回填。
* ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker"
* —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。
*
* 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`;
* 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。
* 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimeRow`(**与观影共用**)——
* 用户 2026-10-19:「观影的开始/结束时间应该跟日程里面的是一样的。」
* 这里只负责"把本表单的值传进去 + 接住点击"。
*/
@Builder
dateTimeRow(label: string, isStart: boolean) {
Row({ space: 10 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(28)
// 整块可点:日期与时间在**同一个控件**里,中间不再分两块
Row() {
Text(this.cnDate(isStart ? this.fStartDate : this.fEndDate))
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
if (!this.fAllDay) {
Text(' ' + (isStart ? this.fStartTime : this.fEndTime))
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
}
}
.layoutWeight(1)
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
FbDateTimeRow({
label: label,
date: isStart ? this.fStartDate : this.fEndDate,
time: isStart ? this.fStartTime : this.fEndTime,
allDay: this.fAllDay,
accent: this.accentColor(),
onTap: (): void => {
this.openPicker(isStart);
})
// 右侧小箭头:暗示"点开会选"(与 SyncCalendar 的行样式一致)
Text('›')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.faint)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
})
}
/**
@@ -632,19 +604,17 @@ export struct FbEventEditSheet {
*/
private openPicker(isEnd: boolean): void {
this.pickIsEnd = isEnd;
const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate);
this.pickDate = d;
const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime);
// ⚠️ 草稿值放在宿主,弹层组件 `aboutToAppear` 时从这里读初值 ——
// 组件每次挂载都会重新读一次,所以"点开始 → 取消 → 点结束"拿到的是各自的值。
this.pickDate = fbParseDate(isEnd ? this.fEndDate : this.fStartDate);
const tm: Date = fbParseTime(isEnd ? this.fEndTime : this.fStartTime);
this.pickHour = tm.getHours();
this.pickMin = tm.getMinutes();
this.pickOpen = true;
}
/** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */
private applyPicker(): void {
const iso: string = this.pad(this.pickDate.getFullYear()) + '-'
+ this.pad(this.pickDate.getMonth() + 1) + '-' + this.pad(this.pickDate.getDate());
const hm: string = this.pad(this.pickHour) + ':' + this.pad(this.pickMin);
private applyPicker(iso: string, hm: string): void {
if (this.pickIsEnd) {
this.fEndDate = iso;
this.fEndTime = hm;
@@ -663,105 +633,25 @@ export struct FbEventEditSheet {
/**
* 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。
*
* 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。
* ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**:
* 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。
* ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。
* 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimePicker`(**与观影共用**)。
* 结构(标题 → `Row { DatePicker | TimePicker }` 并排 → 确定)与原来完全一致,
* 差别只是"这一份现在只有一处代码"。
*/
@Builder
dateTimePickerSheet() {
// 🔴 2026-10-19:与其它弹框统一(92% 宽 + 四角圆角 + 主题色环)。
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 14 }) {
Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 12 })
Row({ space: 6 }) {
DatePicker({
start: new Date('2000-01-01'),
end: new Date('2035-12-31'),
selected: this.pickDate
})
.onDateChange((v: Date): void => {
this.pickDate = v;
})
.textStyle({ color: FbColors.faint, font: { size: 14 } })
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
.layoutWeight(1)
.height(210)
if (!this.fAllDay) {
TimePicker({ selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) })
.useMilitaryTime(true)
.onChange((v: TimePickerResult): void => {
// ⚠️ `hour`/`minute` 是**数字**;这里存草稿数字,拼 `HH:MM` 时再补零
this.pickHour = v.hour;
this.pickMin = v.minute;
})
.textStyle({ color: FbColors.faint, font: { size: 14 } })
.selectedTextStyle({ color: FbColors.ink, font: { size: 16, weight: FontWeight.Medium } })
.disappearTextStyle({ color: FbColors.faint, font: { size: 14 } })
.layoutWeight(1)
.height(210)
}
FbDateTimePicker({
title: this.pickIsEnd ? '选择结束时间' : '选择开始时间',
date: fbIsoOf(this.pickDate),
time: fbPad2(this.pickHour) + ':' + fbPad2(this.pickMin),
allDay: this.fAllDay,
accent: this.accentColor(),
onConfirm: (d: string, t: string): void => {
this.applyPicker(d, t);
},
onCancel: (): void => {
this.pickOpen = false;
}
.width('100%')
Button(this.fAllDay ? '确定日期' : '确定')
.width('100%')
.height(44)
.borderRadius(FbMetric.radiusS)
.fontSize(FbMetric.fontBody)
.backgroundColor('#4A7C82')
.fontColor(Color.White)
.onClick((): void => {
this.applyPicker();
})
}
.width('100%')
.padding({ left: 18, right: 18, top: 8, bottom: 20 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
/** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */
private cnDate(iso: string): string {
if (iso.length < 10) {
return iso;
}
const y: number = parseInt(iso.substring(0, 4), 10);
const m: number = parseInt(iso.substring(5, 7), 10);
const d: number = parseInt(iso.substring(8, 10), 10);
const w: string[] = ['日', '一', '二', '三', '四', '五', '六'];
return m.toString() + '月' + d.toString() + '日 周' + w[new Date(y, m - 1, d).getDay()];
}
private parseDate(s: string): Date {
if (s.length < 10) {
return new Date();
}
const y: number = parseInt(s.substring(0, 4), 10);
const m: number = parseInt(s.substring(5, 7), 10) - 1;
const d: number = parseInt(s.substring(8, 10), 10);
return new Date(y, m, d);
}
private parseTime(s: string): Date {
const out: Date = new Date();
if (s.length >= 5) {
out.setHours(parseInt(s.substring(0, 2), 10));
out.setMinutes(parseInt(s.substring(3, 5), 10));
}
return out;
})
}
@Builder