Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbDateTimeField.ets
T

317 lines
12 KiB
Plaintext
Raw Normal View History

/**
* 家书 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;
}
}