① 编辑行程的视频附件不显示 根因:照片墙对所有附件都用 Image 渲染,视频是 .mp4, Image 加载不出来 ⇒ 空白块 ⇒ 用户以为"视频没带进来"。 按 mime 分流:video/ → 深色底 + ▶(不取首帧,端上解视频首帧很卡)。 ② 新增共享组件 FbFormField.ets(GLOBAL-FORM-SPEC) 5 个表单原来各写一份 field()/labelRow(),改一处要改五处, 而且外层结构不一致(有的 Row 有的 Column),批量脚本改坏过两次。 收敛到一个组件,表单只调用不自己实现。 ③ 全量铺开 - FbPersonEditSheet 姓名标必填(红色无星号) - FbEventEditSheet 日程 field 两排 - FbMovieNewSheet 片名/影院/同看的人/海报/观影照片/简介/备注 全部两排 - FbDateTimeField FbDateTimeRow 改两排(电影+日程同时生效) 规范:标签主题色 + Bold + fontSmall,必填红 #C0533F 无星号, 标签独占第一排、控件第二排整宽。
942 lines
36 KiB
Plaintext
942 lines
36 KiB
Plaintext
/**
|
||
* 家书 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();
|
||
}
|
||
} |