Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbDateTimeField.ets
T
laoyang 3b7238a418 v64b:修真机弹层"点了没反应"(根容器 Column → Stack)+ 日期行热区
四个日期/选择类浮层在真机上**完全打不开**:编译无告警、`aboutToAppear` 照常调用、
`dumpLayout` 里连弹层一个节点都看不到、点它会"穿透"到底下的列表。

根因(逐层二分定位):根容器写成 `Column` 且里面**先排一个整高遮罩**
(`Column().width('100%').height('100%').backgroundColor(FB_MASK)`)再排卡片。
`Column` 是**线性**布局 ⇒ 遮罩占满整高后,卡片被排到屏幕**外面**。
⇒ 全屏遮罩与卡片要"叠"(`Stack`),不能"排"(`Column`)。

改动:
- FbDateTimeField / FbCinemaPickSheet / FbPersonPickSheet:根容器改
  `Stack({ alignContent: Alignment.Center })`,遮罩与卡片作并列子项叠放;
  去掉原先用于"居中卡片"的 `justifyContent(FlexAlign.Center)`(Stack 已对齐)。
- FbDateTimeField:「日期行」的 `.onClick` 由内层 `layoutWeight(1)` 的 Row
  挪到**根 Row** —— 挂内层时那一行会被表单所在的**两列容器**盖住,
  点击穿透到底下的观影列表(表现为"点日期行,列表反而响应了")。
- FbSheetClose:`onClose` 由 `@Param` 改 `@Event`(语义归位;实测 `@Param`
  声明回调**并不会失效**,✕ 一直是能点的 —— 这不是本轮 bug 的根因,别混为一谈)。

验证:
- 构建 BUILD SUCCESSFUL;
- 静态守卫 644/0(新增 8 条:三个组件的 Stack 叠放 + 整高遮罩非空转 +
  全项目无"根 Column 先排整高遮罩"坏形态 + 日期行热区在根 Row);
- 守卫反向验证 92/0(新增 6 条注入,含把根 Stack 改回 Column、删遮罩、
  热区挪回内层,逐条证明断言真会红)。
2026-10-20 08:55:27 +08:00

343 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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() {
/*
* ⚠️ `.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;
}
}