Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbEventEditSheet.ets
T
laoyang 0c2cf9b5e7 表单规范全量铺开 + 编辑时视频附件不显示 (#56)
① 编辑行程的视频附件不显示

根因:照片墙对所有附件都用 Image 渲染,视频是 .mp4,
Image 加载不出来 ⇒ 空白块 ⇒ 用户以为"视频没带进来"。
按 mime 分流:video/ → 深色底 + ▶(不取首帧,端上解视频首帧很卡)。

② 新增共享组件 FbFormField.ets(GLOBAL-FORM-SPEC)

5 个表单原来各写一份 field()/labelRow(),改一处要改五处,
而且外层结构不一致(有的 Row 有的 Column),批量脚本改坏过两次。
收敛到一个组件,表单只调用不自己实现。

③ 全量铺开

- FbPersonEditSheet  姓名标必填(红色无星号)
- FbEventEditSheet   日程 field 两排
- FbMovieNewSheet    片名/影院/同看的人/海报/观影照片/简介/备注 全部两排
- FbDateTimeField    FbDateTimeRow 改两排(电影+日程同时生效)

规范:标签主题色 + Bold + fontSmall,必填红 #C0533F 无星号,
标签独占第一排、控件第二排整宽。
2026-11-05 12:52:51 +08:00

942 lines
36 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 · 日程新建 / 编辑浮层(鸿蒙端)
*
* 用户 2026-10-15:「我们家书的日程功能模块……跟 SyncCalendar 做得一样」。
*
* 字段对齐 SyncCalendar 的 `EventEditPage`:
* **标题 / 全天 / 日期与时间(同一行)/ 重复 / 提醒 / 日历本 / 地点 / 备注**,
* 编辑既有日程时底部还有一颗**删除**。
*
* 🔴 用户 2026-10-16 的两条修正:
* 1. 「**重复和提醒都挤在一块了**……而且**重复和提醒什么的都点不了**。」
* · "点不了" = chip 曾是 `@Builder` 值参数(不参与观察)⇒ 换成 `FbEventChip` 组件;
* · "挤在一块" = 裸 `Flex({wrap})` 里的 chip **没有 margin** ⇒
* 间距放进 `FbEventChip` 组件内部(项目别处的 wrap 也都写了 margin)。
* 2. 「时间和日期……**放在一行上吧**,别放上下了。」
* · 原来开始/结束各占一整行(`日期 时间` 拼成一串)。
* 3. **第二轮**:「你参考一下 SyncCalendar 的做法,实际上就是
* **把日期和时间放在一个控件里面**。」
* · 🔴 第一版我只做到"日期块 + 时间块并排一行",**但仍是两个可点区**、
* 点开的是**两个独立选择器** —— 不是 SyncCalendar 的形态。
* · SyncCalendar(`EventEditPage.dateTimePickerSheet`)是:
* **一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**,
* 选完点「确定」**一次回填**。
* · ⇒ 改成:控件是**一整块可点的框**(显示 `10月16日 周五 20:00`),
* 点它开弹层、里面两个滚轮并排、点「确定」写回;
* 弹层内部用**草稿**(`pickDate`/`pickHour`/`pickMin`)—— 取消可丢弃。
*
* 🔴 为什么用**浮层**而不是像 SyncCalendar 那样跳新页面:
* 本项目的浮层范式已经统一(见 `FbPersonEditSheet` / `FbMovieDetailSheet`)——
* 都是页面内的 `Stack` 覆盖层。跳页面会丢列表滚动位置、还要处理返回栈;
* 浮层状态完全由父页面掌控(一个 `@Param`),更简单。
* ⚠️ 与 SyncCalendar 的**功能对齐**是有意的;**形态不同**是有意的。
*
* ⚠️ 服务端 `POST/PATCH /api/v1/events` 的字段名是**下划线**(`starts_at` 等),
* 且"没传的字段沿用旧值" ⇒ 编辑时只需发改动的那些(见 `FbCalRepo.toBody`)。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric, FB_MASK } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbCalEventFull, FbCalEventInput, FbCalRepo } from '../data/FbCalRepo';
import { FbCalBook } from '../model/Types';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { FB_CAL_SETTINGS } from '../data/FbCalSettings';
import { FbEventChip } from './FbEventChip';
import { FbSheetRing } from './FbSheetRing';
/* 🔴 2026-10-30(工单 #36):地点改成带联想的输入框 ——
用户要「输入简单地址 → 下拉里带详细地址,好区分同名地点」。 */
import { FbAddrInput } from './FbAddrInput';
/* 🔴 v64(用户 2026-10-19):「观影的开始/结束时间应该跟**日程里面的是一样的**。」
⇒ 「日期 + 时间」那一套控件抽成了共用组件(见该文件头),日程与观影都用它。
⚠️ 本文件里原来的 `dateTimeRow` / `dateTimePickerSheet` / `cnDate` /
`parseDate` / `parseTime` / `pad` 全部删掉,改调这里的 —— 两份实现必然漂移,
用户这次点名的正是"两边不一致"。 */
import {
FbDateTimeRow,
FbDateTimePicker,
fbCnDate,
fbPad2,
fbToday,
fbParseDate,
fbParseTime,
fbDateOf,
fbTimeOf,
fbIsoOf,
fbAddHour,
// 工单 #34 追加需求:点「开始」确定后,结束 = 开始 + 1 小时(跨天自动进位)
fbPlusHours,
FbDateTime
} from './FbDateTimeField';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbEventEdit';
/** 贴底卡片的手势区缓冲(同其他浮层) */
const FB_SHEET_BOTTOM_GAP: number = 16;
/**
* 提醒选项(分钟 → 文案)。
*
* 🔴 与 SyncCalendar 的 `reminderChip` 同一组值(0/5/10/15/30/60/1440)——
* 两边口径一致,用户在两个 App 里看到的选项不会不一样。
*/
interface FbRemindOpt {
min: number;
label: string;
}
const REMIND_OPTS: FbRemindOpt[] = [
{ min: 0, label: '不提醒' },
{ min: 5, label: '提前5分钟' },
{ min: 10, label: '提前10分钟' },
{ min: 15, label: '提前15分钟' },
{ min: 30, label: '提前30分钟' },
{ min: 60, label: '提前1小时' },
{ min: 1440, label: '提前1天' }
];
/**
* 重复选项(key → 文案 + RRULE)。
*
* ⚠️ 只做**最常见的那几种**(对齐 SyncCalendar 的 chip 组),
* "每两周/自定义"这类留给网页端 —— 手机上点五层下拉去拼 RRULE 得不偿失。
* ⚠️ 端上拼的是**完整 RRULE 串**,服务端 `rruleFromBody()` 也认这个口径。
*/
interface FbRepeatOpt {
key: string;
label: string;
rule: string;
}
const REPEAT_OPTS: FbRepeatOpt[] = [
{ key: 'none', label: '不重复', rule: '' },
{ key: 'daily', label: '每天', rule: 'FREQ=DAILY' },
{ key: 'weekday', label: '工作日', rule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' },
{ key: 'weekly', label: '每周', rule: 'FREQ=WEEKLY' },
{ key: 'monthly', label: '每月', rule: 'FREQ=MONTHLY' },
{ key: 'yearly', label: '每年', rule: 'FREQ=YEARLY' }
];
@ComponentV2
export struct FbEventEditSheet {
/** 要编辑的日程(null + isCreate=false 表示没打开;isCreate=true 时是"新建") */
@Param event: FbCalEventFull | null = null;
/** 新建模式 */
@Param isCreate: boolean = false;
/** 新建时预填的日期(`YYYY-MM-DD`,从"点哪天的格子"来) */
@Param presetDate: string = '';
/** 可选的日历本(新建时要选落到哪本) */
@Param books: FbCalBook[] = [];
/**
* 卡片外圈描边环的颜色(hex,'' = 用日程模块色)。
*
* ⚠️ 必须是字符串:环色要直接当背景色(`FbColors.brand` 是 `Resource`,用不了)。
*/
@Param accent: string = '';
/** 环色:显式给了就用给的,否则取日程模块色 */
private accentColor(): string {
if (this.accent !== '') {
return this.accent;
}
const m: FbModuleDef | undefined = fbModuleOf('calendar');
return fbThemeVars(m !== undefined ? m.color : '#A84A28').brand;
}
/** 取消 */
@Event onCancel: () => void = (): void => {
};
/** 保存成功 */
@Event onSaved: () => void = (): void => {
};
/** 删除成功 */
@Event onDeleted: () => void = (): void => {
};
// ---- 表单状态 ----
@Local fTitle: string = '';
@Local fAllDay: boolean = false;
/** `YYYY-MM-DD` */
@Local fStartDate: string = '';
/** `HH:mm` */
@Local fStartTime: string = '09:00';
@Local fEndDate: string = '';
@Local fEndTime: string = '10:00';
@Local fPlace: string = '';
@Local fNote: string = '';
/** 重复的 key(REPEAT_OPTS 里那个) */
@Local fRepeat: string = 'none';
/**
* 🔴 用户**是否亲手点过**重复选项(v86d)。
*
* 为什么要这一位:`save()` 里有个保护逻辑 —— 原日程的 `rrule` 若非空
* 而端上认不出(归到 `none`),就不发 `rrule`,免得把人家自定义的规则清掉。
* 但这条保护**误伤了一个常见操作**:原规则是「每天」,用户改选「不重复」,
* 此时 `fRepeat === 'none'` 且 `event.rrule !== ''` ⇒ 被判成"认不出"⇒
* **整项跳过、规则原样保留** ⇒ 用户看着选了不重复,存完还是每天,且毫无提示。
* ⇒ 用这一位区分"归入 none"(初始态/认不出)与"主动选了 none"(要清空)。
*/
@Local fRepeatTouched: boolean = false;
/** 提醒分钟(可多选;空数组 = 不提醒) */
@Local fRemind: number[] = [];
/** 选中的日历本 id */
@Local fBookId: number = 0;
@Local busy: boolean = false;
@Local err: string = '';
@Local confirmDel: boolean = false;
@Local deleting: boolean = false;
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar 的 `dateTimePickerSheet`)----
/**
* 弹层开着吗。
*
* 🔴 **为什么不是"就地展开"**(这是用户 2026-10-16 的第二条反馈):
* SyncCalendar 的做法是**一个底部弹层里 `DatePicker` 与 `TimePicker` 并排**
* (见其 `EventEditPage.dateTimePickerSheet`),选完点「确定」一次回填。
* 我第一版做成了"点日期块就地展开一个 DatePicker、点时间块再展开一个 TimePicker"
* —— 那是两个控件、分两次选,不是它的做法。
*/
@Local pickOpen: boolean = false;
/** 正在选的是"结束"(false = 开始) */
@Local pickIsEnd: boolean = false;
/** 弹层里的**草稿**值:点「确定」才写回 `fStartXxx`/`fEndXxx`(取消则丢弃) */
@Local pickDate: Date = new Date();
@Local pickHour: number = 9;
@Local pickMin: number = 0;
aboutToAppear(): void {
const e: FbCalEventFull | null = this.event;
if (e !== null) {
// ---- 编辑:回填 ----
this.fTitle = e.title;
this.fAllDay = e.all_day;
this.fStartDate = fbDateOf(e.starts_at, this.todayStr());
this.fStartTime = fbTimeOf(e.starts_at, '09:00');
this.fEndDate = e.ends_at !== '' ? fbDateOf(e.ends_at, this.todayStr()) : fbDateOf(e.starts_at, this.todayStr());
this.fEndTime = e.ends_at !== '' ? fbTimeOf(e.ends_at, '10:00') : this.addHour(e.starts_at);
this.fPlace = e.location;
this.fNote = e.note;
this.fRepeat = this.repeatKeyOf(e.rrule);
this.fRemind = this.minsOf(e.reminder_minutes);
this.fBookId = e.calendar_id;
return;
}
// ---- 新建:预填 ----
const d: string = this.presetDate !== '' ? this.presetDate : fbToday();
this.fStartDate = d;
this.fEndDate = d;
// 新建时把**开始时间**预填成"下一个整点"、结束 +1 小时 ——
// 比固定 09:00 更符合"现在就要加一条"的场景(打开就能直接存)。
const now: Date = new Date();
const nh: number = Math.min(now.getHours() + 1, 22);
this.fStartTime = fbPad2(nh) + ':00';
this.fEndTime = fbPad2(nh + 1 > 23 ? 23 : nh + 1) + ':00';
this.fBookId = this.defaultBookId();
}
// ============================================================
// 取值 / 换算
// ============================================================
private todayStr(): string {
return fbToday();
}
/** 给一个时刻的时分 +1 小时(当结束时间没填时的兜底) */
private addHour(dt: string): string {
return fbAddHour(fbTimeOf(dt, '09:00'));
}
/** 默认落到的日历本:设置里选的(若还在可选列表里),否则第一本可写的 */
private defaultBookId(): number {
const want: number = FB_CAL_SETTINGS.getBookId();
for (const b of this.books) {
if (b.id === want && b.canWrite) {
return b.id;
}
}
for (const b of this.books) {
if (b.canWrite) {
return b.id;
}
}
return 0;
}
/** 重复:从 RRULE 串反推是哪个 chip(认不出就归"不重复") */
private repeatKeyOf(rrule: string): string {
if (rrule === '') {
return 'none';
}
for (const o of REPEAT_OPTS) {
if (o.rule !== '' && rrule.indexOf(o.rule) === 0) {
return o.key;
}
}
// 认不出的复杂规则:给"不重复"更危险(会覆盖掉原规则),
// 但端上确实没有编辑自定义规则的能力 ⇒ 保留原文不覆盖(见 save() 的处理)
return 'none';
}
/** 重复 chip → RRULE 串 */
private ruleOfRepeat(): string {
for (const o of REPEAT_OPTS) {
if (o.key === this.fRepeat) {
return o.rule;
}
}
return '';
}
/** 提醒串 → 分钟数组 */
private minsOf(s: string): number[] {
const out: number[] = [];
if (s === '') {
return out;
}
for (const x of s.split(',')) {
const n: number = parseInt(x.trim(), 10);
if (!isNaN(n) && n > 0) {
out.push(n);
}
}
return out;
}
/** 登录令牌 */
private token(): string {
return FB_TOKEN_STORE.get();
}
// ============================================================
// 小件
// ============================================================
@Builder
/**
* 一个输入行(**两排版式**,GLOBAL-FORM-SPEC —— 用户 2026-11-05)。
*
* 「点击名册里面的"添加",姓名之类的内容全部都按这种形式显示。
* 它会按照主题色,加粗大一点显示。**所有地方都需要这样改**。」
* 「为了解决字数原因导致输入框左右来回对不齐的问题,我们全部按两排来显示。」
*
* | 项 | 规则 |
* |---|---|
* | 标签颜色 | **主题色** `this.accent`(必填红 `#C0533F`) |
* | 标签字重 | **Bold** |
* | 标签字号 | `fontSmall`(比正文大一号) |
* | 版式 | 标签独占第一排,输入框第二排整宽 |
* | 必填 | 红色,**无星号** |
*/
@Builder
field(label: string, value: string, hint: string, onChange: (v: string) => void,
req: boolean = false) {
Column({ space: 6 }) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(req ? '#C0533F' : this.accent)
.width('100%')
TextInput({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(42)
.width('100%')
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
area(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
TextArea({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(72)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onChange((v: string): void => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 小标题(把一大串字段分开) */
@Builder
section(title: string) {
Text(title)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width('100%')
.margin({ top: 6 })
}
/**
* ⚠️ 这里删掉过两个 `@Builder`,都**不要**加回来:
* · `chip(label, on: boolean, onTap)` —— "重复/提醒点不了"的根因
* (@Builder 值参数不参与观察)⇒ 已换成 `FbEventChip` 组件。
* · `pickRow(label, value, onTap)` —— 被"日期时间并排一行"取代
* (用户要求时间和日期同一行,不再上下堆)⇒ 见 `dateTimeRow`。
*/
build() {
// 🔴 外层 `Stack` 是**必需**的:本组件有**三个**要显示的层
// (编辑卡片 + 日期时间弹层 + 删除确认遮罩),而 build() 只能有一个根节点。
// 🔴 2026-10-19 用户:「所有弹框都加线,编辑也弄成弹框,保持一致。」
// ⇒ 原来 `alignContent: Bottom`(贴底抽屉),现在改成**居中**,
// 与名册的编辑/详情、日程详情同一个形态(四角圆角浮卡 + 主题色环)。
Stack({ alignContent: Alignment.Center }) {
this.editCard()
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar)----
if (this.pickOpen) {
this.dateTimePickerSheet()
}
if (this.confirmDel) {
this.deleteConfirm()
}
}
.width('100%')
.height('100%')
}
@Builder
editCard() {
// ⚠️ 这一层只给**宽度**(92%);卡片自己必须 100%,
// 否则 0.92×0.92 ≈ 84.6%,比别的浮层窄一圈。
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 0 }) {
// ---- 标题栏 ----
Row({ space: 12 }) {
Text(this.isCreate ? '新建日程' : '编辑日程')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick((): void => {
this.onCancel();
})
}
.width('100%')
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 12 }) {
this.field('标题', this.fTitle, '日程得有个标题', (v: string): void => {
this.fTitle = v;
})
// ---- 全天 ----
Row() {
Text('全天')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.fAllDay })
.selectedColor('#4A7C82')
.onChange((on: boolean): void => {
this.fAllDay = on;
})
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
// ---- 日历本 ----
/* 🔴🔴 v84 真机实测(2026-10-24):这一组原来排在「提醒」后面,
而表单内容比卡片(`maxHeight: 86%`)高 ⇒ 它在**滚动区下方**,
首屏只能看到被底边切掉半行的标题(实测 bounds 高 7px)。
用户报「选不了日历本」的直觉来源就是这个:看不见 = 用不了。
⇒ 它是**必选项**(没选就 `请选一本日历` 存不上),
必须排在**首屏可见**的位置:紧跟「全天」,在日期时间之前。
⚠️ 别再往回调到「提醒」后面 —— 那栏一长,它必然被推到屏幕外。 */
if (this.books.length > 0) {
this.section('加入哪本日历')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.books, (b: FbCalBook) => {
FbEventChip({
label: b.name,
active: this.fBookId === b.id,
// 选中时用**这本日历自己的颜色**(一眼看出落到哪本)
activeBg: b.color,
onPick: (): void => {
this.fBookId = b.id;
}
})
}, (b: FbCalBook): string => 'bk_' + b.id.toString())
}
.width('100%')
}
// ---- 日期与时间 ----
// 🔴 照 SyncCalendar:**一行一个"日期 + 时间"控件**(点开一个弹层、
// 里面 DatePicker 与 TimePicker 并排,选完点「确定」)。
// 用户 2026-10-16:「实际上就是把日期和时间放在一个控件里面。」
this.section('日期与时间')
this.dateTimeRow('开始', true)
if (!this.fAllDay) {
this.dateTimeRow('结束', false)
}
// ---- 重复 ----
// 🔴 用 `FbEventChip` **组件**而不是 @Builder ——
// @Builder 的值类型参数(on: boolean)变化**不触发重建**,
// 这正是"重复/提醒点不了"的根因(见该组件文件头)。
this.section('重复')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(REPEAT_OPTS, (o: FbRepeatOpt) => {
FbEventChip({
label: o.label,
active: this.fRepeat === o.key,
onPick: (): void => {
this.fRepeat = o.key;
/* 🔴 v86d(2026-10-24 真机事故):**记下"用户真的点过"**。
详见 `save()` 里 `recognize` 的说明 —— 没有这一位,
用户从「每天」改选「不重复」时,保存会把这项**整条跳过**,
于是"选了不重复、存完还是每天",而且**没有任何报错**。 */
this.fRepeatTouched = true;
}
})
}, (o: FbRepeatOpt): string => 'rp_' + o.key)
}
.width('100%')
.margin({ top: 2 })
// ---- 提醒 ----
this.section('提醒(可多选)')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(REMIND_OPTS, (o: FbRemindOpt) => {
FbEventChip({
label: o.label,
active: this.hasRemind(o.min),
onPick: (): void => {
this.toggleRemind(o.min);
}
})
}, (o: FbRemindOpt): string => 'rm_' + o.min.toString())
}
.width('100%')
.margin({ top: 2 })
// ---- 地点 / 备注 ----
/* 🔴 2026-10-30(工单 #36):地点换成**带联想**的输入框。
用户:「输入一个简单地址、下拉显示详细地址时,不能只把地点告诉我,
一定要把详细地址也放在后面。因为同一个地址可能有好几个,
我根本不知道应该选哪一个。」——与网页端 addr_suggest.js 同一行为。
⚠️ 选中候选后只把**地名**写进 location(与网页端日程那栏一致:
日程没有单独的"详细地址"字段,而带门牌会让后续按名字搜索变难;
详细地址在下拉里已经看到、能区分就够)。 */
FbAddrInput({
label: '地点',
placeholder: '地点(可选,输入后可从下拉选)',
value: this.fPlace,
onValue: (v: string): void => {
this.fPlace = v;
}
})
this.area('备注', this.fNote, '备注(可选)', (v: string): void => {
this.fNote = v;
})
}
.width('100%')
.padding({ top: 14 })
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
/* 🔴🔴 v84 真机实测(2026-10-24):错误提示原来放在**滚动区内部最底部**
(`this.area('备注' …)` 后面),于是——
① 用户在顶部点「创建」时,提示在几百 px 之下,**根本看不见**;
② 就算滚到底,它也被 `Scroll` 底边切掉半行(实测 bounds 高 39px,紧贴 2024 边界)。
现象就是「点了创建什么都没发生」——这是最难查的一类失败(用户只会以为没生效)。
⇒ 挪到**滚动区外面、按钮区上方**:无论滚到哪儿,点完立刻看得见。
⚠️ 不要挪回 Scroll 里:那里的可见性取决于用户当时滚到哪儿,运气好才看得见。 */
if (this.err !== '') {
Text(this.err)
.fontSize(FbMetric.fontTiny)
.fontColor('#A32D2D')
.width('100%')
.margin({ top: 10 })
}
// ---- 底部按钮:删除 / 保存 ----
if (this.deleting) {
Text('删除中…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.textAlign(TextAlign.Center)
.width('100%')
.height(42)
.margin({ top: 16 })
} else {
Row({ space: 10 }) {
if (!this.isCreate) {
// 🔴 用户 2026-10-19:「删除按钮全部用红色背景」
// (原来是红色描边、白底 —— 与"所有删除都红底"不一致)
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.height(42)
.padding({ left: 18, right: 18 })
.backgroundColor(FbColors.red)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.confirmDel = true;
}
})
}
Text(this.busy ? '保存中…' : (this.isCreate ? '创建' : '保存'))
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
// 🔴 底色用**日程模块主题色**(原来写死名册青 #4A7C82,
// 在日程页里看着像走错了页面 —— 用户 2026-10-19)
.backgroundColor(this.busy ? FbColors.faint : this.accentColor())
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
if (!this.busy) {
this.save();
}
})
}
.width('100%')
.margin({ top: 16 })
}
}
.width('100%')
.constraintSize({ maxHeight: '86%' })
.padding({ left: 18, right: 18, top: 18, bottom: 20 })
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
}
/**
* **一个「日期 + 时间」控件**(照 SyncCalendar 的 `EventEditPage`)。
*
* 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimeRow`(**与观影共用**)——
* 用户 2026-10-19:「观影的开始/结束时间应该跟日程里面的是一样的。」
* 这里只负责"把本表单的值传进去 + 接住点击"。
*/
@Builder
dateTimeRow(label: string, isStart: boolean) {
FbDateTimeRow({
label: label,
date: isStart ? this.fStartDate : this.fEndDate,
time: isStart ? this.fStartTime : this.fEndTime,
allDay: this.fAllDay,
accent: this.accentColor(),
onTap: (): void => {
this.openPicker(isStart);
}
})
}
/**
* 打开「日期 + 时间」弹层(把当前值装进草稿)。
*
* ⚠️ 草稿的意义:用户滚了一半又点「取消」⇒ **不写回**(`fStartXxx` 保持原样)。
* 若直接改 `fStartXxx`,"取消"就没法撤销了。
*
* 🔴🔴 v88(工单 #34,用户 2026-10-30):「日程添加时,选择时间,点上面,
* 修改的反而是结束时间。」
*
* 根因:这个参数**原来叫 `isEnd`**,而调用方 `dateTimeRow(label, isStart)`
* 传进来的是 `isStart`。名字一换、语义就反了:
* isStart=true(点「开始」)→ isEnd=true → 整条链路按"结束"处理。
* 而且错得很"自洽"——弹层初值读的是结束时间、写回也写给结束时间、
* 连标题都显示「选择结束时间」,三个地方一起错,所以看着不像坏。
*
* ⇒ 参数统一成 `isStart`(**与调用方同名同义**),内部自己取反。
* 不要图省事在调用处写 `openPicker(!isStart)` —— 双重否定更容易再错一次。
*/
private openPicker(isStart: boolean): void {
this.pickIsEnd = !isStart;
// ⚠️ 草稿值放在宿主,弹层组件 `aboutToAppear` 时从这里读初值 ——
// 组件每次挂载都会重新读一次,所以"点开始 → 取消 → 点结束"拿到的是各自的值。
this.pickDate = fbParseDate(isStart ? this.fStartDate : this.fEndDate);
const tm: Date = fbParseTime(isStart ? this.fStartTime : this.fEndTime);
this.pickHour = tm.getHours();
this.pickMin = tm.getMinutes();
this.pickOpen = true;
}
/**
* 「确定」:把草稿写回。
*
* 🔵 工单 #34 追加需求(用户 2026-11-01):
* 「当我选择了开始时间并确定之后,结束时间要跟着开始时间变成开始时间加1个小时。
* 也就是说,所有时间我们都默认它是一个小时。如果不是一个小时,再让他自己去改。」
*
* ⇒ **点「开始」确定后,结束无条件重置为「开始 + 1 小时」**。
*
* 改动前只在"会变成负时长"时才动结束(`if (结束 < 开始)`),
* 于是用户改了开始、结束还挂在老位置,实际时长变成随机的 —— 就是要灭掉这个。
*
* ⚠️ 改「结束」那条路**一个字不动**:改结束不能影响开始。
* 这正是"默认一小时、不像一小时再自己改"的另一半。
* ⚠️ 跨天用 `fbPlusHours()` 的 `Date` 运算:23:30 + 1h ⇒ **次日 00:30**,
* 日期一起进一天。不要图省事写成 `fbAddHour()` —— 那个封顶 23 点且不带日期。
* ⚠️ 全天日程不参与:时刻部分本来就不生效(`save()` 里全天走 `00:00:00`),
* 这里改了也不会落到服务端,但会让"切回非全天"时看到一个莫名的时间,所以跳过。
*/
private applyPicker(iso: string, hm: string): void {
if (this.pickIsEnd) {
this.fEndDate = iso;
this.fEndTime = hm;
} else {
this.fStartDate = iso;
this.fStartTime = hm;
if (!this.fAllDay) {
// 结束 = 开始 + 1 小时(默认时长一律一小时;要别的时长用户自己去点「结束」改)
const plus: FbDateTime = fbPlusHours(iso, hm, 1);
this.fEndDate = plus.date;
this.fEndTime = plus.time;
}
}
this.pickOpen = false;
}
/**
* 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。
*
* 🔴 v64:实现已抽到 `FbDateTimeField.ets::FbDateTimePicker`(**与观影共用**)。
* 结构(标题 → `Row { DatePicker | TimePicker }` 并排 → 确定)与原来完全一致,
* 差别只是"这一份现在只有一处代码"。
*/
@Builder
dateTimePickerSheet() {
FbDateTimePicker({
title: this.pickIsEnd ? '选择结束时间' : '选择开始时间',
date: fbIsoOf(this.pickDate),
time: fbPad2(this.pickHour) + ':' + fbPad2(this.pickMin),
allDay: this.fAllDay,
accent: this.accentColor(),
onConfirm: (d: string, t: string): void => {
this.applyPicker(d, t);
},
onCancel: (): void => {
this.pickOpen = false;
}
})
}
@Builder
deleteConfirm() {
Column() {
// 🔴 2026-10-19:确认框也加主题色环("所有弹框都加线"不能漏这一层)
Column() {
FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) {
Column({ space: 8 }) {
Text('删除这条日程?')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('删掉之后不能撤销。若这条日程是重复的,这一次和以后的都会不见。')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(3)
Row({ space: 10 }) {
Text('取消')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
})
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.layoutWeight(1)
.height(42)
.backgroundColor(FbColors.red)
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
this.doDelete();
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(18)
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.padding({ left: 32, right: 32 })
.backgroundColor(FB_MASK)
.onClick((): void => {
this.confirmDel = false;
})
}
// ============================================================
// 提醒多选
// ============================================================
private hasRemind(min: number): boolean {
// 0 = "不提醒":选中它等价于"没有任何提醒"
if (min === 0) {
return this.fRemind.length === 0;
}
return this.fRemind.indexOf(min) >= 0;
}
private toggleRemind(min: number): void {
if (min === 0) {
this.fRemind = [];
return;
}
const at: number = this.fRemind.indexOf(min);
if (at >= 0) {
this.fRemind.splice(at, 1);
} else {
this.fRemind.push(min);
}
}
// ============================================================
// 网络
// ============================================================
private async doDelete(): Promise<void> {
const e: FbCalEventFull | null = this.event;
if (e === null) {
return;
}
const token: string = this.token();
if (token === '') {
this.err = '未登录,无法删除';
return;
}
this.deleting = true;
this.err = '';
const res = await FbCalRepo.inst().remove(e.id, token);
this.deleting = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '删除日程失败:%{public}s', res.error);
this.err = '删除失败:' + res.error;
return;
}
this.onDeleted();
}
/** 保存(新建走 POST、编辑走 PATCH) */
private async save(): Promise<void> {
const title: string = this.fTitle.trim();
if (title === '') {
this.err = '日程得有个标题';
return;
}
const token: string = this.token();
if (token === '') {
this.err = this.isCreate ? '未登录,无法创建' : '未登录,无法保存';
return;
}
const isNew: boolean = this.isCreate || this.event === null;
if (isNew && this.fBookId <= 0) {
this.err = '请选一本日历';
return;
}
// ---- 时间:全天归零到 00:00;否则拼上时分 ----
const startAt: string = this.fAllDay
? (this.fStartDate + ' 00:00:00')
: (this.fStartDate + ' ' + this.fStartTime + ':00');
let endAt: string = this.fAllDay
? (this.fEndDate + ' 00:00:00')
: (this.fEndDate + ' ' + this.fEndTime + ':00');
// ⚠️ 结束早于开始 ⇒ 补成同一时刻(服务端不校验时长,但"负时长"看着是坏的)
if (endAt < startAt) {
endAt = startAt;
}
this.busy = true;
this.err = '';
const input: FbCalEventInput = {
title: title,
location: this.fPlace.trim(),
starts_at: startAt,
ends_at: endAt,
all_day: this.fAllDay,
note: this.fNote,
reminder_minutes: this.fRemind
};
/* ⚠️ 重复:只在**能确定用户意图**时才发。
两种情况会走到这里:
① 用户**亲手点过**某个 chip(含"不重复")⇒ 以他点的为准,必须发。
② 用户没点过,但原日程的 `rrule` 本来就认得出(或本来就是空)⇒ 发原值。
留空的唯一情形:用户没点过、且原规则非空又认不出(自定义重复)——
这时发空 `rrule` 会把人家的规则**清掉**,所以宁可不发。
🔴 v86d 修的正是漏掉情况 ① 里的"主动选不重复":
原写法只判 `fRepeat !== 'none'`,于是"每天 → 不重复"被判成认不出,
整项跳过 ⇒ 存完还是每天(真机实测,用户会以为点了没生效)。 */
const recognize: boolean = this.fRepeatTouched
|| this.fRepeat !== 'none'
|| (this.event !== null && this.event.rrule === '');
if (recognize) {
input.rrule = this.ruleOfRepeat();
}
if (isNew) {
input.calendar_id = this.fBookId;
}
const repo: FbCalRepo = FbCalRepo.inst();
const res = isNew ? await repo.create(input, token) : await repo.update(this.event!.id, input, token);
this.busy = false;
if (!res.ok) {
hilog.warn(DOMAIN, TAG, '保存日程失败:%{public}s', res.error);
this.err = (isNew ? '创建失败:' : '保存失败:') + res.error;
return;
}
// 新建时把选的日历本记成"以后默认用它"(与 SyncCalendar 的默认本同一体验)
if (isNew) {
FB_CAL_SETTINGS.setBookId(this.fBookId);
}
this.onSaved();
}
}