Files
SyncCalendar/entry/src/main/ets/pages/EventEditPage.ets
T

1952 lines
80 KiB
Plaintext
Raw Normal View History

2026-09-13 15:50:37 +08:00
// entry/src/main/ets/pages/EventEditPage.ets
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
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';
import { notificationManager } from '@kit.NotificationKit';
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';
import { AppSettings } from '../common/AppSettings';
2026-09-13 15:50:37 +08:00
import { DavClient } from '../common/DavClient';
import { SyncEngine } from '../common/SyncEngine';
import { ReminderService } from '../common/ReminderService';
import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil';
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
/** 重复系列某次发生的前后边界(用于 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 = '';
/**
* ⭐ 地点补全(鸿蒙 Map Kit / Site Kit):
* placeItems —— 候选数据(**不参与页面布局**,由 bindPopup 渲染成浮层)
* placeLoading —— 是否正在请求,浮层内显示"搜索中"
* placeOpen —— 浮层是否弹出(绑在输入框上,向上弹)
* placeHint —— 无结果/地图能力不可用时的提示(不阻塞手输地点)
*
* ⚠️ 为什么必须是浮层而不是内联列表:内联列表改变表单高度 ⇒ 输入框跟着移动 ⇒
* 每次"搜索中/出结果/结果变少"都要滚动纠正 ⇒ 用户看到输入框反复上下跳。
*/
@State placeItems: PlaceItem[] = [];
@State placeLoading: boolean = false;
@State placeOpen: boolean = false;
@State placeHint: string = '';
/** 用户是否已从候选里选中(选中后不再自动弹候选,避免"选完又弹出") */
private placePicked: boolean = false;
/**
* 最后一次**程序写入**输入框的文本(选中候选后回写的地址)。
* TextInput 的 text 被程序改写同样会触发 onChange;用它精确识别"这次 onChange
* 不是用户打字造成的",从而不重复发起搜索。
*/
private lastAppliedText: string = '';
/** 地点输入框当前是否聚焦(仅用于日志/状态,不再驱动滚动) */
private placeFocused: boolean = false;
/** 输入防抖定时器 */
private placeTimer: number = -1;
/** 表单滚动控制器(键盘避让由系统 RESIZE 模式负责,这里不再主动滚) */
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;
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
/**
* ⭐ 重复的"结束条件"(新建与编辑都可用):
* 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;
@State reminderList: number[] = []; // 提前提醒分钟数(多选),空 = 不提醒
/**
* ⭐ 底部弹层:**整个页面只能挂一个 `bindSheet`**。
* ArkUI 里同一组件挂多个 bindSheet 时,后一个会覆盖前一个 —— 实测表现就是
* "时间选择框白框一闪而过、什么都没出来"(范围弹层把时间弹层顶掉了)。
* 因此这里用一个状态 + 一个模式来复用同一个弹层:
* sheetMode = 'picker' → 日期/时间选择
* sheetMode = 'scope' → 重复日程范围选择
*/
@State sheetShow: boolean = false;
@State sheetMode: string = 'picker';
@State pickerIsEnd: boolean = false;
@State pickDate: Date = new Date();
@State pickHour: number = 9;
@State pickMin: number = 0;
/** ⭐ 重复日程编辑范围:'one' 仅此次 / 'future' 此次及以后 / 'all' 全部 */
@State scope: string = 'all';
private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程
/**
* ⭐ 载入时的**原始行快照**(不可变)。
* `doSave` 会就地改写 `this.event` 的 calKey/href/startTime 等字段,
* 而"重复系列"的范围计算(EXDATE / UNTIL / 拆系列)必须依据**原始**的
* 系列标识(原 calKey、原 href、原发生时刻),否则会算错。
*/
private original: LocalEvent | null = null;
/** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */
private occStartMs: number = 0;
2026-09-13 15:50:37 +08:00
private event: LocalEvent | null = null;
aboutToAppear(): Promise<void> {
// 键盘避让不阻塞页面加载:拿窗口是异步的,失败也只是少一层滚动校正
this.setupKeyboardAvoid();
2026-09-13 15:50:37 +08:00
return this.initPage();
}
/** 页面销毁:清掉地点补全的防抖定时器 */
aboutToDisappear(): void {
this.cancelSuggestTimer();
}
/**
* ⭐ 键盘避让:改成 RESIZE 模式。
* 默认 OFFSET 是「整页上移」,页面上移后表单底部(地点/备注/按钮)会被顶出屏幕。
* RESIZE 是「把页面高度压缩到键盘上沿」,滚动区域随之变矮,
* 聚焦的输入框由系统自动滚入可见区,**无需再手动 scrollBy**。
*
* ⚠️ 曾经写过"手动量矩形 + scrollBy 对齐"的方案,是错的方向:
* ① getRectangleById 返回 px、scrollBy 收 vp,单位混用会把输入框推飞;
* ② 更根本的是——只要候选参与布局,位置就会反复变化,
* 靠"事后纠正"必然表现为抖动。所以候选改成了浮层(见 placePopup)。
*/
private setupKeyboardAvoid(): void {
try {
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`设置键盘避让模式失败: ${e.message}`);
}
}
2026-09-13 15:50:37 +08:00
private async initPage(): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
// 收集可写入的日历本:只取 **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;
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;
// ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event)。
// ⚠️ 必须是**完整**拷贝 —— 曾经漏了 title/description/location/reminders,
// 导致"只改这一次"时把这一行还原成空标题(BUG:已有标题被删没)。
const snap = new LocalEvent();
snap.id = loaded.id;
snap.uid = loaded.uid;
snap.calKey = loaded.calKey;
snap.href = loaded.href;
snap.remotePath = loaded.remotePath;
snap.title = loaded.title;
snap.description = loaded.description;
snap.location = loaded.location;
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;
snap.reminder = loaded.reminder;
snap.reminders = loaded.reminders.slice();
this.original = snap;
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;
// ⭐ 若这条日程所属的日历本不在"可写日历本"列表里(例如该本被标记只读、
// 或服务器写权限探测暂时失败),把它补进选择列表,保证 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]);
}
}
// 历史遗留的"本机"日程:本应用已不再支持本机日程,改为引导用户挑一个 CalDAV 日历本
// (保存时 calKey/href 会改写为该日历本,事件随后被推送到服务器)
if (loaded.calKey === LOCAL_CAL_KEY) {
this.statusMsg = '这条日程原先只存在「本机」(不同步)。请选一个 CalDAV 日历本,保存后即会同步到服务器。';
}
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
this.parseRepeatEnd(loaded.rrule);
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
: (loaded.reminder > 0 ? [loaded.reminder] : []);
// ⭐ 目标发生时刻("仅这一次"要排除/覆盖的那一次):
// ① 覆盖行 → 用 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;
}
}
// 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 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();
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;
}
}
// ==================== 地点补全(鸿蒙 Map Kit / Site Kit) ====================
/**
* 输入防抖:停止输入 350ms 后才真正发请求。
* 好处:① 不把服务端配额/网络浪费在"每敲一个字"上;② 避免结果闪烁。
* ⚠️ 这里只用「关键词补全」这一种能力(不需要定位权限),
* 所以**不需要**申请 LOCATION 权限,符合"权限只在用户主动点该功能时申请"的约束。
*/
private scheduleSuggest(v: string): void {
this.cancelSuggestTimer();
const q: string = v.trim();
if (q.length < 1) {
this.closePlacePopup();
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 = '地图服务不可用,可直接手动输入地点';
this.placeItems = [];
this.placeOpen = true;
return;
}
// ⭐ 浮层一直保持展开(从"搜索中"过渡到结果),中间不收起、不重弹 ——
// 收起再弹会让浮层闪一下,看起来像"消失了又出现"。
this.placeLoading = true;
this.placeHint = '';
this.placeOpen = true;
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;
}
this.placeItems = items.slice(0, 6);
if (this.placeItems.length === 0) {
this.placeHint = PlaceSuggest.isUnavailable()
? '地图服务不可用,可直接手动输入地点'
: '没有找到匹配地点,可直接手动输入';
// 无结果也留在浮层里给提示;用户再输入会被下一次请求覆盖
this.placeOpen = true;
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`地点补全异常: ${e.message}`);
this.placeItems = [];
this.placeHint = '地点搜索失败,可直接手动输入';
this.placeOpen = true;
} finally {
this.placeLoading = false;
}
}
/**
* ⭐ 候选浮层(bindPopup)的内容。
*
* 为什么是浮层而不是内联列表:
* 内联列表会改变表单高度 ⇒ 输入框位置随之变化 ⇒ 每次"搜索中/出结果/结果变少"
* 都要靠滚动去纠正 ⇒ 用户看到的就是**输入框上下跳好几次**。
* 浮层不参与布局,出现与消失**完全不移动输入框**,这是唯一能根治该问题的结构。
*
* 为什么向上弹(Placement.Top):
* 输入框在表单下部、键盘又在下方,向下弹必然被键盘盖住;
* 向上弹则整块候选都落在输入框与标题之间的空白区。
*/
@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'))
}
.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 })
}
/** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void {
// ⭐ 顺序很关键:先收起浮层,再写文本。
// TextInput 的 text 被程序改写会触发 onChange,若此时 placePicked 已被
// onChange 重置为 false,就会**重新发起一次搜索** → 用户看到
// "候选框消失了一下又冒出来一次"。用 lastAppliedText 精确识别这类回写。
this.cancelSuggestTimer();
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 = '';
}
/** 打开系统地图选点页(可搜索、可周边、可拖图钉) */
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 {
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())}`;
}
/** 重复模式 → 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
}
/** 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
}
/** 从已有 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;
}
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
}
/** 打开时间选择弹层:日期 + 时间一次选完 */
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();
this.sheetMode = 'picker';
this.sheetShow = true;
}
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 {
// 设定开始时间后,结束时间自动跟随 = 开始 + 1 小时
this.startMs = picked;
this.endMs = this.allDay ? picked + 86399999 : picked + 3600000;
}
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;
}
/**
* 当前编辑的是不是一个"重复系列中的某一次发生"?
* - 主事件行(rrule 非空):用户点开的可能就是第一次发生或其展开实例 → 需要询问范围
* - 覆盖实例行(recurring && rrule==''):明确就是被单独改写过的那一次 → 需要询问范围
* 非重复的普通日程返回 false,直接按原逻辑保存/删除。
*/
private isSeriesOccurrence(): boolean {
const src: LocalEvent | null = this.original ?? this.event;
if (src === null) {
return false;
}
return src.recurring || src.rrule !== '';
}
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行)。
* ⚠️ 必须按**原始** calKey/uid 查找(doSave 会改写 this.event 的 calKey)。 */
private async loadMaster(context: common.Context): Promise<LocalEvent | null> {
const src: LocalEvent | null = this.original ?? this.event;
if (src === null) {
return null;
}
const rows: LocalEvent[] = await EventDb.queryByUid(context, src.calKey, src.uid);
for (const r of rows) {
if (r.rrule !== '') {
return r;
}
}
return src;
}
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分)。
* ⭐ 会用展开结果**校正 occStartMs**:调用方传来的时刻可能带秒/毫秒偏差
* (列表里点开的那次发生时间是精确的,但主事件运行到卡片等路径可能有偏差),
* 必须吸附到"真实存在的那一次",否则 EXDATE 写出的时刻匹配不上任何发生 →
* 表现为"选了仅这一次,却把所有次都改了"。 */
private async seriesBoundaries(context: common.Context): Promise<SeriesBounds> {
const b = new SeriesBounds();
let occ: number = this.occStartMs;
b.thisOcc = occ;
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null || master.rrule === '') {
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);
}
}
}
// 展开窗口:主事件开始 ~ "该次"之后一年(覆盖主事件的首次发生)
const winEnd: number = Math.max(occ, master.startTime) + 366 * 86400000;
const occs: number[] = RruleUtil.expand(master.rrule, master.startTime,
master.startTime, winEnd, exNums, 3000);
// ⭐ 吸附:找与 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;
}
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;
// ⚠️ 用**原始快照**判据:doSave 已把 e(= this.event)的 calKey/href/startTime 改成新值,
// 直接用 this.event 会把"改后的日期/日历本"当成原系列,导致 EXDATE / UNTIL 算错。
const cur: LocalEvent | null = this.original;
// 用户点开的是"某一次发生"(occProvided=true)时,list/周视图里的行 id 是**主事件 id**,
// 所以"改全部"也必须给用户明确的确认——但语义上就是改整条系列:
// · 点开的是主事件行本身(occProvided=false)→ 直接改这一行就是改整个系列,无需特殊处理
// · 点开的是某一次发生 且选了"全部" → 把改动写到主事件行上(整系列平移/改名)
if (isNew || cur === null || !this.isSeriesOccurrence()) {
if (isNew) {
await EventDb.insertLocal(context, target);
} else {
await EventDb.updateLocal(context, target);
}
2026-09-13 15:50:37 +08:00
return;
}
const bounds: SeriesBounds = await this.seriesBoundaries(context);
if (scope === 'all') {
// 全部:改到**主事件行**上(整系列一起改)。
// ⚠️ 不能直接 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;
}
await EventDb.updateLocal(context, target);
return;
}
if (scope === 'one') {
if (cur.rrule === '' && cur.recurrenceId > 0) {
// 已经是覆盖实例 → 直接改它(保留 recurrenceId)
target.recurrenceId = cur.recurrenceId;
await EventDb.updateLocal(context, target);
return;
}
// 主事件上的"仅此次":加 EXDATE 排除该次 + 插入覆盖行
const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) {
return;
}
// ⚠️ **顺序很重要**:先把主事件行还原为原值(用户在编辑页改的标题/时间只属于"这一次",
// 不能污染主事件),**再**写 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(';');
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 会被原系列继续展开)。
// ⚠️ 判据要用**原始主事件的开始时间**(cur.startTime 是"载入时的行时间",
// 对主事件行而言就是它的第一次发生时刻)。
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;
// 新的重复规则:优先用界面上设置的(含"重复到某天 / 共几次"),
// 否则沿用原系列的重复节奏,保持"此次及以后"不变
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 {
// ⚠️ 顺序:先还原主事件行(本次编辑只属于"此次及以后"的新系列,
// 不能留在原主事件上),**再**截断 RRULE;反过来会被还原覆盖掉截断结果。
if (cur.rrule !== '' && master.id === cur.id) {
await EventDb.restoreRowById(context, master.id, cur);
}
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
}
}
private async save(): Promise<void> {
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;
this.sheetMode = 'scope';
this.sheetShow = true;
LogUtil.write('保存判定:重复系列 → 弹出范围选择');
return;
}
if (!this.validate()) {
LogUtil.write(`保存判定:校验未通过(${this.statusMsg})`);
2026-09-13 15:50:37 +08:00
return;
}
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
return;
}
await this.doSave('all');
}
/** 真正执行保存(scope 已被用户确认) */
private async doSave(scope: string): Promise<void> {
if (this.isSaving) {
return;
}
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
if (!this.validate()) {
return;
}
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
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;
// 一律写入所选 CalDAV 日历本(新建/编辑/从「本机」迁过来都一样)
e.calKey = book.calKey;
e.href = book.href;
// 重复规则与提醒(含"重复到某天 / 共几次"的结束条件)
e.rrule = this.effectiveRrule();
e.recurring = e.rrule !== '';
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 {
// 重复系列的"仅此次/此次及以后"需要特殊落库(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)
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 范围=${scope} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能,
// 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗)
if (e.reminders.length > 0) {
await this.ensureNotifyPermission();
}
// 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效
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;
LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`);
2026-09-13 15:50:37 +08:00
this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`;
// 数据仍在本地且带 dirty 标记,不会丢
router.back();
}
this.isSaving = false;
}
/**
* 确认通知权限已开启 —— 仅"用户保存了带提醒的日程"这条路径会调用。
* 日程提醒依赖系统通知,用户刚主动设置了提醒,此时申请权限与其使用目的完全一致。
* ⚠️ 严禁在 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;
}
// ⭐ 重复日程:先让用户选择"删这一次 / 此次及以后 / 全部"
if (this.isSeriesOccurrence()) {
this.pendingDelete = true;
this.sheetMode = 'scope';
this.sheetShow = true;
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 => {
this.removeEvent('all');
2026-09-15 12:51:58 +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;
}
const cur: LocalEvent = this.original ?? this.event;
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);
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 追踪
LogUtil.write(`本地删除日程(标题已脱敏)uid=${cur.uid} 范围=${scope}`);
// 立即刷新提醒(取消已发布但日程已删的提醒)
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 15:50:37 +08:00
Row({ space: 6 }) {
Text('取消')
.fontSize(16)
.fontColor($r('app.color.brand'))
.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)
.padding({ left: 4, right: 4, top: 8, bottom: 8 })
2026-09-13 15:50:37 +08:00
.onClick(() => {
this.save();
})
}
.width('100%')
.padding({ left: 16, right: 16 })
2026-09-13 15:50:37 +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)
// 显式指定文字/提示/光标颜色: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 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'))
// 重复
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%')
// ⭐ 结束条件:永不结束 / 重复到某天 / 共重复几次
// (新建与编辑都可用;编辑时会回显服务器上已有的 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 })
}
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
// 提醒
Column({ space: 8 }) {
Text('提醒(可多选)')
.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'))
// 日历本选择:两列等宽对齐(每项固定 48% 宽 + 两端对齐),只列 CalDAV 可写日历本
2026-09-13 15:50:37 +08:00
Column({ space: 8 }) {
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%')
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'))
// 地点(输入几个字 → 系统地图服务补全候选)
// ⭐ 候选列表**不在布局流里**:它通过 bindPopup 挂在输入框上、向**上方**弹出。
// 这样候选的出现/消失完全不改变输入框位置 —— 从根上消除"输入框上下跳"。
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) => {
// ⭐ 程序改写 text(选中候选后回写地址)也会触发 onChange。
// 精确比对最后一次程序写入值:命中就不再发起搜索,
// 否则会"选完 → 候选框消失 → 又冒出来一次"。
if (v === this.lastAppliedText) {
this.lastAppliedText = '';
this.location = v;
return;
}
this.lastAppliedText = '';
this.location = v;
// 用户重新输入时解除"已选中"状态,恢复自动补全
this.placePicked = false;
this.scheduleSuggest(v);
})
.onSubmit(() => {
// 回车:立刻搜一次(不等防抖)
this.cancelSuggestTimer();
this.runSuggest(this.location, true);
})
.onFocus(() => {
// 键盘避让交给系统(RESIZE 已保证光标可见);
// 候选是浮层,不需要再滚动页面去给它腾位置
this.placeFocused = true;
})
.onBlur(() => {
// 失焦(点"地图"按钮、点别处、收起键盘)→ 收起候选浮层
this.placeFocused = false;
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;
}
}
})
.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)
.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'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// ⚠️ 整个页面**只允许一个 bindSheet**:挂两个时后者会覆盖前者,
// 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
// 这里用 sheetMode 在同一个弹层里切换内容。
.bindSheet($$this.sheetShow, this.bottomSheet(), {
height: this.sheetMode === 'scope' ? 320 : (this.sheetMode === 'repeatEnd' ? 340 : 380),
showClose: false,
dragBar: true,
backgroundColor: $r('app.color.card_bg')
})
}
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"/"重复范围选择"/"重复截止日期" */
@Builder
bottomSheet() {
if (this.sheetMode === 'scope') {
this.scopeSheet()
} else if (this.sheetMode === 'repeatEnd') {
this.repeatEndSheet()
} else {
this.dateTimePickerSheet()
}
}
/** 重复"截止日期"选择弹层(只选日期) */
@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 })
}
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
@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(() => {
this.sheetShow = false;
})
}
.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(() => {
this.sheetShow = false;
if (this.pendingDelete) {
this.removeEvent(mode);
} else {
this.doSave(mode);
}
})
2026-09-13 15:50:37 +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)
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'))
.layoutWeight(1)
Text('›')
.fontSize(16)
.fontColor($r('app.color.text_hint'))
2026-09-13 15:50:37 +08:00
}
.width('100%')
.padding({ left: 8, right: 8, top: 10, bottom: 10 })
.onClick(() => {
// 注意:timeRow 的参数是 isStart,openPicker 的参数是 isEnd,语义相反必须取反
this.openPicker(!isStart);
})
}
@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;
// 切换到"重复"时给个合理默认:永不结束(用户可按需改为到某天/共几次)
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();
}
})
}
/** 提醒 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;
}
@Builder
reminderChip(minutes: number) {
Text(this.reminderLabel(minutes))
.fontSize(12)
.fontColor(this.chipOn(minutes)
? $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.chipOn(minutes) ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
.onClick(() => {
this.toggleReminder(minutes);
})
}
/** 开始/结束时间选择弹层:左侧日期、右侧时间(全天时只有日期),一次确定 */
@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;
})
// 滚轮文字显式指定颜色:默认取系统主题色,深色模式下与弹层底色撞色会"看不清"
.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)
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;
})
.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(this.allDay ? '确定日期' : '确定')
.width('100%')
.height(44)
.borderRadius(12)
.fontSize(15)
.backgroundColor($r('app.color.brand'))
.onClick(() => {
this.applyPicker();
this.sheetShow = false;
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
2026-09-13 15:50:37 +08:00
}
}
/**
* 桥接:从账号存储拿**可写入且能同步**的日历本(只取 CalDAV 日历本),并附上 href。
*
* ⚠️ 刻意**不返回"本机"(calKey='local')**:本应用新建/编辑的日程一律写入某个 CalDAV
* 日历本并同步到服务器;纯本机日程请用户改用系统「日历」应用完成
* (本应用也没有申请写系统日历本地的权限,做不到)。
*/
2026-09-13 15:50:37 +08:00
class CalendarDataBridge {
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);
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++) {
// 只读日历本(服务器无写权限,或用户手动标记只读)不可选
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]) : '';
s.color = color !== '' ? color : BookPalette.colorForHref(acc.calendarHrefs[i]);
2026-09-13 15:50:37 +08:00
s.href = acc.calendarHrefs[i];
s.writable = true;
2026-09-13 15:50:37 +08:00
result.push(s);
}
}
return result;
}
/**
* ⭐ 按 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 = '';
}