2026-09-13 15:50:37 +08:00
|
|
|
|
// entry/src/main/ets/pages/EventEditPage.ets
|
2026-09-16 22:11:42 +08:00
|
|
|
|
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
|
2026-10-21 09:03:31 +08:00
|
|
|
|
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
|
2026-09-13 15:50:37 +08:00
|
|
|
|
import { common } from '@kit.AbilityKit';
|
|
|
|
|
|
import { BusinessError } from '@kit.BasicServicesKit';
|
2026-09-16 20:54:41 +08:00
|
|
|
|
import { notificationManager } from '@kit.NotificationKit';
|
2026-09-16 22:11:42 +08:00
|
|
|
|
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
|
2026-09-13 15:50:37 +08:00
|
|
|
|
import { EventDb, LocalEvent } from '../common/EventDb';
|
2026-09-13 20:25:18 +08:00
|
|
|
|
import { AppSettings } from '../common/AppSettings';
|
2026-09-13 15:50:37 +08:00
|
|
|
|
import { DavClient } from '../common/DavClient';
|
|
|
|
|
|
import { SyncEngine } from '../common/SyncEngine';
|
2026-09-13 23:35:52 +08:00
|
|
|
|
import { ReminderService } from '../common/ReminderService';
|
|
|
|
|
|
import { LogUtil } from '../common/LogUtil';
|
2026-10-09 21:40:53 +08:00
|
|
|
|
import { IcsUtil } from '../common/IcsUtil';
|
|
|
|
|
|
import { RruleUtil } from '../common/RruleUtil';
|
2026-10-21 07:59:21 +08:00
|
|
|
|
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
|
2026-10-09 21:40:53 +08:00
|
|
|
|
|
|
|
|
|
|
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
|
|
|
|
|
|
class SeriesBounds {
|
|
|
|
|
|
thisOcc: number = 0; // 本次目标的(原始)发生时刻
|
|
|
|
|
|
beforeThis: number = 0; // 此次之前一点(截断 UNTIL 用),= 上一次与本次的中点或本次-1s
|
|
|
|
|
|
nextOcc: number = 0; // 下一次发生时刻(0 = 已是最后一次)
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
|
|
|
|
|
|
/** 可选的日历本 */
|
|
|
|
|
|
class BookChoice {
|
|
|
|
|
|
calKey: string = '';
|
|
|
|
|
|
href: string = '';
|
|
|
|
|
|
name: string = '';
|
|
|
|
|
|
color: string = '#007DFF';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Entry
|
|
|
|
|
|
@Component
|
|
|
|
|
|
struct EventEditPage {
|
|
|
|
|
|
@State title: string = '';
|
|
|
|
|
|
@State location: string = '';
|
2026-10-21 07:59:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 地点补全(鸿蒙 Map Kit / Site Kit):
|
2026-10-21 09:03:31 +08:00
|
|
|
|
* placeItems —— 候选数据(**不参与页面布局**,由 bindPopup 渲染成浮层)
|
|
|
|
|
|
* placeLoading —— 是否正在请求,浮层内显示"搜索中"
|
|
|
|
|
|
* placeOpen —— 浮层是否弹出(绑在输入框上,向上弹)
|
|
|
|
|
|
* placeHint —— 无结果/地图能力不可用时的提示(不阻塞手输地点)
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么必须是浮层而不是内联列表:内联列表改变表单高度 ⇒ 输入框跟着移动 ⇒
|
|
|
|
|
|
* 每次"搜索中/出结果/结果变少"都要滚动纠正 ⇒ 用户看到输入框反复上下跳。
|
2026-10-21 07:59:21 +08:00
|
|
|
|
*/
|
|
|
|
|
|
@State placeItems: PlaceItem[] = [];
|
|
|
|
|
|
@State placeLoading: boolean = false;
|
|
|
|
|
|
@State placeOpen: boolean = false;
|
|
|
|
|
|
@State placeHint: string = '';
|
|
|
|
|
|
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
|
|
|
|
|
|
private placePicked: boolean = false;
|
2026-10-21 08:52:13 +08:00
|
|
|
|
/**
|
2026-10-21 09:03:31 +08:00
|
|
|
|
* 最后一次**程序写入**输入框的文本(选中候选后回写的地址)。
|
|
|
|
|
|
* TextInput 的 text 被程序改写同样会触发 onChange;用它精确识别"这次 onChange
|
|
|
|
|
|
* 不是用户打字造成的",从而不重复发起搜索。
|
2026-10-21 08:52:13 +08:00
|
|
|
|
*/
|
2026-10-21 09:03:31 +08:00
|
|
|
|
private lastAppliedText: string = '';
|
|
|
|
|
|
/** 地点输入框当前是否聚焦(仅用于日志/状态,不再驱动滚动) */
|
2026-10-21 08:52:13 +08:00
|
|
|
|
private placeFocused: boolean = false;
|
2026-10-21 07:59:21 +08:00
|
|
|
|
/** 输入防抖定时器 */
|
|
|
|
|
|
private placeTimer: number = -1;
|
2026-10-21 09:03:31 +08:00
|
|
|
|
/** 表单滚动控制器(键盘避让由系统 RESIZE 模式负责,这里不再主动滚) */
|
2026-10-21 08:26:16 +08:00
|
|
|
|
private formScroller: Scroller = new Scroller();
|
2026-09-13 15:50:37 +08:00
|
|
|
|
@State description: string = '';
|
|
|
|
|
|
@State allDay: boolean = false;
|
|
|
|
|
|
@State startMs: number = 0;
|
|
|
|
|
|
@State endMs: number = 0;
|
|
|
|
|
|
@State books: BookChoice[] = [];
|
|
|
|
|
|
@State chosenKey: string = '';
|
|
|
|
|
|
@State isSaving: boolean = false;
|
|
|
|
|
|
@State statusMsg: string = '';
|
|
|
|
|
|
@State isExisting: boolean = false;
|
2026-09-13 20:25:18 +08:00
|
|
|
|
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
|
2026-10-10 07:05:32 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 重复的"结束条件"(新建与编辑都可用):
|
|
|
|
|
|
* never 永不结束
|
|
|
|
|
|
* until 重复到某个日期为止(界面上选具体日期)
|
|
|
|
|
|
* count 重复固定次数
|
|
|
|
|
|
*/
|
|
|
|
|
|
@State repeatEnd: string = 'never';
|
|
|
|
|
|
@State repeatUntil: number = 0; // 截止日期(当天 0 点毫秒)
|
|
|
|
|
|
@State repeatCount: number = 10; // 重复次数
|
|
|
|
|
|
/** ⭐ 编辑时载入的原始 RRULE:用于判断"新建的结束条件是否会覆盖已有规则" */
|
|
|
|
|
|
private originalRrule: string = '';
|
|
|
|
|
|
/** ⭐ 发生时刻是否由调用方传入(true=用户点的是某一次,false=用户点的是主事件本身) */
|
|
|
|
|
|
private occProvided: boolean = false;
|
2026-09-13 23:35:52 +08:00
|
|
|
|
@State reminderList: number[] = []; // 提前提醒分钟数(多选),空 = 不提醒
|
2026-10-09 22:41:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 底部弹层:**整个页面只能挂一个 `bindSheet`**。
|
|
|
|
|
|
* ArkUI 里同一组件挂多个 bindSheet 时,后一个会覆盖前一个 —— 实测表现就是
|
|
|
|
|
|
* "时间选择框白框一闪而过、什么都没出来"(范围弹层把时间弹层顶掉了)。
|
|
|
|
|
|
* 因此这里用一个状态 + 一个模式来复用同一个弹层:
|
|
|
|
|
|
* sheetMode = 'picker' → 日期/时间选择
|
|
|
|
|
|
* sheetMode = 'scope' → 重复日程范围选择
|
|
|
|
|
|
*/
|
|
|
|
|
|
@State sheetShow: boolean = false;
|
|
|
|
|
|
@State sheetMode: string = 'picker';
|
2026-09-13 20:25:18 +08:00
|
|
|
|
@State pickerIsEnd: boolean = false;
|
|
|
|
|
|
@State pickDate: Date = new Date();
|
|
|
|
|
|
@State pickHour: number = 9;
|
|
|
|
|
|
@State pickMin: number = 0;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
|
|
|
|
|
|
@State scope: string = 'all';
|
|
|
|
|
|
private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程
|
2026-10-09 22:19:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 载入时的**原始行快照**(不可变)。
|
|
|
|
|
|
* `doSave` 会就地改写 `this.event` 的 calKey/href/startTime 等字段,
|
|
|
|
|
|
* 而"重复系列"的范围计算(EXDATE / UNTIL / 拆系列)必须依据**原始**的
|
|
|
|
|
|
* 系列标识(原 calKey、原 href、原发生时刻),否则会算错。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private original: LocalEvent | null = null;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
/** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */
|
|
|
|
|
|
private occStartMs: number = 0;
|
2026-09-13 15:50:37 +08:00
|
|
|
|
private event: LocalEvent | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
aboutToAppear(): Promise<void> {
|
2026-10-21 08:26:16 +08:00
|
|
|
|
// 键盘避让不阻塞页面加载:拿窗口是异步的,失败也只是少一层滚动校正
|
|
|
|
|
|
this.setupKeyboardAvoid();
|
2026-09-13 15:50:37 +08:00
|
|
|
|
return this.initPage();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 09:03:31 +08:00
|
|
|
|
/** 页面销毁:清掉地点补全的防抖定时器 */
|
2026-10-21 07:59:21 +08:00
|
|
|
|
aboutToDisappear(): void {
|
|
|
|
|
|
this.cancelSuggestTimer();
|
2026-10-21 08:26:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 键盘避让:改成 RESIZE 模式。
|
2026-10-21 09:03:31 +08:00
|
|
|
|
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕。
|
2026-10-21 08:26:16 +08:00
|
|
|
|
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
|
2026-10-21 09:03:31 +08:00
|
|
|
|
* 聚焦的输入框由系统自动滚入可见区,**无需再手动 scrollBy**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 曾经写过"手动量矩形 + scrollBy 对齐"的方案,是错的方向:
|
|
|
|
|
|
* ① getRectangleById 返回 px、scrollBy 收 vp,单位混用会把输入框推飞;
|
|
|
|
|
|
* ② 更根本的是——只要候选参与布局,位置就会反复变化,
|
|
|
|
|
|
* 靠"事后纠正"必然表现为抖动。所以候选改成了浮层(见 placePopup)。
|
2026-10-21 08:26:16 +08:00
|
|
|
|
*/
|
2026-10-21 09:03:31 +08:00
|
|
|
|
private setupKeyboardAvoid(): void {
|
2026-10-21 08:26:16 +08:00
|
|
|
|
try {
|
|
|
|
|
|
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const e = err as BusinessError;
|
|
|
|
|
|
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
|
|
|
|
|
|
}
|
2026-10-21 07:59:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 15:50:37 +08:00
|
|
|
|
private async initPage(): Promise<void> {
|
|
|
|
|
|
const context = this.getUIContext().getHostContext();
|
|
|
|
|
|
if (context === undefined) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-16 22:11:42 +08:00
|
|
|
|
// 收集可写入的日历本:只取 **CalDAV** 可写日历本("本机"不再出现在选择里);
|
|
|
|
|
|
// 只读日历本同样不出现
|
|
|
|
|
|
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableDavSources(context);
|
2026-09-13 15:50:37 +08:00
|
|
|
|
const choices: BookChoice[] = [];
|
|
|
|
|
|
for (const s of sources) {
|
|
|
|
|
|
const b = new BookChoice();
|
|
|
|
|
|
b.calKey = s.calKey;
|
|
|
|
|
|
b.href = s.href;
|
|
|
|
|
|
b.name = s.name;
|
|
|
|
|
|
b.color = s.color;
|
|
|
|
|
|
choices.push(b);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.books = choices;
|
2026-09-16 22:11:42 +08:00
|
|
|
|
if (choices.length === 0) {
|
|
|
|
|
|
// 没有可写入的 CalDAV 日历本 → 日程无处可写,给明确指引(保存时也会再拦一次)
|
|
|
|
|
|
this.statusMsg = '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号';
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
|
|
|
|
|
|
// 编辑既有事件
|
|
|
|
|
|
const pendingId: number | undefined = AppStorage.get<number>('pendingEventId');
|
|
|
|
|
|
if (pendingId !== undefined && pendingId > 0) {
|
|
|
|
|
|
const loaded = await EventDb.getById(context, pendingId);
|
|
|
|
|
|
if (loaded !== null) {
|
|
|
|
|
|
this.event = loaded;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event)。
|
|
|
|
|
|
// ⚠️ 必须是**完整**拷贝 —— 曾经漏了 title/description/location/reminders,
|
|
|
|
|
|
// 导致"只改这一次"时把这一行还原成空标题(BUG:已有标题被删没)。
|
2026-10-09 22:19:31 +08:00
|
|
|
|
const snap = new LocalEvent();
|
|
|
|
|
|
snap.id = loaded.id;
|
|
|
|
|
|
snap.uid = loaded.uid;
|
|
|
|
|
|
snap.calKey = loaded.calKey;
|
|
|
|
|
|
snap.href = loaded.href;
|
|
|
|
|
|
snap.remotePath = loaded.remotePath;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
snap.title = loaded.title;
|
|
|
|
|
|
snap.description = loaded.description;
|
|
|
|
|
|
snap.location = loaded.location;
|
2026-10-09 22:19:31 +08:00
|
|
|
|
snap.startTime = loaded.startTime;
|
|
|
|
|
|
snap.endTime = loaded.endTime;
|
|
|
|
|
|
snap.recurrenceId = loaded.recurrenceId;
|
|
|
|
|
|
snap.rrule = loaded.rrule;
|
|
|
|
|
|
snap.exdate = loaded.exdate;
|
|
|
|
|
|
snap.isAllDay = loaded.isAllDay;
|
|
|
|
|
|
snap.recurring = loaded.recurring;
|
|
|
|
|
|
snap.deleted = loaded.deleted;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
snap.reminder = loaded.reminder;
|
|
|
|
|
|
snap.reminders = loaded.reminders.slice();
|
2026-10-09 22:19:31 +08:00
|
|
|
|
this.original = snap;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
this.originalRrule = loaded.rrule;
|
2026-09-13 15:50:37 +08:00
|
|
|
|
this.isExisting = true;
|
|
|
|
|
|
this.title = loaded.title;
|
|
|
|
|
|
this.location = loaded.location;
|
|
|
|
|
|
this.description = loaded.description;
|
|
|
|
|
|
this.allDay = loaded.isAllDay;
|
|
|
|
|
|
this.startMs = loaded.startTime;
|
|
|
|
|
|
this.endMs = loaded.endTime;
|
|
|
|
|
|
this.chosenKey = loaded.calKey;
|
2026-10-09 22:19:31 +08:00
|
|
|
|
// ⭐ 若这条日程所属的日历本不在"可写日历本"列表里(例如该本被标记只读、
|
|
|
|
|
|
// 或服务器写权限探测暂时失败),把它补进选择列表,保证 chosenBook() 一定能解析到 ——
|
|
|
|
|
|
// 否则保存会被拦下,用户永远进不到"重复日程范围选择"这一步。
|
|
|
|
|
|
if (loaded.calKey !== LOCAL_CAL_KEY
|
|
|
|
|
|
&& !choices.some((c: BookChoice): boolean => c.calKey === loaded.calKey)) {
|
|
|
|
|
|
const info: BookLookup | null =
|
|
|
|
|
|
await CalendarDataBridge.lookupBook(context, loaded.calKey, loaded.href);
|
|
|
|
|
|
if (info !== null) {
|
|
|
|
|
|
const b = new BookChoice();
|
|
|
|
|
|
b.calKey = info.calKey;
|
|
|
|
|
|
b.href = info.href;
|
|
|
|
|
|
b.name = info.name;
|
|
|
|
|
|
b.color = info.color;
|
|
|
|
|
|
this.books = this.books.concat([b]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-16 22:11:42 +08:00
|
|
|
|
// 历史遗留的"本机"日程:本应用已不再支持本机日程,改为引导用户挑一个 CalDAV 日历本
|
|
|
|
|
|
// (保存时 calKey/href 会改写为该日历本,事件随后被推送到服务器)
|
|
|
|
|
|
if (loaded.calKey === LOCAL_CAL_KEY) {
|
|
|
|
|
|
this.statusMsg = '这条日程原先只存在「本机」(不同步)。请选一个 CalDAV 日历本,保存后即会同步到服务器。';
|
|
|
|
|
|
}
|
2026-09-13 20:25:18 +08:00
|
|
|
|
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
|
2026-10-10 07:05:32 +08:00
|
|
|
|
this.parseRepeatEnd(loaded.rrule);
|
2026-09-13 23:35:52 +08:00
|
|
|
|
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
|
|
|
|
|
|
: (loaded.reminder > 0 ? [loaded.reminder] : []);
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// ⭐ 目标发生时刻("仅这一次"要排除/覆盖的那一次):
|
|
|
|
|
|
// ① 覆盖行 → 用 RECURRENCE-ID(原始发生时刻)
|
|
|
|
|
|
// ② 调用方显式传入了"用户点的是哪一次" → 用它(列表里点开的某次发生,行的 id 是主事件 id,
|
|
|
|
|
|
// 若不传,就会把"主事件的第一次发生"当成目标 → 表现出来就是"选仅这一次却改了全部")
|
|
|
|
|
|
// ③ 兜底才用行本身的开始时间
|
|
|
|
|
|
const occFromCaller: number | undefined = AppStorage.get<number>('pendingOccurrenceTs');
|
|
|
|
|
|
if (loaded.recurrenceId > 0) {
|
|
|
|
|
|
this.occStartMs = loaded.recurrenceId;
|
|
|
|
|
|
} else if (occFromCaller !== undefined && occFromCaller > 0) {
|
|
|
|
|
|
this.occStartMs = occFromCaller;
|
|
|
|
|
|
this.occProvided = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.occStartMs = loaded.startTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⭐ 用户点开的是"某一次发生"时,编辑页必须显示**那一次**的实际时间,
|
|
|
|
|
|
// 而不是重复主事件第一次发生的时间(否则时间框显示错误,用户改时间也改错基准)。
|
|
|
|
|
|
if (this.occProvided && this.occStartMs > 0) {
|
|
|
|
|
|
const dur: number = Math.max(0, loaded.endTime - loaded.startTime);
|
|
|
|
|
|
this.startMs = this.occStartMs;
|
|
|
|
|
|
this.endMs = this.allDay ? this.occStartMs + 86399999 : this.occStartMs + dur;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 消费后立即清掉,避免影响下一次新建/编辑
|
|
|
|
|
|
AppStorage.setOrCreate<number>('pendingOccurrenceTs', 0);
|
2026-09-13 15:50:37 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-13 23:35:52 +08:00
|
|
|
|
// 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 1 小时
|
2026-09-13 15:50:37 +08:00
|
|
|
|
const base: number = AppStorage.get<number>('pendingEventDate') ?? Date.now();
|
|
|
|
|
|
const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(),
|
|
|
|
|
|
new Date(base).getDate()).getTime();
|
2026-09-13 23:35:52 +08:00
|
|
|
|
const now = new Date();
|
|
|
|
|
|
this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000;
|
|
|
|
|
|
this.endMs = this.startMs + 3600000;
|
2026-09-13 15:50:37 +08:00
|
|
|
|
if (choices.length > 0) {
|
|
|
|
|
|
this.chosenKey = choices[0].calKey;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 07:59:21 +08:00
|
|
|
|
// ==================== 地点补全(鸿蒙 Map Kit / Site Kit) ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 输入防抖:停止输入 350ms 后才真正发请求。
|
|
|
|
|
|
* 好处:① 不把服务端配额/网络浪费在"每敲一个字"上;② 避免结果闪烁。
|
|
|
|
|
|
* ⚠️ 这里只用「关键词补全」这一种能力(不需要定位权限),
|
|
|
|
|
|
* 所以**不需要**申请 LOCATION 权限,符合"权限只在用户主动点该功能时申请"的约束。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private scheduleSuggest(v: string): void {
|
|
|
|
|
|
this.cancelSuggestTimer();
|
|
|
|
|
|
const q: string = v.trim();
|
|
|
|
|
|
if (q.length < 1) {
|
2026-10-21 09:03:31 +08:00
|
|
|
|
this.closePlacePopup();
|
2026-10-21 07:59:21 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.placeTimer = setTimeout(() => {
|
|
|
|
|
|
this.placeTimer = -1;
|
|
|
|
|
|
this.runSuggest(q, false);
|
|
|
|
|
|
}, 350);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private cancelSuggestTimer(): void {
|
|
|
|
|
|
if (this.placeTimer !== -1) {
|
|
|
|
|
|
clearTimeout(this.placeTimer);
|
|
|
|
|
|
this.placeTimer = -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async runSuggest(q: string, forceOpen: boolean): Promise<void> {
|
|
|
|
|
|
if (this.placePicked && !forceOpen) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (PlaceSuggest.isUnavailable()) {
|
|
|
|
|
|
// 地图服务没开通:给一次明确提示,然后永久静默(不阻塞手输)
|
|
|
|
|
|
this.placeHint = '地图服务不可用,可直接手动输入地点';
|
2026-10-21 09:03:31 +08:00
|
|
|
|
this.placeItems = [];
|
|
|
|
|
|
this.placeOpen = true;
|
2026-10-21 07:59:21 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-21 09:03:31 +08:00
|
|
|
|
// ⭐ 浮层一直保持展开(从"搜索中"过渡到结果),中间不收起、不重弹 ——
|
|
|
|
|
|
// 收起再弹会让浮层闪一下,看起来像"消失了又出现"。
|
2026-10-21 07:59:21 +08:00
|
|
|
|
this.placeLoading = true;
|
|
|
|
|
|
this.placeHint = '';
|
2026-10-21 09:03:31 +08:00
|
|
|
|
this.placeOpen = true;
|
2026-10-21 07:59:21 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const c = this.getUIContext().getHostContext();
|
|
|
|
|
|
const ctx: common.Context | undefined = c;
|
|
|
|
|
|
// 先补全;补全空则内部会自动退到关键字搜索
|
|
|
|
|
|
let items: PlaceItem[] = await PlaceSuggest.query(q, ctx);
|
|
|
|
|
|
if (items.length === 0 && !PlaceSuggest.isUnavailable()) {
|
|
|
|
|
|
items = await PlaceSuggest.search(q, ctx);
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⭐ 丢弃过期结果:用户又改了输入框,就别再覆盖成旧候选
|
|
|
|
|
|
if (q !== this.location.trim()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-21 08:26:16 +08:00
|
|
|
|
this.placeItems = items.slice(0, 6);
|
2026-10-21 07:59:21 +08:00
|
|
|
|
if (this.placeItems.length === 0) {
|
|
|
|
|
|
this.placeHint = PlaceSuggest.isUnavailable()
|
|
|
|
|
|
? '地图服务不可用,可直接手动输入地点'
|
|
|
|
|
|
: '没有找到匹配地点,可直接手动输入';
|
2026-10-21 09:03:31 +08:00
|
|
|
|
// 无结果也留在浮层里给提示;用户再输入会被下一次请求覆盖
|
|
|
|
|
|
this.placeOpen = true;
|
2026-10-21 07:59:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const e = err as BusinessError;
|
|
|
|
|
|
LogUtil.write(`地点补全异常: ${e.message}`);
|
2026-10-21 09:03:31 +08:00
|
|
|
|
this.placeItems = [];
|
2026-10-21 07:59:21 +08:00
|
|
|
|
this.placeHint = '地点搜索失败,可直接手动输入';
|
2026-10-21 09:03:31 +08:00
|
|
|
|
this.placeOpen = true;
|
2026-10-21 07:59:21 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
this.placeLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 08:26:16 +08:00
|
|
|
|
/**
|
2026-10-21 09:03:31 +08:00
|
|
|
|
* ⭐ 候选浮层(bindPopup)的内容。
|
2026-10-21 08:26:16 +08:00
|
|
|
|
*
|
2026-10-21 09:03:31 +08:00
|
|
|
|
* 为什么是浮层而不是内联列表:
|
|
|
|
|
|
* 内联列表会改变表单高度 ⇒ 输入框位置随之变化 ⇒ 每次"搜索中/出结果/结果变少"
|
|
|
|
|
|
* 都要靠滚动去纠正 ⇒ 用户看到的就是**输入框上下跳好几次**。
|
|
|
|
|
|
* 浮层不参与布局,出现与消失**完全不移动输入框**,这是唯一能根治该问题的结构。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么向上弹(Placement.Top):
|
|
|
|
|
|
* 输入框在表单下部、键盘又在下方,向下弹必然被键盘盖住;
|
|
|
|
|
|
* 向上弹则整块候选都落在输入框与标题之间的空白区。
|
2026-10-21 08:26:16 +08:00
|
|
|
|
*/
|
2026-10-21 09:03:31 +08:00
|
|
|
|
@Builder
|
|
|
|
|
|
placePopup() {
|
|
|
|
|
|
Column({ space: 0 }) {
|
|
|
|
|
|
if (this.placeLoading) {
|
|
|
|
|
|
Row({ space: 8 }) {
|
|
|
|
|
|
LoadingProgress().width(16).height(16).color($r('app.color.text_hint'))
|
|
|
|
|
|
Text('搜索中…')
|
|
|
|
|
|
.fontSize(13)
|
|
|
|
|
|
.fontColor($r('app.color.text_hint'))
|
2026-10-21 08:26:16 +08:00
|
|
|
|
}
|
2026-10-21 09:03:31 +08:00
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
|
|
|
|
|
} else if (this.placeItems.length > 0) {
|
|
|
|
|
|
// 限高 + 内部滚动:候选最多 6 条,一次全铺开会顶到屏幕顶部
|
|
|
|
|
|
List() {
|
|
|
|
|
|
ForEach(this.placeItems, (it: PlaceItem, idx: number) => {
|
|
|
|
|
|
ListItem() {
|
|
|
|
|
|
Column({ space: 2 }) {
|
|
|
|
|
|
Text(it.title)
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.maxLines(1)
|
|
|
|
|
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Start)
|
|
|
|
|
|
if (it.subtitle !== '') {
|
|
|
|
|
|
Text(it.subtitle)
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
.maxLines(1)
|
|
|
|
|
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Start)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
|
|
|
|
|
|
}
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.applyPlace(it);
|
|
|
|
|
|
})
|
|
|
|
|
|
}, (it: PlaceItem, idx: number) => `${it.siteId}_${idx}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.constraintSize({ maxHeight: 216 })
|
|
|
|
|
|
.scrollBar(BarState.Auto)
|
|
|
|
|
|
.divider({ strokeWidth: 0.5, color: $r('app.color.shadow_color') })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Text(this.placeHint !== '' ? this.placeHint : '没有找到匹配地点,可直接手动输入')
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.fontColor($r('app.color.text_hint'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ top: 4, bottom: 4 })
|
2026-10-21 08:26:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-21 07:59:21 +08:00
|
|
|
|
/** 选中一条候选:写进地点输入框并收起列表 */
|
|
|
|
|
|
private applyPlace(it: PlaceItem): void {
|
2026-10-21 09:03:31 +08:00
|
|
|
|
// ⭐ 顺序很关键:先收起浮层,再写文本。
|
|
|
|
|
|
// TextInput 的 text 被程序改写会触发 onChange,若此时 placePicked 已被
|
|
|
|
|
|
// onChange 重置为 false,就会**重新发起一次搜索** → 用户看到
|
|
|
|
|
|
// "候选框消失了一下又冒出来一次"。用 lastAppliedText 精确识别这类回写。
|
2026-10-21 07:59:21 +08:00
|
|
|
|
this.cancelSuggestTimer();
|
2026-10-21 09:03:31 +08:00
|
|
|
|
this.closePlacePopup();
|
|
|
|
|
|
this.placePicked = true;
|
|
|
|
|
|
this.lastAppliedText = it.locationText;
|
|
|
|
|
|
this.location = it.locationText;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 收起候选浮层(统一入口,保证 placePicked 之外的状态都被清干净) */
|
|
|
|
|
|
private closePlacePopup(): void {
|
|
|
|
|
|
this.placeOpen = false;
|
|
|
|
|
|
this.placeLoading = false;
|
|
|
|
|
|
this.placeItems = [];
|
|
|
|
|
|
this.placeHint = '';
|
2026-10-21 07:59:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 打开系统地图选点页(可搜索、可周边、可拖图钉) */
|
|
|
|
|
|
private async pickOnMap(): Promise<void> {
|
|
|
|
|
|
const c = this.getUIContext().getHostContext();
|
|
|
|
|
|
if (c === undefined) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const it: PlaceItem | null = await PlaceSuggest.pickOnMap(c as common.UIAbilityContext);
|
|
|
|
|
|
if (it === null) {
|
|
|
|
|
|
// 用户取消,或地图服务不可用
|
|
|
|
|
|
if (PlaceSuggest.isUnavailable()) {
|
|
|
|
|
|
this.placeHint = '地图服务不可用,可直接手动输入地点';
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.applyPlace(it);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 15:50:37 +08:00
|
|
|
|
private chosenBook(): BookChoice | null {
|
2026-10-09 22:19:31 +08:00
|
|
|
|
const found = this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey);
|
|
|
|
|
|
if (found !== undefined) {
|
|
|
|
|
|
return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⭐ 兜底:编辑既有日程时,若它的日历本恰好不在"可写日历本"列表里
|
|
|
|
|
|
// (例如该本被标记只读、或服务器写权限探测暂时失败),
|
|
|
|
|
|
// 仍然用这条日程自己的 calKey/href 构造一个目标 —— 否则 chosenBook() 返回 null,
|
|
|
|
|
|
// 保存会被直接拦下,用户只看到一行错误提示,永远进不到"重复日程范围选择"。
|
|
|
|
|
|
if (this.event !== null && this.event.calKey !== '' && this.event.calKey === this.chosenKey) {
|
|
|
|
|
|
const b = new BookChoice();
|
|
|
|
|
|
b.calKey = this.event.calKey;
|
|
|
|
|
|
b.href = this.event.href;
|
|
|
|
|
|
b.name = '';
|
|
|
|
|
|
return b;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private fmtDate(ms: number): string {
|
|
|
|
|
|
const d = new Date(ms);
|
|
|
|
|
|
const p = (n: number): string => n < 10 ? '0' + n : String(n);
|
|
|
|
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private fmtTime(ms: number): string {
|
|
|
|
|
|
const d = new Date(ms);
|
|
|
|
|
|
const p = (n: number): string => n < 10 ? '0' + n : String(n);
|
|
|
|
|
|
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 20:25:18 +08:00
|
|
|
|
/** 重复模式 → RRULE 字符串(RruleUtil 已支持这些规则) */
|
|
|
|
|
|
private rruleForMode(mode: string): string {
|
|
|
|
|
|
if (mode === 'daily') {
|
|
|
|
|
|
return 'FREQ=DAILY';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mode === 'workday') {
|
|
|
|
|
|
return 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mode === 'weekly') {
|
|
|
|
|
|
return 'FREQ=WEEKLY';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mode === 'monthly') {
|
|
|
|
|
|
return 'FREQ=MONTHLY';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mode === 'yearly') {
|
|
|
|
|
|
return 'FREQ=YEARLY';
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 20:25:18 +08:00
|
|
|
|
/** RRULE → 重复模式(无法识别的规则归为 custom,保存时保留原规则) */
|
|
|
|
|
|
private modeFromRrule(rrule: string): string {
|
|
|
|
|
|
const u: string = rrule.toUpperCase();
|
|
|
|
|
|
if (u.includes('FREQ=DAILY')) {
|
|
|
|
|
|
return 'daily';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (u.includes('FREQ=WEEKLY')) {
|
|
|
|
|
|
const hasWeekday: boolean = u.includes('MO') && u.includes('TU') && u.includes('WE')
|
|
|
|
|
|
&& u.includes('TH') && u.includes('FR');
|
|
|
|
|
|
return hasWeekday ? 'workday' : 'weekly';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (u.includes('FREQ=MONTHLY')) {
|
|
|
|
|
|
return 'monthly';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (u.includes('FREQ=YEARLY')) {
|
|
|
|
|
|
return 'yearly';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'custom';
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 07:05:32 +08:00
|
|
|
|
/** 从已有 RRULE 解析出"结束条件"(用于编辑时回显"重复到某天/共几次") */
|
|
|
|
|
|
private parseRepeatEnd(rrule: string): void {
|
|
|
|
|
|
if (rrule === '') {
|
|
|
|
|
|
this.repeatEnd = 'never';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const u: string = rrule.toUpperCase();
|
|
|
|
|
|
// COUNT=n
|
|
|
|
|
|
const cm = /COUNT=(\d+)/.exec(u);
|
|
|
|
|
|
if (cm !== null) {
|
|
|
|
|
|
this.repeatEnd = 'count';
|
|
|
|
|
|
this.repeatCount = Number(cm[1]) > 0 ? Number(cm[1]) : 10;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// UNTIL=yyyymmddThhmmssZ 或 yyyymmdd
|
|
|
|
|
|
const um = /UNTIL=(\d{8})(T\d{6}Z?)?/.exec(u);
|
|
|
|
|
|
if (um !== null) {
|
|
|
|
|
|
this.repeatEnd = 'until';
|
|
|
|
|
|
const y: number = Number(um[1].substring(0, 4));
|
|
|
|
|
|
const mo: number = Number(um[1].substring(4, 6));
|
|
|
|
|
|
const d: number = Number(um[1].substring(6, 8));
|
|
|
|
|
|
this.repeatUntil = new Date(y, mo - 1, d).getTime();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.repeatEnd = 'never';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 把当前"重复模式 + 结束条件"合成 RRULE 字符串 */
|
|
|
|
|
|
private buildRrule(): string {
|
|
|
|
|
|
const base: string = this.rruleForMode(this.repeatMode);
|
|
|
|
|
|
if (base === '' || this.repeatMode === 'custom') {
|
|
|
|
|
|
return ''; // custom 由调用方保留原规则
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.repeatEnd === 'count') {
|
|
|
|
|
|
return `${base};COUNT=${Math.max(1, Math.round(this.repeatCount))}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.repeatEnd === 'until' && this.repeatUntil > 0) {
|
|
|
|
|
|
// UNTIL 用"截止日当天 23:59:59"(本地时区),避免把当天那次排除掉
|
|
|
|
|
|
const d = new Date(this.repeatUntil);
|
|
|
|
|
|
const end: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59).getTime();
|
|
|
|
|
|
return `${base};UNTIL=${IcsUtil.fmtIcsTime(end, false)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return base; // 永不结束
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 编辑既有重复日程时:用户在界面上调整了"结束条件"才覆盖原 RRULE;
|
|
|
|
|
|
* 没动过就原样保留(防止把服务器上的复杂规则简单化掉)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private effectiveRrule(): string {
|
|
|
|
|
|
if (this.repeatMode === 'custom') {
|
|
|
|
|
|
return this.originalRrule;
|
|
|
|
|
|
}
|
|
|
|
|
|
const built: string = this.buildRrule();
|
|
|
|
|
|
if (this.originalRrule === '') {
|
|
|
|
|
|
return built;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 原有规则的 FREQ 与当前模式一致、且用户没有显式改结束条件 → 保留原规则
|
|
|
|
|
|
const freqOf = (m: string): string => this.modeFromRrule(m);
|
|
|
|
|
|
if (freqOf(this.originalRrule) === this.repeatMode
|
|
|
|
|
|
&& this.repeatEndMatches(this.originalRrule)) {
|
|
|
|
|
|
return this.originalRrule;
|
|
|
|
|
|
}
|
|
|
|
|
|
return built;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 原 RRULE 的结束条件是否与界面当前选择一致 */
|
|
|
|
|
|
private repeatEndMatches(rrule: string): boolean {
|
|
|
|
|
|
const u: string = rrule.toUpperCase();
|
|
|
|
|
|
if (this.repeatEnd === 'never') {
|
|
|
|
|
|
return !u.includes('COUNT=') && !u.includes('UNTIL=');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.repeatEnd === 'count') {
|
|
|
|
|
|
const m = /COUNT=(\d+)/.exec(u);
|
|
|
|
|
|
return m !== null && Number(m[1]) === Math.round(this.repeatCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.repeatEnd === 'until') {
|
|
|
|
|
|
const m = /UNTIL=(\d{8})/.exec(u);
|
|
|
|
|
|
if (m === null) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
const y = Number(m[1].substring(0, 4));
|
|
|
|
|
|
const mo = Number(m[1].substring(4, 6));
|
|
|
|
|
|
const d = Number(m[1].substring(6, 8));
|
|
|
|
|
|
const a = new Date(y, mo - 1, d);
|
|
|
|
|
|
const b = new Date(this.repeatUntil);
|
|
|
|
|
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
|
|
|
|
|
|
&& a.getDate() === b.getDate();
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 打开"重复结束条件"日期选择弹层(复用唯一的底部弹层) */
|
|
|
|
|
|
private openRepeatUntilPicker(): void {
|
|
|
|
|
|
const base = this.repeatUntil > 0 ? new Date(this.repeatUntil) : new Date(this.startMs);
|
|
|
|
|
|
this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
|
|
|
|
|
|
this.sheetMode = 'repeatEnd';
|
|
|
|
|
|
this.sheetShow = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 20:25:18 +08:00
|
|
|
|
private reminderLabel(m: number): string {
|
|
|
|
|
|
if (m === 0) {
|
|
|
|
|
|
return '不提醒';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m < 60) {
|
|
|
|
|
|
return `提前${m}分钟`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (m < 1440) {
|
|
|
|
|
|
return `提前${m / 60}小时`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `提前${m / 1440}天`;
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 20:25:18 +08:00
|
|
|
|
/** 打开时间选择弹层:日期 + 时间一次选完 */
|
|
|
|
|
|
private openPicker(isEnd: boolean): void {
|
|
|
|
|
|
this.pickerIsEnd = isEnd;
|
|
|
|
|
|
const base = new Date(isEnd ? this.endMs : this.startMs);
|
|
|
|
|
|
this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
|
|
|
|
|
|
this.pickHour = base.getHours();
|
|
|
|
|
|
this.pickMin = base.getMinutes();
|
2026-10-09 22:41:17 +08:00
|
|
|
|
this.sheetMode = 'picker';
|
|
|
|
|
|
this.sheetShow = true;
|
2026-09-13 20:25:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private applyPicker(): void {
|
|
|
|
|
|
const d = this.pickDate;
|
|
|
|
|
|
const picked: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(),
|
|
|
|
|
|
this.pickHour, this.pickMin).getTime();
|
|
|
|
|
|
if (this.pickerIsEnd) {
|
|
|
|
|
|
// 全天日程的结束存为"当天 23:59:59.999"(排他日期前 1 毫秒)
|
|
|
|
|
|
this.endMs = this.allDay ? picked + 86399999 : picked;
|
|
|
|
|
|
} else {
|
2026-09-13 23:35:52 +08:00
|
|
|
|
// 设定开始时间后,结束时间自动跟随 = 开始 + 1 小时
|
2026-09-13 20:25:18 +08:00
|
|
|
|
this.startMs = picked;
|
2026-09-13 23:35:52 +08:00
|
|
|
|
this.endMs = this.allDay ? picked + 86399999 : picked + 3600000;
|
2026-09-13 20:25:18 +08:00
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private toggleAllDay(): void {
|
|
|
|
|
|
this.allDay = !this.allDay;
|
|
|
|
|
|
if (this.allDay) {
|
|
|
|
|
|
const s = new Date(this.startMs);
|
|
|
|
|
|
const dayStart: number = new Date(s.getFullYear(), s.getMonth(), s.getDate()).getTime();
|
|
|
|
|
|
this.startMs = dayStart;
|
|
|
|
|
|
this.endMs = dayStart + 86399999;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const s = new Date(this.startMs);
|
|
|
|
|
|
this.startMs = s.getTime() + 9 * 3600000;
|
|
|
|
|
|
this.endMs = this.startMs + 3600000;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private validate(): boolean {
|
|
|
|
|
|
if (this.title.trim() === '') {
|
|
|
|
|
|
this.statusMsg = '请输入日程标题';
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.endMs < this.startMs) {
|
|
|
|
|
|
this.statusMsg = '结束时间不能早于开始时间';
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 21:40:53 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 当前编辑的是不是一个"重复系列中的某一次发生"?
|
|
|
|
|
|
* - 主事件行(rrule 非空):用户点开的可能就是第一次发生或其展开实例 → 需要询问范围
|
|
|
|
|
|
* - 覆盖实例行(recurring && rrule==''):明确就是被单独改写过的那一次 → 需要询问范围
|
|
|
|
|
|
* 非重复的普通日程返回 false,直接按原逻辑保存/删除。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private isSeriesOccurrence(): boolean {
|
2026-10-09 22:19:31 +08:00
|
|
|
|
const src: LocalEvent | null = this.original ?? this.event;
|
|
|
|
|
|
if (src === null) {
|
2026-10-09 21:40:53 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-10-09 22:19:31 +08:00
|
|
|
|
return src.recurring || src.rrule !== '';
|
2026-10-09 21:40:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 22:19:31 +08:00
|
|
|
|
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行)。
|
|
|
|
|
|
* ⚠️ 必须按**原始** calKey/uid 查找(doSave 会改写 this.event 的 calKey)。 */
|
2026-10-09 21:40:53 +08:00
|
|
|
|
private async loadMaster(context: common.Context): Promise<LocalEvent | null> {
|
2026-10-09 22:19:31 +08:00
|
|
|
|
const src: LocalEvent | null = this.original ?? this.event;
|
|
|
|
|
|
if (src === null) {
|
2026-10-09 21:40:53 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-10-09 22:19:31 +08:00
|
|
|
|
const rows: LocalEvent[] = await EventDb.queryByUid(context, src.calKey, src.uid);
|
2026-10-09 21:40:53 +08:00
|
|
|
|
for (const r of rows) {
|
|
|
|
|
|
if (r.rrule !== '') {
|
|
|
|
|
|
return r;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-09 22:19:31 +08:00
|
|
|
|
return src;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 07:05:32 +08:00
|
|
|
|
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分)。
|
|
|
|
|
|
* ⭐ 会用展开结果**校正 occStartMs**:调用方传来的时刻可能带秒/毫秒偏差
|
|
|
|
|
|
* (列表里点开的那次发生时间是精确的,但主事件运行到卡片等路径可能有偏差),
|
|
|
|
|
|
* 必须吸附到"真实存在的那一次",否则 EXDATE 写出的时刻匹配不上任何发生 →
|
|
|
|
|
|
* 表现为"选了仅这一次,却把所有次都改了"。 */
|
2026-10-09 21:40:53 +08:00
|
|
|
|
private async seriesBoundaries(context: common.Context): Promise<SeriesBounds> {
|
|
|
|
|
|
const b = new SeriesBounds();
|
2026-10-10 07:05:32 +08:00
|
|
|
|
let occ: number = this.occStartMs;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
b.thisOcc = occ;
|
|
|
|
|
|
const master: LocalEvent | null = await this.loadMaster(context);
|
2026-10-10 07:05:32 +08:00
|
|
|
|
if (master === null || master.rrule === '') {
|
2026-10-09 21:40:53 +08:00
|
|
|
|
b.beforeThis = occ - 1000;
|
|
|
|
|
|
b.nextOcc = 0;
|
|
|
|
|
|
return b;
|
|
|
|
|
|
}
|
|
|
|
|
|
const exNums: number[] = [];
|
|
|
|
|
|
if (master.exdate !== '') {
|
|
|
|
|
|
for (const raw of master.exdate.split(';')) {
|
|
|
|
|
|
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
|
|
|
|
|
|
if (t !== null) {
|
|
|
|
|
|
exNums.push(t.time);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// 展开窗口:主事件开始 ~ "该次"之后一年(覆盖主事件的首次发生)
|
|
|
|
|
|
const winEnd: number = Math.max(occ, master.startTime) + 366 * 86400000;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
const occs: number[] = RruleUtil.expand(master.rrule, master.startTime,
|
|
|
|
|
|
master.startTime, winEnd, exNums, 3000);
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// ⭐ 吸附:找与 occ 最接近的那一次真实发生(容差 12 小时,足以跨时区/跨日误差)
|
|
|
|
|
|
let snapped: number = 0;
|
|
|
|
|
|
let bestDiff: number = -1;
|
|
|
|
|
|
for (const o of occs) {
|
|
|
|
|
|
const d: number = Math.abs(o - occ);
|
|
|
|
|
|
if (bestDiff < 0 || d < bestDiff) {
|
|
|
|
|
|
bestDiff = d;
|
|
|
|
|
|
snapped = o;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (snapped > 0 && bestDiff <= 12 * 3600000) {
|
|
|
|
|
|
occ = snapped;
|
|
|
|
|
|
b.thisOcc = snapped;
|
|
|
|
|
|
this.occStartMs = snapped; // 回写,保证后续 EXDATE / 覆盖行用同一时刻
|
|
|
|
|
|
} else if (!this.occProvided && master.startTime > 0) {
|
|
|
|
|
|
// 没有可信的"点选时刻"时,退回主事件的首次发生(至少是真实存在的一次)
|
|
|
|
|
|
occ = master.startTime;
|
|
|
|
|
|
b.thisOcc = occ;
|
|
|
|
|
|
this.occStartMs = occ;
|
|
|
|
|
|
}
|
2026-10-09 21:40:53 +08:00
|
|
|
|
let prev: number = 0;
|
|
|
|
|
|
let next: number = 0;
|
|
|
|
|
|
for (const o of occs) {
|
|
|
|
|
|
if (o < occ) {
|
|
|
|
|
|
prev = o;
|
|
|
|
|
|
} else if (o > occ) {
|
|
|
|
|
|
next = o;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
b.beforeThis = prev > 0 ? (prev + occ) / 2 : (occ - 1000);
|
|
|
|
|
|
b.nextOcc = next;
|
|
|
|
|
|
return b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 按用户选择的"重复日程编辑范围"落库:
|
|
|
|
|
|
* - all :原逻辑,直接改这一行(若是覆盖实例则改覆盖行),整系列由 pushSeries 重建
|
|
|
|
|
|
* - one :单次改写 ——
|
|
|
|
|
|
* · 原行是覆盖实例 → 直接更新它
|
|
|
|
|
|
* · 原行是主事件(用户点开了系列)→ 为主事件加 EXDATE 排除该次,另插一条覆盖行
|
|
|
|
|
|
* - future :此次及以后 —— 先把主事件的 RRULE 截断到"此次之前"(加 UNTIL),
|
|
|
|
|
|
* 再把"此次及以后"的内容作为**新的独立系列**(新 uid)插入。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async applySeriesEdit(context: common.Context, e: LocalEvent, isNew: boolean,
|
|
|
|
|
|
scope: string): Promise<void> {
|
|
|
|
|
|
const target: LocalEvent = e;
|
2026-10-09 22:19:31 +08:00
|
|
|
|
// ⚠️ 用**原始快照**判据:doSave 已把 e(= this.event)的 calKey/href/startTime 改成新值,
|
|
|
|
|
|
// 直接用 this.event 会把"改后的日期/日历本"当成原系列,导致 EXDATE / UNTIL 算错。
|
|
|
|
|
|
const cur: LocalEvent | null = this.original;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// 用户点开的是"某一次发生"(occProvided=true)时,list/周视图里的行 id 是**主事件 id**,
|
|
|
|
|
|
// 所以"改全部"也必须给用户明确的确认——但语义上就是改整条系列:
|
|
|
|
|
|
// · 点开的是主事件行本身(occProvided=false)→ 直接改这一行就是改整个系列,无需特殊处理
|
|
|
|
|
|
// · 点开的是某一次发生 且选了"全部" → 把改动写到主事件行上(整系列平移/改名)
|
2026-10-09 22:19:31 +08:00
|
|
|
|
if (isNew || cur === null || !this.isSeriesOccurrence()) {
|
2026-10-09 21:40:53 +08:00
|
|
|
|
if (isNew) {
|
|
|
|
|
|
await EventDb.insertLocal(context, target);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await EventDb.updateLocal(context, target);
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-09 21:40:53 +08:00
|
|
|
|
const bounds: SeriesBounds = await this.seriesBoundaries(context);
|
|
|
|
|
|
if (scope === 'all') {
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// 全部:改到**主事件行**上(整系列一起改)。
|
|
|
|
|
|
// ⚠️ 不能直接 updateLocal(target):target 是"用户点开的那一行",
|
|
|
|
|
|
// 当用户点的是某次发生、而该行其实是主事件行时没问题;
|
|
|
|
|
|
// 但若这一行是覆盖实例(rrule=''),把整系列的属性写进覆盖行只会改到那一次。
|
|
|
|
|
|
const masterForAll: LocalEvent | null = await this.loadMaster(context);
|
|
|
|
|
|
if (masterForAll !== null && masterForAll.rrule !== '') {
|
|
|
|
|
|
// 把编辑内容写到主事件行(保留主事件的 uid / rrule / exdate)
|
|
|
|
|
|
masterForAll.title = target.title;
|
|
|
|
|
|
masterForAll.description = target.description;
|
|
|
|
|
|
masterForAll.location = target.location;
|
|
|
|
|
|
masterForAll.startTime = target.startTime;
|
|
|
|
|
|
masterForAll.endTime = target.endTime;
|
|
|
|
|
|
masterForAll.isAllDay = target.isAllDay;
|
|
|
|
|
|
masterForAll.reminders = target.reminders;
|
|
|
|
|
|
masterForAll.reminder = target.reminder;
|
|
|
|
|
|
await EventDb.updateLocal(context, masterForAll);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-09 21:40:53 +08:00
|
|
|
|
await EventDb.updateLocal(context, target);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (scope === 'one') {
|
|
|
|
|
|
if (cur.rrule === '' && cur.recurrenceId > 0) {
|
2026-10-09 22:19:31 +08:00
|
|
|
|
// 已经是覆盖实例 → 直接改它(保留 recurrenceId)
|
|
|
|
|
|
target.recurrenceId = cur.recurrenceId;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
await EventDb.updateLocal(context, target);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 主事件上的"仅此次":加 EXDATE 排除该次 + 插入覆盖行
|
|
|
|
|
|
const master: LocalEvent | null = await this.loadMaster(context);
|
|
|
|
|
|
if (master === null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// ⚠️ **顺序很重要**:先把主事件行还原为原值(用户在编辑页改的标题/时间只属于"这一次",
|
|
|
|
|
|
// 不能污染主事件),**再**写 EXDATE。反过来会被还原操作覆盖掉 EXDATE
|
|
|
|
|
|
// (快照里存的是旧的 exdate),导致排除失效 → 原时间残留一条、看起来"没删掉/改错了"。
|
|
|
|
|
|
if (cur.rrule !== '' && master.id === cur.id) {
|
|
|
|
|
|
await EventDb.restoreRowById(context, master.id, cur);
|
|
|
|
|
|
}
|
|
|
|
|
|
const exList: string[] = cur.exdate === '' ? [] : cur.exdate.split(';');
|
2026-10-09 21:40:53 +08:00
|
|
|
|
const occIcs: string = IcsUtil.fmtIcsTime(bounds.thisOcc, master.isAllDay);
|
|
|
|
|
|
if (!exList.includes(occIcs)) {
|
|
|
|
|
|
exList.push(occIcs);
|
|
|
|
|
|
}
|
|
|
|
|
|
await EventDb.setExdate(context, master.id, exList.join(';'));
|
|
|
|
|
|
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
|
|
|
|
|
|
const ov = new LocalEvent();
|
|
|
|
|
|
ov.uid = master.uid;
|
|
|
|
|
|
ov.calKey = master.calKey;
|
|
|
|
|
|
ov.href = master.href;
|
|
|
|
|
|
ov.remotePath = master.remotePath;
|
|
|
|
|
|
ov.title = target.title;
|
|
|
|
|
|
ov.description = target.description;
|
|
|
|
|
|
ov.location = target.location;
|
|
|
|
|
|
ov.startTime = target.startTime;
|
|
|
|
|
|
ov.endTime = target.endTime;
|
|
|
|
|
|
ov.isAllDay = target.isAllDay;
|
|
|
|
|
|
ov.recurring = true;
|
|
|
|
|
|
ov.rrule = '';
|
|
|
|
|
|
ov.recurrenceId = bounds.thisOcc;
|
|
|
|
|
|
ov.reminders = target.reminders;
|
|
|
|
|
|
ov.reminder = target.reminder;
|
|
|
|
|
|
await EventDb.insertLocal(context, ov);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// scope === 'future'
|
|
|
|
|
|
const master: LocalEvent | null = await this.loadMaster(context);
|
|
|
|
|
|
if (master === null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 主事件本身被改期 → "此次及以后"里包含主事件(第一次发生)本身,
|
|
|
|
|
|
// 需要让新系列从"改到的时间"开始(否则原 DTSTART 会被原系列继续展开)。
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// ⚠️ 判据要用**原始主事件的开始时间**(cur.startTime 是"载入时的行时间",
|
|
|
|
|
|
// 对主事件行而言就是它的第一次发生时刻)。
|
2026-10-09 21:40:53 +08:00
|
|
|
|
const startedAtThis: boolean =
|
|
|
|
|
|
(cur.rrule !== '' && Math.abs(cur.startTime - bounds.thisOcc) < 1000);
|
|
|
|
|
|
// 1) 先把"此次及以后"的覆盖实例行搬到新 uid(主事件行 recurrenceId=0,不受影响)
|
|
|
|
|
|
const newUid: string = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
|
|
|
|
|
|
const newPath: string = encodeURIComponent(newUid) + '.ics';
|
|
|
|
|
|
await EventDb.reassignOverridesFrom(context, master.calKey, master.uid,
|
|
|
|
|
|
newUid, newPath, bounds.thisOcc);
|
|
|
|
|
|
// 2) 创建新系列主事件(本次编辑内容)
|
|
|
|
|
|
const newMaster = new LocalEvent();
|
|
|
|
|
|
newMaster.uid = newUid;
|
|
|
|
|
|
newMaster.calKey = master.calKey;
|
|
|
|
|
|
newMaster.href = master.href;
|
|
|
|
|
|
newMaster.remotePath = newPath;
|
|
|
|
|
|
newMaster.title = target.title;
|
|
|
|
|
|
newMaster.description = target.description;
|
|
|
|
|
|
newMaster.location = target.location;
|
|
|
|
|
|
newMaster.startTime = target.startTime;
|
|
|
|
|
|
newMaster.endTime = target.endTime;
|
|
|
|
|
|
newMaster.isAllDay = target.isAllDay;
|
|
|
|
|
|
newMaster.recurring = true;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// 新的重复规则:优先用界面上设置的(含"重复到某天 / 共几次"),
|
|
|
|
|
|
// 否则沿用原系列的重复节奏,保持"此次及以后"不变
|
2026-10-09 21:40:53 +08:00
|
|
|
|
newMaster.rrule = target.rrule !== '' ? target.rrule : master.rrule;
|
|
|
|
|
|
newMaster.reminders = target.reminders;
|
|
|
|
|
|
newMaster.reminder = target.reminder;
|
|
|
|
|
|
await EventDb.insertLocal(context, newMaster);
|
|
|
|
|
|
// 3) 原主事件系列截断到"此次之前"。
|
|
|
|
|
|
// 若主事件本身就代表"此次"(第一次发生且被改期)→ 原系列整个让位给新系列,
|
|
|
|
|
|
// 直接标记原主事件删除(pushSeries 会 DELETE 原资源),避免旧时间残留成幽灵日程。
|
|
|
|
|
|
if (startedAtThis) {
|
|
|
|
|
|
await EventDb.markDeleted(context, master.id);
|
|
|
|
|
|
} else {
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// ⚠️ 顺序:先还原主事件行(本次编辑只属于"此次及以后"的新系列,
|
|
|
|
|
|
// 不能留在原主事件上),**再**截断 RRULE;反过来会被还原覆盖掉截断结果。
|
|
|
|
|
|
if (cur.rrule !== '' && master.id === cur.id) {
|
2026-10-09 22:19:31 +08:00
|
|
|
|
await EventDb.restoreRowById(context, master.id, cur);
|
|
|
|
|
|
}
|
2026-10-10 07:05:32 +08:00
|
|
|
|
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
|
|
|
|
|
|
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
|
2026-10-09 21:40:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async save(): Promise<void> {
|
2026-10-09 22:19:31 +08:00
|
|
|
|
if (this.isSaving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 便于诊断:记录这次保存走了哪条分支(标题已脱敏)
|
|
|
|
|
|
LogUtil.write(`保存判定:isExisting=${this.isExisting} recurring=${this.original !== null ? this.original.recurring : '?'} rrule=${this.original !== null && this.original.rrule !== '' ? '有' : '无'} 选本=${this.books.length} chosenKey=${this.chosenKey}`);
|
|
|
|
|
|
// ⭐ 重复日程:**先让用户选择范围(仅这一次 / 此次及以后 / 全部)**,再校验与落库。
|
|
|
|
|
|
// 必须放在最前面 —— 与"删除"路径保持一致。若把校验/日历本检查放在前面,
|
|
|
|
|
|
// 任一条件不满足就会提前 return,用户看到的只是表单底部一行小字,
|
|
|
|
|
|
// 永远等不到范围选择弹层(表现为"编辑重复日程没有那三个选项")。
|
|
|
|
|
|
if (this.isSeriesOccurrence()) {
|
|
|
|
|
|
this.pendingDelete = false;
|
2026-10-09 22:41:17 +08:00
|
|
|
|
this.sheetMode = 'scope';
|
|
|
|
|
|
this.sheetShow = true;
|
2026-10-09 22:19:31 +08:00
|
|
|
|
LogUtil.write('保存判定:重复系列 → 弹出范围选择');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!this.validate()) {
|
|
|
|
|
|
LogUtil.write(`保存判定:校验未通过(${this.statusMsg})`);
|
2026-09-13 15:50:37 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-16 22:11:42 +08:00
|
|
|
|
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
|
|
|
|
|
|
const book = this.chosenBook();
|
|
|
|
|
|
if (book === null) {
|
|
|
|
|
|
this.statusMsg = this.books.length === 0
|
|
|
|
|
|
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
|
|
|
|
|
: '请选择要同步到的 CalDAV 日历本';
|
2026-10-09 22:19:31 +08:00
|
|
|
|
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
|
2026-10-09 21:40:53 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await this.doSave('all');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 真正执行保存(scope 已被用户确认) */
|
|
|
|
|
|
private async doSave(scope: string): Promise<void> {
|
|
|
|
|
|
if (this.isSaving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-09 22:19:31 +08:00
|
|
|
|
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
|
|
|
|
|
|
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
|
|
|
|
|
|
if (!this.validate()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-09 21:40:53 +08:00
|
|
|
|
const book = this.chosenBook();
|
|
|
|
|
|
if (book === null) {
|
2026-10-09 22:19:31 +08:00
|
|
|
|
this.statusMsg = this.books.length === 0
|
|
|
|
|
|
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
|
|
|
|
|
: '请选择要同步到的 CalDAV 日历本';
|
2026-10-09 21:40:53 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
this.isSaving = true;
|
|
|
|
|
|
this.statusMsg = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const context = this.getUIContext().getHostContext();
|
|
|
|
|
|
if (context === undefined) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const e = this.event ?? new LocalEvent();
|
|
|
|
|
|
const isNew: boolean = this.event === null;
|
|
|
|
|
|
e.title = this.title.trim();
|
|
|
|
|
|
e.location = this.location.trim();
|
|
|
|
|
|
e.description = this.description.trim();
|
|
|
|
|
|
e.startTime = this.startMs;
|
|
|
|
|
|
e.endTime = this.allDay ? this.startMs + 86399999 : this.endMs;
|
|
|
|
|
|
e.isAllDay = this.allDay;
|
2026-09-16 22:11:42 +08:00
|
|
|
|
// 一律写入所选 CalDAV 日历本(新建/编辑/从「本机」迁过来都一样)
|
|
|
|
|
|
e.calKey = book.calKey;
|
|
|
|
|
|
e.href = book.href;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// 重复规则与提醒(含"重复到某天 / 共几次"的结束条件)
|
|
|
|
|
|
e.rrule = this.effectiveRrule();
|
2026-09-13 20:25:18 +08:00
|
|
|
|
e.recurring = e.rrule !== '';
|
2026-09-13 23:35:52 +08:00
|
|
|
|
e.reminders = EventDb.normalizeReminders(this.reminderList);
|
|
|
|
|
|
e.reminder = e.reminders.length > 0 ? e.reminders[0] : 0;
|
2026-09-13 15:50:37 +08:00
|
|
|
|
if (isNew) {
|
|
|
|
|
|
e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
|
|
|
|
|
|
e.remotePath = encodeURIComponent(e.uid) + '.ics';
|
|
|
|
|
|
await EventDb.insertLocal(context, e);
|
|
|
|
|
|
} else {
|
2026-10-09 21:40:53 +08:00
|
|
|
|
// 重复系列的"仅此次/此次及以后"需要特殊落库(EXDATE / UNTIL / 拆分)
|
|
|
|
|
|
await this.applySeriesEdit(context, e, false, scope);
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 立即推送(尽力而为,失败不打断,下次同步会再推)
|
|
|
|
|
|
if (e.href !== '') {
|
|
|
|
|
|
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
|
|
|
|
|
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(e.href));
|
|
|
|
|
|
if (acc !== undefined) {
|
|
|
|
|
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
|
|
|
|
|
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await SyncEngine.settleLocalEvents(context);
|
|
|
|
|
|
}
|
2026-09-15 12:51:58 +08:00
|
|
|
|
// 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log)
|
2026-10-09 21:40:53 +08:00
|
|
|
|
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 范围=${scope} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
|
2026-09-16 20:54:41 +08:00
|
|
|
|
// 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能,
|
|
|
|
|
|
// 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗)
|
|
|
|
|
|
if (e.reminders.length > 0) {
|
|
|
|
|
|
await this.ensureNotifyPermission();
|
|
|
|
|
|
}
|
2026-09-13 23:35:52 +08:00
|
|
|
|
// 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ReminderService.refreshReminders(context as common.UIAbilityContext);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// 刷新失败不影响保存
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
this.getUIContext().getPromptAction().showToast({ message: '日程已保存' });
|
|
|
|
|
|
router.back();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const ex = err as BusinessError;
|
2026-09-13 23:35:52 +08:00
|
|
|
|
LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`);
|
2026-09-13 15:50:37 +08:00
|
|
|
|
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
|
|
|
|
|
|
// 数据仍在本地且带 dirty 标记,不会丢
|
|
|
|
|
|
router.back();
|
|
|
|
|
|
}
|
|
|
|
|
|
this.isSaving = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 20:54:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 确认通知权限已开启 —— 仅"用户保存了带提醒的日程"这条路径会调用。
|
|
|
|
|
|
* 日程提醒依赖系统通知,用户刚主动设置了提醒,此时申请权限与其使用目的完全一致。
|
|
|
|
|
|
* ⚠️ 严禁在 App 启动 / 首次打开时调用(《审核指南》7.17:不得提前弹窗申请权限)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async ensureNotifyPermission(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const enabled: boolean = await notificationManager.isNotificationEnabled();
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const context = this.getUIContext().getHostContext();
|
|
|
|
|
|
if (context === undefined) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await notificationManager.requestEnableNotification(context as common.UIAbilityContext);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// 用户拒绝或未弹窗:日程照常保存,只是提醒可能不弹出(设置页可再开启)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 12:51:58 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 删除前二次确认。
|
|
|
|
|
|
* 删除日程不可撤销,且会同时从本地库与服务器(DAV)移除,故破坏性操作前必须显式确认,
|
|
|
|
|
|
* 避免"删除日程"按钮点击即删的误触。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private askRemoveEvent(): void {
|
|
|
|
|
|
if (this.event === null || this.isSaving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-09 21:40:53 +08:00
|
|
|
|
// ⭐ 重复日程:先让用户选择"删这一次 / 此次及以后 / 全部"
|
|
|
|
|
|
if (this.isSeriesOccurrence()) {
|
|
|
|
|
|
this.pendingDelete = true;
|
2026-10-09 22:41:17 +08:00
|
|
|
|
this.sheetMode = 'scope';
|
|
|
|
|
|
this.sheetShow = true;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-15 12:51:58 +08:00
|
|
|
|
const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`;
|
|
|
|
|
|
this.getUIContext().showAlertDialog({
|
|
|
|
|
|
title: '删除日程',
|
|
|
|
|
|
message: `确定删除${label}吗?\n\n删除后该日程将从本地与服务器日历中一并移除,且不可撤销。`,
|
|
|
|
|
|
autoCancel: true,
|
|
|
|
|
|
alignment: DialogAlignment.Center,
|
|
|
|
|
|
primaryButton: {
|
|
|
|
|
|
value: '取消',
|
|
|
|
|
|
action: (): void => {}
|
|
|
|
|
|
},
|
|
|
|
|
|
secondaryButton: {
|
|
|
|
|
|
value: '删除',
|
|
|
|
|
|
fontColor: $r('app.color.error'),
|
|
|
|
|
|
action: (): void => {
|
2026-10-09 21:40:53 +08:00
|
|
|
|
this.removeEvent('all');
|
2026-09-15 12:51:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 21:40:53 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 按范围删除:one = 仅此次(EXDATE 排除该次发生)/ future = 此次及以后(截断 RRULE)/
|
|
|
|
|
|
* all = 删除全部(DELETE 整个系列资源)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async removeEvent(scope: string): Promise<void> {
|
2026-09-13 15:50:37 +08:00
|
|
|
|
if (this.event === null || this.isSaving) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.isSaving = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const context = this.getUIContext().getHostContext();
|
|
|
|
|
|
if (context === undefined) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-09 22:19:31 +08:00
|
|
|
|
const cur: LocalEvent = this.original ?? this.event;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
const isSeries: boolean = this.isSeriesOccurrence();
|
|
|
|
|
|
if (!isSeries || scope === 'all') {
|
|
|
|
|
|
// 普通日程 / 删除全部:整行标记删除(系列删除由 pushSeries 处理成 DELETE 资源)
|
|
|
|
|
|
await EventDb.markDeleted(context, cur.id);
|
|
|
|
|
|
} else if (scope === 'one') {
|
|
|
|
|
|
// 仅此次:主事件加 EXDATE 排除该次;若该行本身就是覆盖实例 → 直接删掉这一行
|
|
|
|
|
|
if (cur.rrule === '' && cur.recurrenceId > 0) {
|
|
|
|
|
|
await EventDb.markDeleted(context, cur.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const master: LocalEvent | null = await this.loadMaster(context);
|
|
|
|
|
|
if (master !== null) {
|
|
|
|
|
|
const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';');
|
|
|
|
|
|
const occIcs: string = IcsUtil.fmtIcsTime(this.occStartMs, master.isAllDay);
|
|
|
|
|
|
if (!exList.includes(occIcs)) {
|
|
|
|
|
|
exList.push(occIcs);
|
|
|
|
|
|
}
|
|
|
|
|
|
await EventDb.setExdate(context, master.id, exList.join(';'));
|
|
|
|
|
|
// 若该次此前被单独改写过(存在覆盖行),一并清掉
|
|
|
|
|
|
const rows: LocalEvent[] = await EventDb.queryByUid(context, cur.calKey, cur.uid);
|
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
|
if (r.rrule === '' && r.recurrenceId > 0) {
|
|
|
|
|
|
await EventDb.markDeleted(context, r.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 此次及以后:主事件 RRULE 截断到"此次之前";该次及以后的本地行物理删除
|
|
|
|
|
|
const master: LocalEvent | null = await this.loadMaster(context);
|
|
|
|
|
|
if (master !== null) {
|
|
|
|
|
|
const bounds: SeriesBounds = await this.seriesBoundaries(context);
|
|
|
|
|
|
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
|
|
|
|
|
|
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
|
|
|
|
|
|
await EventDb.purgeByUidFrom(context, master.calKey, master.uid, bounds.thisOcc);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cur.href !== '') {
|
2026-09-13 15:50:37 +08:00
|
|
|
|
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
2026-10-09 21:40:53 +08:00
|
|
|
|
const acc = accounts.find((a: DavAccount): boolean => a.calendarHrefs.includes(cur.href));
|
2026-09-13 15:50:37 +08:00
|
|
|
|
if (acc !== undefined) {
|
|
|
|
|
|
const auth: string = DavClient.authHeader(acc.username, acc.password);
|
|
|
|
|
|
await SyncEngine.pushDirtyForAccount(context, acc, auth);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await SyncEngine.settleLocalEvents(context);
|
|
|
|
|
|
}
|
2026-09-15 12:51:58 +08:00
|
|
|
|
// 日志不落盘日程标题,仅以 uid 追踪
|
2026-10-09 21:40:53 +08:00
|
|
|
|
LogUtil.write(`本地删除日程(标题已脱敏)uid=${cur.uid} 范围=${scope}`);
|
2026-09-13 23:35:52 +08:00
|
|
|
|
// 立即刷新提醒(取消已发布但日程已删的提醒)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ReminderService.refreshReminders(context as common.UIAbilityContext);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// 刷新失败不影响删除
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
this.getUIContext().getPromptAction().showToast({ message: '日程已删除' });
|
|
|
|
|
|
router.back();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
const ex = err as BusinessError;
|
|
|
|
|
|
this.statusMsg = `删除失败:${ex.message}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.isSaving = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
build() {
|
|
|
|
|
|
Column({ space: 14 }) {
|
2026-09-13 20:25:18 +08:00
|
|
|
|
// 顶部(与内容区左右对齐,避免贴边误触)
|
2026-09-13 15:50:37 +08:00
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
Text('取消')
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontColor($r('app.color.brand'))
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
|
2026-09-13 15:50:37 +08:00
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
router.back();
|
|
|
|
|
|
})
|
|
|
|
|
|
Blank()
|
|
|
|
|
|
Text(this.isExisting ? '编辑日程' : '新建日程')
|
|
|
|
|
|
.fontSize(18)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
Blank()
|
|
|
|
|
|
Text('保存')
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontColor(this.isSaving ? $r('app.color.text_hint') : $r('app.color.brand'))
|
|
|
|
|
|
.fontWeight(FontWeight.Medium)
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
|
2026-09-13 15:50:37 +08:00
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.save();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.padding({ left: 16, right: 16 })
|
2026-09-13 15:50:37 +08:00
|
|
|
|
|
2026-10-21 08:26:16 +08:00
|
|
|
|
Scroll(this.formScroller) {
|
2026-09-13 15:50:37 +08:00
|
|
|
|
Column({ space: 14 }) {
|
|
|
|
|
|
// 标题
|
|
|
|
|
|
TextInput({ text: this.title, placeholder: '标题' })
|
|
|
|
|
|
.height(46)
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
.borderRadius(12)
|
2026-09-16 21:40:11 +08:00
|
|
|
|
// 显式指定文字/提示/光标颜色:TextInput 默认取系统主题色,
|
|
|
|
|
|
// 深色模式下会变成浅色文字 → 与卡片底色撞色而"看不清"
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.placeholderColor($r('app.color.text_hint'))
|
|
|
|
|
|
.caretColor($r('app.color.brand'))
|
2026-09-13 15:50:37 +08:00
|
|
|
|
.onChange((v: string) => {
|
|
|
|
|
|
this.title = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 全天
|
|
|
|
|
|
Row() {
|
|
|
|
|
|
Text('全天')
|
|
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
Blank()
|
|
|
|
|
|
Toggle({ type: ToggleType.Switch, isOn: this.allDay })
|
|
|
|
|
|
.selectedColor($r('app.color.brand'))
|
|
|
|
|
|
.onChange(() => {
|
|
|
|
|
|
this.toggleAllDay();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding(14)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
|
2026-09-13 20:25:18 +08:00
|
|
|
|
// 时间卡片(单击任一行:底部弹层中日期+时间一次选完)
|
2026-09-13 15:50:37 +08:00
|
|
|
|
Column({ space: 10 }) {
|
|
|
|
|
|
this.timeRow('开始', true)
|
|
|
|
|
|
Divider().color($r('app.color.shadow_color'))
|
|
|
|
|
|
this.timeRow('结束', false)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding(6)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
|
2026-09-13 20:25:18 +08:00
|
|
|
|
// 重复
|
|
|
|
|
|
Column({ space: 8 }) {
|
|
|
|
|
|
Text('重复')
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
|
|
|
|
|
this.repeatChip('none', '不重复')
|
|
|
|
|
|
this.repeatChip('daily', '每天')
|
|
|
|
|
|
this.repeatChip('workday', '工作日')
|
|
|
|
|
|
this.repeatChip('weekly', '每周')
|
|
|
|
|
|
this.repeatChip('monthly', '每月')
|
|
|
|
|
|
this.repeatChip('yearly', '每年')
|
|
|
|
|
|
if (this.repeatMode === 'custom') {
|
|
|
|
|
|
// 服务器来的复杂规则(INTERVAL/COUNT 等)标记为自定义,保存时原样保留
|
|
|
|
|
|
this.repeatChip('custom', '自定义')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// ⭐ 结束条件:永不结束 / 重复到某天 / 共重复几次
|
|
|
|
|
|
// (新建与编辑都可用;编辑时会回显服务器上已有的 COUNT / UNTIL)
|
|
|
|
|
|
if (this.repeatMode !== 'none' && this.repeatMode !== 'custom') {
|
|
|
|
|
|
Divider().color($r('app.color.shadow_color'))
|
|
|
|
|
|
Text('结束条件')
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
|
|
|
|
|
this.repeatEndChip('never', '永不结束')
|
|
|
|
|
|
this.repeatEndChip('until', '重复到某天')
|
|
|
|
|
|
this.repeatEndChip('count', '共几次')
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
if (this.repeatEnd === 'until') {
|
|
|
|
|
|
Row({ space: 8 }) {
|
|
|
|
|
|
Text('截止日期')
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
Text(this.repeatUntil > 0 ? this.fmtDate(this.repeatUntil) : '请选择')
|
|
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.fontColor($r('app.color.brand'))
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
Text('›')
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontColor($r('app.color.text_hint'))
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 8, right: 8, top: 10, bottom: 10 })
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.openRepeatUntilPicker();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.repeatEnd === 'count') {
|
|
|
|
|
|
Row({ space: 8 }) {
|
|
|
|
|
|
Text('重复次数')
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
Blank()
|
|
|
|
|
|
Text('−')
|
|
|
|
|
|
.fontSize(20)
|
|
|
|
|
|
.fontColor($r('app.color.brand'))
|
|
|
|
|
|
.padding({ left: 14, right: 14 })
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.repeatCount = Math.max(1, Math.round(this.repeatCount) - 1);
|
|
|
|
|
|
})
|
|
|
|
|
|
Text(String(Math.round(this.repeatCount)))
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.width(44)
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
Text('+')
|
|
|
|
|
|
.fontSize(18)
|
|
|
|
|
|
.fontColor($r('app.color.brand'))
|
|
|
|
|
|
.padding({ left: 14, right: 14 })
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.repeatCount = Math.min(999, Math.round(this.repeatCount) + 1);
|
|
|
|
|
|
})
|
|
|
|
|
|
Text('次')
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-13 20:25:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding(14)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
|
|
|
|
|
|
// 提醒
|
|
|
|
|
|
Column({ space: 8 }) {
|
2026-09-13 23:35:52 +08:00
|
|
|
|
Text('提醒(可多选)')
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
|
|
|
|
|
this.reminderChip(0)
|
|
|
|
|
|
this.reminderChip(5)
|
|
|
|
|
|
this.reminderChip(10)
|
|
|
|
|
|
this.reminderChip(15)
|
|
|
|
|
|
this.reminderChip(30)
|
|
|
|
|
|
this.reminderChip(60)
|
|
|
|
|
|
this.reminderChip(1440)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding(14)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
|
2026-09-16 22:11:42 +08:00
|
|
|
|
// 日历本选择:两列等宽对齐(每项固定 48% 宽 + 两端对齐),只列 CalDAV 可写日历本
|
2026-09-13 15:50:37 +08:00
|
|
|
|
Column({ space: 8 }) {
|
2026-09-16 22:11:42 +08:00
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
Text('日历本')
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
Text('同步到 CalDAV')
|
|
|
|
|
|
.fontSize(11)
|
|
|
|
|
|
.fontColor($r('app.color.text_hint'))
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-09-16 22:11:42 +08:00
|
|
|
|
|
|
|
|
|
|
if (this.books.length === 0) {
|
|
|
|
|
|
// 没有可写入的 CalDAV 日历本:说明原因 + 给替代方案(本机日程请用系统「日历」)
|
|
|
|
|
|
Text('没有可写入的 CalDAV 日历本。请先到「更多 → 账号管理」添加账号;'
|
|
|
|
|
|
+ '这条日程需要同步到服务器。若只想记在本机,请使用系统「日历」应用。')
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.fontColor($r('app.color.error'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (this.chosenBook() === null) {
|
|
|
|
|
|
// 未选中任何日历本(如历史遗留的"本机"日程)→ 就地提示,别让用户找不到原因
|
|
|
|
|
|
Text(this.chosenKey === LOCAL_CAL_KEY
|
|
|
|
|
|
? '这条日程原先只存在「本机」(不同步)。请选一个日历本,保存后即会同步到服务器。'
|
|
|
|
|
|
: '请选择一个日历本,日程会同步到服务器。')
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.fontColor($r('app.color.brand_text'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
|
|
|
|
|
ForEach(this.books, (b: BookChoice) => {
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
Circle().width(8).height(8).fill(b.color)
|
|
|
|
|
|
Text(b.name)
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.maxLines(1)
|
|
|
|
|
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
|
|
|
.fontColor(this.chosenKey === b.calKey
|
|
|
|
|
|
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('48%')
|
|
|
|
|
|
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
|
|
|
|
|
.borderRadius(14)
|
|
|
|
|
|
.margin({ bottom: 8 })
|
|
|
|
|
|
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.chosenKey = b.calKey;
|
|
|
|
|
|
})
|
|
|
|
|
|
}, (b: BookChoice) => b.calKey)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
.alignItems(HorizontalAlign.Start)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding(14)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
|
2026-10-21 08:52:13 +08:00
|
|
|
|
// 地点(输入几个字 → 系统地图服务补全候选)
|
2026-10-21 09:03:31 +08:00
|
|
|
|
// ⭐ 候选列表**不在布局流里**:它通过 bindPopup 挂在输入框上、向**上方**弹出。
|
|
|
|
|
|
// 这样候选的出现/消失完全不改变输入框位置 —— 从根上消除"输入框上下跳"。
|
2026-10-21 08:52:13 +08:00
|
|
|
|
Row({ space: 8 }) {
|
|
|
|
|
|
TextInput({ text: this.location, placeholder: '地点(可选,输入几个字可搜索)' })
|
|
|
|
|
|
.height(44)
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.placeholderColor($r('app.color.text_hint'))
|
|
|
|
|
|
.caretColor($r('app.color.brand'))
|
|
|
|
|
|
.onChange((v: string) => {
|
2026-10-21 09:03:31 +08:00
|
|
|
|
// ⭐ 程序改写 text(选中候选后回写地址)也会触发 onChange。
|
|
|
|
|
|
// 精确比对最后一次程序写入值:命中就不再发起搜索,
|
|
|
|
|
|
// 否则会"选完 → 候选框消失 → 又冒出来一次"。
|
|
|
|
|
|
if (v === this.lastAppliedText) {
|
|
|
|
|
|
this.lastAppliedText = '';
|
|
|
|
|
|
this.location = v;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.lastAppliedText = '';
|
2026-10-21 08:52:13 +08:00
|
|
|
|
this.location = v;
|
|
|
|
|
|
// 用户重新输入时解除"已选中"状态,恢复自动补全
|
|
|
|
|
|
this.placePicked = false;
|
|
|
|
|
|
this.scheduleSuggest(v);
|
|
|
|
|
|
})
|
|
|
|
|
|
.onSubmit(() => {
|
|
|
|
|
|
// 回车:立刻搜一次(不等防抖)
|
|
|
|
|
|
this.cancelSuggestTimer();
|
|
|
|
|
|
this.runSuggest(this.location, true);
|
|
|
|
|
|
})
|
|
|
|
|
|
.onFocus(() => {
|
2026-10-21 09:03:31 +08:00
|
|
|
|
// 键盘避让交给系统(RESIZE 已保证光标可见);
|
|
|
|
|
|
// 候选是浮层,不需要再滚动页面去给它腾位置
|
2026-10-21 08:52:13 +08:00
|
|
|
|
this.placeFocused = true;
|
|
|
|
|
|
})
|
|
|
|
|
|
.onBlur(() => {
|
2026-10-21 09:03:31 +08:00
|
|
|
|
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选浮层
|
2026-10-21 08:52:13 +08:00
|
|
|
|
this.placeFocused = false;
|
2026-10-21 09:03:31 +08:00
|
|
|
|
this.closePlacePopup();
|
|
|
|
|
|
})
|
|
|
|
|
|
.bindPopup($$this.placeOpen, {
|
|
|
|
|
|
builder: (): void => {
|
|
|
|
|
|
this.placePopup()
|
|
|
|
|
|
},
|
|
|
|
|
|
placement: Placement.Top,
|
|
|
|
|
|
popupColor: $r('app.color.card_bg'),
|
|
|
|
|
|
enableArrow: false,
|
|
|
|
|
|
showInSubWindow: false,
|
|
|
|
|
|
focusable: false,
|
|
|
|
|
|
autoCancel: true,
|
|
|
|
|
|
radius: 12,
|
|
|
|
|
|
onStateChange: (e: PopupStateChangeParam) => {
|
|
|
|
|
|
// 用户点浮层外部/floating 自动关闭时,同步状态,避免下次不弹
|
|
|
|
|
|
if (!e.isVisible && this.placeOpen) {
|
|
|
|
|
|
this.placeOpen = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-21 08:52:13 +08:00
|
|
|
|
})
|
|
|
|
|
|
.id('placeAnchor')
|
|
|
|
|
|
// 在地图上选点(系统地图界面,可选可搜可周边)
|
|
|
|
|
|
Text('地图')
|
|
|
|
|
|
.fontSize(13)
|
|
|
|
|
|
.fontColor($r('app.color.brand_text'))
|
|
|
|
|
|
.padding({ left: 12, right: 12 })
|
|
|
|
|
|
.height(44)
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.pickOnMap();
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.id('placeRow')
|
|
|
|
|
|
|
2026-09-13 15:50:37 +08:00
|
|
|
|
// 描述
|
|
|
|
|
|
TextArea({ text: this.description, placeholder: '备注(可选)' })
|
|
|
|
|
|
.height(90)
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.backgroundColor($r('app.color.card_bg'))
|
|
|
|
|
|
.borderRadius(12)
|
2026-09-16 21:40:11 +08:00
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.placeholderColor($r('app.color.text_hint'))
|
|
|
|
|
|
.caretColor($r('app.color.brand'))
|
2026-09-13 15:50:37 +08:00
|
|
|
|
.onChange((v: string) => {
|
|
|
|
|
|
this.description = v;
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (this.statusMsg !== '') {
|
|
|
|
|
|
Text(this.statusMsg)
|
|
|
|
|
|
.fontSize(13)
|
|
|
|
|
|
.fontColor($r('app.color.error'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 删除
|
|
|
|
|
|
if (this.isExisting) {
|
|
|
|
|
|
Button('删除日程')
|
|
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.fontColor($r('app.color.error'))
|
|
|
|
|
|
.backgroundColor($r('app.color.error_bg'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height(44)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.enabled(!this.isSaving)
|
|
|
|
|
|
.onClick(() => {
|
2026-09-15 12:51:58 +08:00
|
|
|
|
// 二次确认后再执行删除
|
|
|
|
|
|
this.askRemoveEvent();
|
2026-09-13 15:50:37 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 20, right: 20, bottom: 30 })
|
|
|
|
|
|
.constraintSize({ minHeight: '100%' })
|
|
|
|
|
|
}
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.scrollBar(BarState.Off)
|
|
|
|
|
|
.edgeEffect(EdgeEffect.Spring)
|
|
|
|
|
|
.align(Alignment.Top)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height('100%')
|
|
|
|
|
|
.padding({ top: 12 })
|
|
|
|
|
|
.backgroundColor($r('app.color.page_bg'))
|
2026-09-16 21:40:11 +08:00
|
|
|
|
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
|
|
|
|
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
2026-10-09 22:41:17 +08:00
|
|
|
|
// ⚠️ 整个页面**只允许一个 bindSheet**:挂两个时后者会覆盖前者,
|
|
|
|
|
|
// 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
|
|
|
|
|
|
// 这里用 sheetMode 在同一个弹层里切换内容。
|
|
|
|
|
|
.bindSheet($$this.sheetShow, this.bottomSheet(), {
|
2026-10-10 07:05:32 +08:00
|
|
|
|
height: this.sheetMode === 'scope' ? 320 : (this.sheetMode === 'repeatEnd' ? 340 : 380),
|
2026-10-09 21:40:53 +08:00
|
|
|
|
showClose: false,
|
|
|
|
|
|
dragBar: true,
|
|
|
|
|
|
backgroundColor: $r('app.color.card_bg')
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 07:05:32 +08:00
|
|
|
|
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"/"重复范围选择"/"重复截止日期" */
|
2026-10-09 22:41:17 +08:00
|
|
|
|
@Builder
|
|
|
|
|
|
bottomSheet() {
|
|
|
|
|
|
if (this.sheetMode === 'scope') {
|
|
|
|
|
|
this.scopeSheet()
|
2026-10-10 07:05:32 +08:00
|
|
|
|
} else if (this.sheetMode === 'repeatEnd') {
|
|
|
|
|
|
this.repeatEndSheet()
|
2026-10-09 22:41:17 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
this.dateTimePickerSheet()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 07:05:32 +08:00
|
|
|
|
/** 重复"截止日期"选择弹层(只选日期) */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
repeatEndSheet() {
|
|
|
|
|
|
Column({ space: 14 }) {
|
|
|
|
|
|
Text('重复到哪一天')
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
.padding({ top: 12 })
|
|
|
|
|
|
DatePicker({
|
|
|
|
|
|
start: new Date(2000, 0, 1),
|
|
|
|
|
|
end: new Date(2060, 11, 31),
|
|
|
|
|
|
selected: this.pickDate
|
|
|
|
|
|
})
|
|
|
|
|
|
.onDateChange((value: Date) => {
|
|
|
|
|
|
this.pickDate = value;
|
|
|
|
|
|
})
|
|
|
|
|
|
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
|
|
|
|
|
|
.selectedTextStyle({
|
|
|
|
|
|
color: $r('app.color.text_primary'),
|
|
|
|
|
|
font: { size: 16, weight: FontWeight.Medium }
|
|
|
|
|
|
})
|
|
|
|
|
|
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
|
|
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.height(210)
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
Button('确定')
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height(44)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.backgroundColor($r('app.color.brand'))
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
const d = this.pickDate;
|
|
|
|
|
|
this.repeatUntil = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
|
|
|
|
this.sheetShow = false;
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 16, right: 16, bottom: 24 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-09 21:40:53 +08:00
|
|
|
|
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
scopeSheet() {
|
|
|
|
|
|
Column({ space: 8 }) {
|
|
|
|
|
|
Text(this.pendingDelete ? '删除重复日程' : '修改重复日程')
|
|
|
|
|
|
.fontSize(17)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
.padding({ top: 14, bottom: 4 })
|
|
|
|
|
|
Text('这是一个重复日程,请选择作用的范围')
|
|
|
|
|
|
.fontSize(13)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
.padding({ bottom: 6 })
|
|
|
|
|
|
this.scopeOption('one', this.pendingDelete ? '仅删除这一次' : '仅修改这一次',
|
|
|
|
|
|
'只影响所选日期的这一次,其余保持不变')
|
|
|
|
|
|
this.scopeOption('future', this.pendingDelete ? '删除此次及以后' : '修改此次及以后',
|
|
|
|
|
|
'从这一次开始到最后一次都' + (this.pendingDelete ? '删除' : '修改'))
|
|
|
|
|
|
this.scopeOption('all', this.pendingDelete ? '删除全部' : '修改全部',
|
|
|
|
|
|
'整条重复日程的所有发生都' + (this.pendingDelete ? '删除' : '修改'))
|
|
|
|
|
|
Text('取消')
|
|
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
.padding({ top: 12, bottom: 16 })
|
|
|
|
|
|
.onClick(() => {
|
2026-10-09 22:41:17 +08:00
|
|
|
|
this.sheetShow = false;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 20, right: 20 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
scopeOption(mode: string, title: string, desc: string) {
|
|
|
|
|
|
Column({ space: 3 }) {
|
|
|
|
|
|
Text(title)
|
|
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.fontWeight(FontWeight.Medium)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Start)
|
|
|
|
|
|
Text(desc)
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Start)
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.backgroundColor($r('app.color.chip_off_bg'))
|
|
|
|
|
|
.onClick(() => {
|
2026-10-09 22:41:17 +08:00
|
|
|
|
this.sheetShow = false;
|
2026-10-09 21:40:53 +08:00
|
|
|
|
if (this.pendingDelete) {
|
|
|
|
|
|
this.removeEvent(mode);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.doSave(mode);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 14:20:14 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 页面转场:淡入淡出。
|
|
|
|
|
|
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
|
|
|
|
|
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
pageTransition() {
|
|
|
|
|
|
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
|
|
|
|
|
.opacity(0);
|
|
|
|
|
|
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
|
|
|
|
|
.opacity(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 15:50:37 +08:00
|
|
|
|
@Builder
|
|
|
|
|
|
timeRow(label: string, isStart: boolean) {
|
|
|
|
|
|
Row({ space: 8 }) {
|
|
|
|
|
|
Text(label)
|
|
|
|
|
|
.fontSize(14)
|
|
|
|
|
|
.fontColor($r('app.color.text_secondary'))
|
|
|
|
|
|
.width(36)
|
2026-09-13 20:25:18 +08:00
|
|
|
|
Text(this.allDay
|
|
|
|
|
|
? this.fmtDate(isStart ? this.startMs : this.endMs)
|
|
|
|
|
|
: `${this.fmtDate(isStart ? this.startMs : this.endMs)} ${this.fmtTime(isStart ? this.startMs : this.endMs)}`)
|
2026-09-13 15:50:37 +08:00
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.fontColor($r('app.color.brand'))
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
Text('›')
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontColor($r('app.color.text_hint'))
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.padding({ left: 8, right: 8, top: 10, bottom: 10 })
|
|
|
|
|
|
.onClick(() => {
|
2026-09-13 23:35:52 +08:00
|
|
|
|
// 注意:timeRow 的参数是 isStart,openPicker 的参数是 isEnd,语义相反必须取反
|
|
|
|
|
|
this.openPicker(!isStart);
|
2026-09-13 20:25:18 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
repeatChip(mode: string, label: string) {
|
|
|
|
|
|
Text(label)
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.fontColor(this.repeatMode === mode
|
|
|
|
|
|
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
|
|
|
|
|
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
|
|
|
|
|
.borderRadius(14)
|
|
|
|
|
|
.margin({ right: 8, bottom: 8 })
|
|
|
|
|
|
.backgroundColor(this.repeatMode === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.repeatMode = mode;
|
2026-10-10 07:05:32 +08:00
|
|
|
|
// 切换到"重复"时给个合理默认:永不结束(用户可按需改为到某天/共几次)
|
|
|
|
|
|
if (mode !== 'none' && this.repeatEnd === '') {
|
|
|
|
|
|
this.repeatEnd = 'never';
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 结束条件 chip:永不结束 / 重复到某天 / 共几次 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
repeatEndChip(mode: string, label: string) {
|
|
|
|
|
|
Text(label)
|
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
|
.fontColor(this.repeatEnd === mode
|
|
|
|
|
|
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
|
|
|
|
|
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
|
|
|
|
|
.borderRadius(14)
|
|
|
|
|
|
.margin({ right: 8, bottom: 8 })
|
|
|
|
|
|
.backgroundColor(this.repeatEnd === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.repeatEnd = mode;
|
|
|
|
|
|
if (mode === 'until' && this.repeatUntil <= 0) {
|
|
|
|
|
|
// 默认给"从开始时间起 3 个月后"
|
|
|
|
|
|
const s = new Date(this.startMs);
|
|
|
|
|
|
this.repeatUntil = new Date(s.getFullYear(), s.getMonth() + 3, s.getDate()).getTime();
|
|
|
|
|
|
}
|
2026-09-13 20:25:18 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 23:35:52 +08:00
|
|
|
|
/** 提醒 chip 是否选中:0 = 不提醒(列表为空时选中);其余按是否已选 */
|
|
|
|
|
|
private chipOn(minutes: number): boolean {
|
|
|
|
|
|
if (minutes === 0) {
|
|
|
|
|
|
return this.reminderList.length === 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return this.reminderList.includes(minutes);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 切换提醒:0 = 清空全部;其余多选切换 */
|
|
|
|
|
|
private toggleReminder(minutes: number): void {
|
|
|
|
|
|
if (minutes === 0) {
|
|
|
|
|
|
this.reminderList = [];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const list: number[] = [...this.reminderList];
|
|
|
|
|
|
const idx: number = list.indexOf(minutes);
|
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
|
list.splice(idx, 1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
list.push(minutes);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.reminderList = list;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 20:25:18 +08:00
|
|
|
|
@Builder
|
|
|
|
|
|
reminderChip(minutes: number) {
|
|
|
|
|
|
Text(this.reminderLabel(minutes))
|
|
|
|
|
|
.fontSize(12)
|
2026-09-13 23:35:52 +08:00
|
|
|
|
.fontColor(this.chipOn(minutes)
|
2026-09-13 20:25:18 +08:00
|
|
|
|
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
|
|
|
|
|
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
|
|
|
|
|
.borderRadius(14)
|
|
|
|
|
|
.margin({ right: 8, bottom: 8 })
|
2026-09-13 23:35:52 +08:00
|
|
|
|
.backgroundColor(this.chipOn(minutes) ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.onClick(() => {
|
2026-09-13 23:35:52 +08:00
|
|
|
|
this.toggleReminder(minutes);
|
2026-09-13 20:25:18 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 开始/结束时间选择弹层:左侧日期、右侧时间(全天时只有日期),一次确定 */
|
|
|
|
|
|
@Builder
|
|
|
|
|
|
dateTimePickerSheet() {
|
|
|
|
|
|
Column({ space: 14 }) {
|
|
|
|
|
|
Text(this.pickerIsEnd ? '选择结束时间' : '选择开始时间')
|
|
|
|
|
|
.fontSize(16)
|
|
|
|
|
|
.fontWeight(FontWeight.Bold)
|
|
|
|
|
|
.fontColor($r('app.color.text_primary'))
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.textAlign(TextAlign.Center)
|
|
|
|
|
|
.padding({ top: 12 })
|
|
|
|
|
|
Row({ space: 6 }) {
|
|
|
|
|
|
DatePicker({
|
|
|
|
|
|
start: new Date(2000, 0, 1),
|
|
|
|
|
|
end: new Date(2050, 11, 31),
|
|
|
|
|
|
selected: this.pickDate
|
|
|
|
|
|
})
|
|
|
|
|
|
.onDateChange((value: Date) => {
|
|
|
|
|
|
this.pickDate = value;
|
|
|
|
|
|
})
|
2026-09-16 21:40:11 +08:00
|
|
|
|
// 滚轮文字显式指定颜色:默认取系统主题色,深色模式下与弹层底色撞色会"看不清"
|
|
|
|
|
|
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
|
|
|
|
|
|
.selectedTextStyle({
|
|
|
|
|
|
color: $r('app.color.text_primary'),
|
|
|
|
|
|
font: { size: 16, weight: FontWeight.Medium }
|
|
|
|
|
|
})
|
|
|
|
|
|
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.height(210)
|
|
|
|
|
|
if (!this.allDay) {
|
|
|
|
|
|
TimePicker({
|
|
|
|
|
|
selected: new Date(2000, 0, 1, this.pickHour, this.pickMin)
|
|
|
|
|
|
})
|
|
|
|
|
|
.onChange((value: TimePickerResult) => {
|
|
|
|
|
|
this.pickHour = value.hour;
|
|
|
|
|
|
this.pickMin = value.minute;
|
|
|
|
|
|
})
|
2026-09-16 21:40:11 +08:00
|
|
|
|
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
|
|
|
|
|
|
.selectedTextStyle({
|
|
|
|
|
|
color: $r('app.color.text_primary'),
|
|
|
|
|
|
font: { size: 16, weight: FontWeight.Medium }
|
|
|
|
|
|
})
|
|
|
|
|
|
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
|
2026-09-13 20:25:18 +08:00
|
|
|
|
.layoutWeight(1)
|
|
|
|
|
|
.height(210)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
|
|
|
|
|
|
Button(this.allDay ? '确定日期' : '确定')
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.height(44)
|
|
|
|
|
|
.borderRadius(12)
|
|
|
|
|
|
.fontSize(15)
|
|
|
|
|
|
.backgroundColor($r('app.color.brand'))
|
|
|
|
|
|
.onClick(() => {
|
|
|
|
|
|
this.applyPicker();
|
2026-10-09 22:41:17 +08:00
|
|
|
|
this.sheetShow = false;
|
2026-09-13 20:25:18 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
.width('100%')
|
|
|
|
|
|
.padding({ left: 16, right: 16, bottom: 24 })
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 22:11:42 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 桥接:从账号存储拿**可写入且能同步**的日历本(只取 CalDAV 日历本),并附上 href。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 刻意**不返回"本机"(calKey='local')**:本应用新建/编辑的日程一律写入某个 CalDAV
|
|
|
|
|
|
* 日历本并同步到服务器;纯本机日程请用户改用系统「日历」应用完成
|
|
|
|
|
|
* (本应用也没有申请写系统日历本地的权限,做不到)。
|
|
|
|
|
|
*/
|
2026-09-13 15:50:37 +08:00
|
|
|
|
class CalendarDataBridge {
|
2026-09-16 22:11:42 +08:00
|
|
|
|
static async loadWritableDavSources(context: common.Context): Promise<CalSourceWithHref[]> {
|
2026-09-13 15:50:37 +08:00
|
|
|
|
const result: CalSourceWithHref[] = [];
|
|
|
|
|
|
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
2026-09-13 20:25:18 +08:00
|
|
|
|
const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context);
|
2026-09-13 15:50:37 +08:00
|
|
|
|
for (const acc of accounts) {
|
|
|
|
|
|
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
2026-09-13 20:25:18 +08:00
|
|
|
|
// 只读日历本(服务器无写权限,或用户手动标记只读)不可选
|
|
|
|
|
|
const detected: boolean = acc.calendarWritable.length > i
|
|
|
|
|
|
? acc.calendarWritable[i] !== '0' : true;
|
|
|
|
|
|
if (!detected || manualKeys.includes(`${acc.id}_${i}`)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-09-13 15:50:37 +08:00
|
|
|
|
const s = new CalSourceWithHref();
|
|
|
|
|
|
s.calKey = `${acc.id}_${i}`;
|
|
|
|
|
|
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
|
|
|
|
|
|
if (name === '') {
|
|
|
|
|
|
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
s.name = `${acc.name} · ${name}`;
|
|
|
|
|
|
let color: string = i < acc.calendarColors.length ? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
|
2026-09-15 23:03:58 +08:00
|
|
|
|
s.color = color !== '' ? color : BookPalette.colorForHref(acc.calendarHrefs[i]);
|
2026-09-13 15:50:37 +08:00
|
|
|
|
s.href = acc.calendarHrefs[i];
|
2026-09-13 20:25:18 +08:00
|
|
|
|
s.writable = true;
|
2026-09-13 15:50:37 +08:00
|
|
|
|
result.push(s);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
2026-10-09 22:19:31 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ⭐ 按 calKey(必要时回退按 href 匹配)解析一个日历本的基本信息,
|
|
|
|
|
|
* 用于"编辑既有日程时,它的日历本不在可写清单里"的兜底补全。
|
|
|
|
|
|
* 找不到时返回 null(界面会给出明确提示,不静默失败)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
static async lookupBook(context: common.Context, calKey: string,
|
|
|
|
|
|
href: string): Promise<BookLookup | null> {
|
|
|
|
|
|
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
|
|
|
|
|
for (const acc of accounts) {
|
|
|
|
|
|
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
|
|
|
|
|
const key: string = `${acc.id}_${i}`;
|
|
|
|
|
|
if (key !== calKey && !(href !== '' && acc.calendarHrefs[i] === href)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
const out = new BookLookup();
|
|
|
|
|
|
out.calKey = key;
|
|
|
|
|
|
out.href = acc.calendarHrefs[i];
|
|
|
|
|
|
let name: string = i < acc.calendarNames.length ? acc.calendarNames[i] : '';
|
|
|
|
|
|
if (name === '') {
|
|
|
|
|
|
name = acc.calendarHrefs.length === 1 ? acc.name : `日历本 ${i + 1}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
out.name = `${acc.name} · ${name}`;
|
|
|
|
|
|
let color: string = i < acc.calendarColors.length
|
|
|
|
|
|
? AccountStore.normalizeColor(acc.calendarColors[i]) : '';
|
|
|
|
|
|
out.color = color !== '' ? color : BookPalette.colorForHref(acc.calendarHrefs[i]);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** lookupBook 的返回值(命名 class:ArkTS 不许用内联对象字面量类型) */
|
|
|
|
|
|
class BookLookup {
|
|
|
|
|
|
calKey: string = '';
|
|
|
|
|
|
href: string = '';
|
|
|
|
|
|
name: string = '';
|
|
|
|
|
|
color: string = '#007DFF';
|
2026-09-13 15:50:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class CalSourceWithHref extends CalSource {
|
|
|
|
|
|
href: string = '';
|
|
|
|
|
|
}
|