Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbDateTimeField.ets
T
laoyang 5661312679 日程编辑:确认「开始」后结束时间自动 = 开始 + 1 小时 (laoyang/FamilyBook#34)
用户 2026-11-01:
  「当我选择了开始时间并确定之后,结束时间要跟着开始时间变成开始时间加1个小时。
    也就是说,所有时间我们都默认它是一个小时。如果不是一个小时,再让他自己去改。」

## 改前

`applyPicker()` 只在**会变成负时长**时才动结束时间:

    if (this.fEndDate < iso || (this.fEndDate === iso && this.fEndTime < hm)) { ... }

⇒ 用户改了开始、结束还挂在老位置,实际时长变成随机的。

## 改后

点「开始」确定 ⇒ 结束**无条件**重置为「开始 + 1 小时」。
改「结束」那条路一个字不动(改结束不影响开始)—— 这是「默认一小时、
不像一小时再自己改」的另一半。

全天日程跳过这段:时刻部分本来就不生效,改了不会落到服务端,
但会让"切回非全天"时看到一个莫名的时间。

## 新增 fbPlusHours()

`FbDateTimeField.ets` 里加 `fbPlusHours(date, time, hours)`,返回 `{date, time}`。
**不复用现成的 `fbAddHour()`**,因为那个有两个问题:

    return fbPad2(Math.min(hh + 1, 23)) + ':' + mm;

① `Math.min(hh+1, 23)` **封顶 23 点** ⇒ 23:30 加一小时还是 23:30;
② 只返回时间、不带日期 ⇒ 表达不了「次日 00:30」。

跨天/跨月/跨年全部交给 `Date` 运算,不自己算天数
(自己 `if (hh>=24) dd+=1` 在"11 月 30 日 + 1 天"上就会错成 11 月 31 日)。

## 验证

**纯函数真值表 13 项**(从真实 `.ets` 抽函数、只擦类型标注后 node 直接跑,
不手抄替身;擦除后断言行数不变、无类型残留、没引入新字符):

普通 / 跨天整点 / 跨天半点 / 跨月 / 跨年 / 非闰年 2 月末 / **闰年 2 月末 ⇒ 2/29** / 午夜 / 带分钟
—— 全过。其中 23:30+1h ⇒ **次日 00:30**、12/31 ⇒ 次年 01/01、2028-02-28 ⇒ 2028-02-29。

**反向验证 3 项**(证明判据不是恒真):
坏A(封顶 23 点 / 不带日期)被抓到、坏B(丢分钟)被抓到、坏C(固定 28 天)被抓到。

**接线断言 12 项 + 反向验证 5 项**(静态,因为"函数对 ≠ 接进去了",
上一轮 #34 主体 bug 就是参数名对不上、编译运行都不报错):
- 开始分支调用 `fbPlusHours(iso, hm, 1)`、结果**同时**写 `fEndDate` 与 `fEndTime`
- 结束分支**没有任何** `fStart*` 赋值
- 全天日程有 `!this.fAllDay` 判断
- `pickIsEnd = !isStart`(#34 主体防回归)
- 不再残留 `this.fEndDate < iso` 那种条件顺延
- 反向:改回老写法 / 只写日期不写时间 / 改结束顺手改开始 / `pickIsEnd` 再反过来
  → 逐条转红;**对照组**(只改一句注释)仍绿 —— 证明断言不会"见到改动就红"

> ⚠️ 接线判据自己第一版有 bug,是反向验证抓出来的:坏1 用正则
> `if \(!this\.fAllDay\) \{[\s\S]*?\n      \}` 找替换目标,但渲染层还有一个
> `if (!this.fAllDay)`(控制「结束」那一行显示),正则先吃到那个 ⇒ 注入落在无关位置
> ⇒ 断言仍绿(假阴性)。改成"先把 applyPicker 的函数体切出来再替换"。

**编译**:BUILD SUCCESSFUL,33 tasks(17 executed)。验签通过。

**⚠️ 已知存量失败(与本次无关)**:`_fb_arkts_guard.js` 跑出 1321 通过 / 3 失败,
三条都在工作区里**别的会话未提交的改动**上(`AGENDA_BACK_DAYS` 被换成 `CAL_FULL_FROM/TO`
导致"列表视图显式传日期范围"判据失配、`canEdit` 相关两条属 #31)。
本次只改 `FbDateTimeField.ets` 与 `FbEventEditSheet.ets`,不碰那些文件;
用 `git show HEAD:` 核实过 HEAD 版本里 `AGENDA_BACK_DAYS` 还在(判据本来通过)。
2026-11-01 09:38:14 +08:00

375 lines
15 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;
}
/** `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;
}
}