/** * 家书 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'; 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'; /** 提醒分钟(可多选;空数组 = 不提醒) */ @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 = this.dateOf(e.starts_at); this.fStartTime = this.timeOf(e.starts_at); this.fEndDate = e.ends_at !== '' ? this.dateOf(e.ends_at) : this.dateOf(e.starts_at); this.fEndTime = e.ends_at !== '' ? this.timeOf(e.ends_at) : 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 : this.todayStr(); 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 = this.pad(nh) + ':00'; this.fEndTime = this.pad(nh + 1 > 23 ? 23 : nh + 1) + ':00'; this.fBookId = this.defaultBookId(); } // ============================================================ // 取值 / 换算 // ============================================================ private todayStr(): string { const n: Date = new Date(); return this.pad(n.getFullYear()) + '-' + this.pad(n.getMonth() + 1) + '-' + this.pad(n.getDate()); } private pad(n: number): string { return n < 10 ? ('0' + n.toString()) : n.toString(); } /** 从 `YYYY-MM-DD HH:MM:SS` 取日期段 */ private dateOf(dt: string): string { return dt.length >= 10 ? dt.substring(0, 10) : this.todayStr(); } /** 从 `YYYY-MM-DD HH:MM:SS` 取时间段的 `HH:mm` */ private timeOf(dt: string): string { return dt.length >= 16 ? dt.substring(11, 16) : '09:00'; } /** 给一个时间的时分 +1 小时(当结束时间没填时的兜底) */ private addHour(dt: string): string { const hh: number = dt.length >= 16 ? parseInt(dt.substring(11, 13), 10) : 9; const h2: number = Math.min(hh + 1, 23); return this.pad(h2) + ':' + (dt.length >= 16 ? dt.substring(14, 16) : '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 field(label: string, value: string, hint: string, onChange: (v: string) => void) { Column({ space: 4 }) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) TextInput({ text: value, placeholder: hint }) .placeholderColor(FbColors.faint) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) .backgroundColor(FbColors.inputBg) .borderRadius(FbMetric.radiusS) .height(40) .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) // ---- 日期与时间 ---- // 🔴 照 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; } }) }, (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 }) // ---- 日历本 ---- 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%') } // ---- 地点 / 备注 ---- this.field('地点', this.fPlace, '地点(可选)', (v: string): void => { this.fPlace = v; }) this.area('备注', this.fNote, '备注(可选)', (v: string): void => { this.fNote = v; }) if (this.err !== '') { Text(this.err) .fontSize(FbMetric.fontTiny) .fontColor('#A32D2D') .width('100%') } } .width('100%') .padding({ top: 14 }) } .layoutWeight(1) .width('100%') .scrollBar(BarState.Off) .align(Alignment.TopStart) // ---- 底部按钮:删除 / 保存 ---- 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`)。 * * 🔴 用户 2026-10-16:「你参考一下 SyncCalendar 的做法,实际上就是 * **把日期和时间放在一个控件里面**。」 * 它的做法(`dateTimePickerSheet`):**一个底部弹层里 `DatePicker` 与 * `TimePicker` 并排**,选完点「确定」一次回填。 * ⚠️ 我第一版做成了"点日期块就地展开 DatePicker、点时间块再展开 TimePicker" * —— 那是**两个控件、分两次选**,形态与它不同(也不如一次选完顺手)。 * * 控件本身是**一整块可点的框**,显示 `10月16日 周五 20:00`; * 点它 → 打开弹层(里面日期/时间并排)→ 「确定」写回。 */ @Builder dateTimeRow(label: string, isStart: boolean) { Row({ space: 10 }) { Text(label) .fontSize(FbMetric.fontTiny) .fontColor(FbColors.faint) .width(28) // 整块可点:日期与时间在**同一个控件**里,中间不再分两块 Row() { Text(this.cnDate(isStart ? this.fStartDate : this.fEndDate)) .fontSize(FbMetric.fontSmall) .fontColor(FbColors.ink) if (!this.fAllDay) { Text(' ' + (isStart ? this.fStartTime : this.fEndTime)) .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.openPicker(isStart); }) // 右侧小箭头:暗示"点开会选"(与 SyncCalendar 的行样式一致) Text('›') .fontSize(FbMetric.fontBody) .fontColor(FbColors.faint) } .width('100%') .alignItems(VerticalAlign.Center) } /** * 打开「日期 + 时间」弹层(把当前值装进草稿)。 * * ⚠️ 草稿的意义:用户滚了一半又点「取消」⇒ **不写回**(`fStartXxx` 保持原样)。 * 若直接改 `fStartXxx`,"取消"就没法撤销了。 */ private openPicker(isEnd: boolean): void { this.pickIsEnd = isEnd; const d: Date = this.parseDate(isEnd ? this.fEndDate : this.fStartDate); this.pickDate = d; const tm: Date = this.parseTime(isEnd ? this.fEndTime : this.fStartTime); this.pickHour = tm.getHours(); this.pickMin = tm.getMinutes(); this.pickOpen = true; } /** 「确定」:把草稿写回;开始改了还会把结束顺延(避免"负时长") */ private applyPicker(): void { const iso: string = this.pad(this.pickDate.getFullYear()) + '-' + this.pad(this.pickDate.getMonth() + 1) + '-' + this.pad(this.pickDate.getDate()); const hm: string = this.pad(this.pickHour) + ':' + this.pad(this.pickMin); if (this.pickIsEnd) { this.fEndDate = iso; this.fEndTime = hm; } else { this.fStartDate = iso; this.fStartTime = hm; // ⚠️ 开始改到"结束之后" ⇒ 把结束顺延到同一时刻(服务端不校验,但负时长是坏的) if (this.fEndDate < iso || (this.fEndDate === iso && this.fEndTime < hm)) { this.fEndDate = iso; this.fEndTime = hm; } } this.pickOpen = false; } /** * 日期 + 时间选择弹层(**照 SyncCalendar 的 `dateTimePickerSheet`**)。 * * 结构:标题 → `Row { DatePicker | TimePicker }`(**并排**)→ 「确定」。 * ⚠️ 滚轮的 `textStyle`/`selectedTextStyle`/`disappearTextStyle` **要显式给色**: * 默认取系统主题色,深色模式下会和弹层底色撞色(SyncCalendar 注释里点明过)。 * ⚠️ 全天日程没有"时刻"可言 ⇒ 只画 `DatePicker`。 */ @Builder dateTimePickerSheet() { // 🔴 2026-10-19:与其它弹框统一(92% 宽 + 四角圆角 + 主题色环)。 Column() { FbSheetRing({ radius: FbMetric.radius, ringColor: this.accentColor() }) { Column({ space: 14 }) { Text(this.pickIsEnd ? '选择结束时间' : '选择开始时间') .fontSize(FbMetric.fontTitle) .fontWeight(FontWeight.Medium) .fontColor(FbColors.ink) .width('100%') .textAlign(TextAlign.Center) .padding({ top: 12 }) Row({ space: 6 }) { DatePicker({ start: new Date('2000-01-01'), end: new Date('2035-12-31'), selected: this.pickDate }) .onDateChange((v: Date): void => { this.pickDate = 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.fAllDay) { TimePicker({ selected: new Date(2000, 0, 1, this.pickHour, this.pickMin) }) .useMilitaryTime(true) .onChange((v: TimePickerResult): void => { // ⚠️ `hour`/`minute` 是**数字**;这里存草稿数字,拼 `HH:MM` 时再补零 this.pickHour = v.hour; this.pickMin = 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%') Button(this.fAllDay ? '确定日期' : '确定') .width('100%') .height(44) .borderRadius(FbMetric.radiusS) .fontSize(FbMetric.fontBody) .backgroundColor('#4A7C82') .fontColor(Color.White) .onClick((): void => { this.applyPicker(); }) } .width('100%') .padding({ left: 18, right: 18, top: 8, bottom: 20 }) .alignItems(HorizontalAlign.Start) } } .width('92%') } /** `YYYY-MM-DD` → `10月16日 周五`(比裸日期好读,且和时刻并排也不长) */ private cnDate(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()]; } private parseDate(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); } private parseTime(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)); } return out; } @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 { 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 { 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 }; // ⚠️ 重复:只在用户**确实选过**时才发。 // 编辑一条"自定义重复"的日程时,端上认不出来(归到"不重复"), // 若照样发个空 rrule,就会把人家原来的规则**清掉**。 // ⇒ 规则原文认不出(非空且 chip 是 none)⇒ 这一项**不发**,保持原样。 const recognize: boolean = 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(); } }