Files
FamilyBook_harmonyos/entry/src/main/ets/components/FbEventEditSheet.ets
T
laoyang 7858de27e3 v0.8.4 简要卡观感 / 分享 / 名册搜索 / 全局返回手势(v59e)
八条需求的落地与真因:

1. 浮层与白底"糊在一起"、看不清 —— 根因不是配色,是**遮罩从来没画出来过**:
   ArkUI 的八位色值是 #AARRGGBB(alpha 在前),而全应用写的是 '#00000066'
   (本意"黑 40%")⇒ alpha = 0x00 ⇒ 全透明。同型还有 brand + '1A'(拼到了蓝通道)
   与 shadow('#00000033')。⇒ 17 个文件统一改走 fbAlpha() / FB_MASK / FB_SHADOW /
   FB_HAIRLINE;真机像素采样复验:遮罩 (148,146,140)、卡片白、浅底 (237,242,243)。
2. 简要卡外圈加**主题色描边环**(外层铺色 + padding,不是 border —— border 会被
   内层圆角切出白缝)。
3. 简要卡**去掉删除按钮**,删除挪到完整详情卡最下面(Divider + 红字块)。
   顺手修一处静默失败:confirmDelete() 原来只取 quickSource(),
   从家谱直接开详情时 quickPerson 为 null ⇒ 点删除毫无反应。
4. 信息行间距 10 → 16(每行再各加 3vp 上下内边距,实测行距 134/118px)。
5. 主电话整行可点**直接拨号**(call.makeCall,落到系统拨号盘)。
6. 按钮改为 详情 / 编辑 / 分享 / 关闭;分享走系统分享面板,
   内容为纯文本「姓名 / 电话 / 邮箱」三项(用户点名的就是这三项,
   不多带单位),面板拉不起来时兜底复制到剪贴板并提示。
7. 名册搜索"完全不能用"有两个独立真因:
   ① 搜索框是 List 的第一个 item,无结果时整个 List 被空态替换 ⇒ 输入框被卸载、
      焦点丢失 ⇒ 输不进第二个字。改:List 无条件渲染,空态自身做成 ListItem。
   ② rowToPerson 把 initials 写死成空串,服务端下发的拼音首字母从未落库 ⇒
      拼音搜索恒 0 结果。改:FB_DB_VERSION 6→7 + PRAGMA 探测补列 + 同步时落库;
      匹配同时遍历全部电话(原来只比主号)。
   另加搜索框「清空」(24×24 命中区)。
8. 全局返回手势:应用是单页 + 模块分发、没有路由栈 ⇒ 自建 FB_BACK 登记表
   (倒序询问)+ moduleHistory + applyModule()(不记历史,否则 A/B 两页来回弹)。
   9 个页面登记/注销;Index.onBackPress 先关浮层 → 回上一模块 → 才 return false
   交给系统退桌面(onBackPress 必须声明 boolean,返回 void 会被当成没接管)。

验证:
- 静态守卫 417 / 0(新增 ⑤阴影·描边·色环 ⑥透明度色 ⑦返回手势 ⑧搜索 ⑨分享)
- 反向验证 10 / 0(9 条注入全部能让守卫变红;其中"遮罩换纯黑"那条暴露了
  一条存在型假绿,已改成数个数 + 禁止 Color.Black)
- 建表/迁移列一致性守卫 12 / 0
- 真机全模块遍历 14 / 14;真机逐项复核 12 / 0 与 7 / 0

工具侧顺带修好两个过期的探针:
- 遍历探针仍用"应用内横滑"打开抽屉,而该手势早已按用户要求去掉、入口改为顶栏图标
  ⇒ 一次全量遍历里 12 个模块全报"抽屉里没找到"(像应用坏了,其实是探针过期)。
- 资源/笔记两页写死了早已不存在的条目(户口本/房产证/阳台改造清单)⇒ 改成
  结构性文案(副标题 + 常驻筛选胶囊),不随用户数据变化而恒红。
2026-10-18 23:12:57 +08:00

908 lines
32 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 { 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';
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[] = [];
/** 取消 */
@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() 只能有一个根节点。
// ⚠️ `alignContent: Bottom` 让卡片/弹层都贴底(同一形态)。
Stack({ alignContent: Alignment.Bottom }) {
this.editCard()
// ---- 「日期 + 时间」选择弹层(照 SyncCalendar)----
if (this.pickOpen) {
this.dateTimePickerSheet()
}
if (this.confirmDel) {
this.deleteConfirm()
}
}
.width('100%')
.height('100%')
}
@Builder
editCard() {
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) {
Text('删除')
.fontSize(FbMetric.fontSmall)
.fontColor('#C0533F')
.textAlign(TextAlign.Center)
.height(42)
.padding({ left: 18, right: 18 })
.border({ width: 1, color: '#C0533F' })
.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)
.backgroundColor(this.busy ? '#4A7C8288' : '#4A7C82')
.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 + FB_SHEET_BOTTOM_GAP })
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.alignItems(HorizontalAlign.Start)
}
/**
* **一个「日期 + 时间」控件**(照 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() {
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%')
.backgroundColor(FbColors.surface)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
.padding({ left: 18, right: 18, top: 8, bottom: 20 + FB_SHEET_BOTTOM_GAP })
.alignItems(HorizontalAlign.Start)
}
/** `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() {
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('#C0533F')
.borderRadius(FbMetric.radiusS)
.onClick((): void => {
this.confirmDel = false;
this.doDelete();
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(18)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
.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
};
// ⚠️ 重复:只在用户**确实选过**时才发。
// 编辑一条"自定义重复"的日程时,端上认不出来(归到"不重复"),
// 若照样发个空 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();
}
}