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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user