/** * 家书 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; } /** `fbPlusHours()` 的返回形状(ArkTS 不允许匿名对象字面量作为返回值类型书写,单独给个接口) */ export interface FbDateTime { date: string; time: string; } /** * `YYYY-MM-DD` + `HH:mm` 往后推 N 小时 ⇒ `{ date, time }`(**跨天自动进位**)。 * * 起源:工单 #34 追加需求(2026-11-01)—— * 「当我选择了开始时间并确定之后,结束时间要跟着开始时间变成开始时间加1个小时。 * 也就是说,所有时间我们都默认它是一个小时。如果不是一个小时,再让他自己去改。」 * * ⚠️ 为什么不复用上面的 `fbAddHour()`:那只返回时间、不带日期, * 而且用 `Math.min(hh+1, 23)` **封顶 23 点** ⇒ 23:30 加一小时还是 23:30。 * 日程跨天是常态(20:00 → 次日 02:00 是真实用例),必须有日期。 * * ⚠️ 用 `Date` 做进位而不是自己算:跨月、跨年、闰年、月末天数全交给运行时。 * 自己 `if (hh >= 24) { dd += 1 }` 看着简单,但"11 月 30 日 + 1 天"就会错成 11 月 31 日。 */ export function fbPlusHours(date: string, time: string, hours: number): FbDateTime { const d: Date = fbParseDate(date); const h: number = time.length >= 5 ? parseInt(time.substring(0, 2), 10) : 9; const m: number = time.length >= 5 ? parseInt(time.substring(3, 5), 10) : 0; const out: Date = new Date(d.getFullYear(), d.getMonth(), d.getDate()); out.setHours(h + hours, m, 0, 0); return { date: fbIsoOf(out), time: fbPad2(out.getHours()) + ':' + fbPad2(out.getMinutes()) }; } /** 日期 + 时分拼成服务端认的 `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() { /* * ⚠️ `.onClick` 挂在**根 `Row`** 上(整行可点),不是挂在里面那个 * `layoutWeight(1)` 的 `Row` 上 —— 2026-10-20 真机实测: * 挂内层时,那一行会被表单所在的**两列容器**(dump 里两个 * `x1=50..521 / 559..1030、y1=602..1500` 的可点节点)盖住, * 点击**穿透**到底下的观影列表(表现为"点日期行,列表反而响应了")。 * 根 `Row`(`.width('100%')`)与 `FbCalOptionRow` 同一形态,热区完整。 */ 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) // 右侧小箭头:暗示"点开会选" Text('›') .fontSize(FbMetric.fontBody) .fontColor(FbColors.faint) } .width('100%') .alignItems(VerticalAlign.Center) .onClick((): void => { this.onTap(); }) } } // ============================================================ // ② 点开后的弹层 // ============================================================ @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() { /* * 🔴🔴 必须是 `Stack`,**不能**是 `Column`(2026-10-20 真机实测抓到的真 bug)。 * * 错误形态(原先): * Column() { // 根:线性布局 * Column().width('100%').height('100%') // ← 全屏遮罩 * Column() { ...卡片... } // ← 被挤到屏幕外 * } * `Column` 是**线性**布局:遮罩占满整高之后,卡片只能排在它**下面**, * 于是落到屏幕之外 —— 弹层"点了没反应"。 * * 症状极具误导性(本轮排查花了很久,逐条记下): * · 编译通过、无告警;`aboutToAppear` **确实被调用**(组件实例化了); * · `dumpLayout` 里**完全看不到**弹层的任何节点(连插桩的 30 号红字都没有); * · 点日期行会"穿透"到底下的观影列表(因为卡片不在屏幕内,点到的其实是列表); * · 同文件的 `FbDateTimeRow`(没有遮罩)一直正常 —— 差异只在遮罩这一层。 * ⇒ 教训:**全屏遮罩与卡片要"叠"(Stack),不能"排"(Column)**。 * 项目里能用的浮层(`FbMovieInsightSheet`、`FbMovieDetailSheet`)都是 * `Column{ 卡片 }` 或 `Stack{ ... }`,**没有**把全屏遮罩当 Column 子项排的。 */ Stack({ alignContent: Alignment.Center }) { // 遮罩:整屏铺一层,点它 = 取消。 // ⚠️ 必须**有**遮罩:没有的话,点弹层外面会直接点到下面那张编辑卡片上 // (真机上表现为"随手一点,表单被改了")。 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%') .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; } }