feat(待办): 支持循环任务(标准 CalDAV / VTODO RRULE)#5
只按标准 CalDAV 实现,不依赖家书的自定义机制。 群晖等标准服务器:RRULE 写在 VTODO 上,由客户端负责展开。 ## 核心语义决策 循环待办「勾完成」= 这一轮做完了,推进到下一周期,而不是结束。 若照搬非循环逻辑(置 COMPLETED 就完结),用户每勾一次循环任务它就永久结束, 循环规则等于白设。所以: · 非循环 → 置 COMPLETED,永久结束 · 循环 → DUE 推到下一周期 + 状态复位 NEEDS-ACTION ## 关键修复:DTSTART 与 DUE 必须分开 原 parseTodos 把 DUE 和 DTSTART 都写进 startTime,后出现的覆盖先出现的。 但循环下两者语义完全不同: · DTSTART = RRULE 的重复基准 · DUE = 这一条的截止时间 混在一起会导致重复基准被截止时间覆盖,算出来的发生日全错。 现在拆成独立字段,数据库补 dtstart 列(rrule 列已有)。 ## 顺带修掉两个既有 bug(测试连带查出) rewriteTodoCompletion 与 rewriteTodoFields 里,插入阶段的块序号 vtodoIdx 在遇到 END:VTODO 时被重置成 -1 ⇒ 每块都从 0 重新数, 多 VTODO 文件里永远匹配不到目标块,字段加不上(静默失败, 表现为「点了没反应」)。三处一并修正,vtodoIdx 改为全局块序号。 ## 实现 - IcsUtil.incForFreq:按 FREQ 推周期,处理月末溢出 (1/31 + 1月 → 2/28 钳制,不跳到 3 月;2/29 + 1年 → 2/28) - IcsUtil.freqOfRrule / rewriteTodoRecurrence - TodoService.setRecurrence / completeRecurringRound - 写回时同时给 DTSTART + DUE:RFC 5545 里 VTODO 的 DTSTART 是 OPTIONAL, 但缺它时各客户端对 RRULE 基准理解不一致,显式给能消除歧义 - 待办行显示「↻ 每周」标记(否则循环任务和普通待办长得一样, 用户勾完发现「又冒出新的」会困惑) - 编辑弹框加 每天/每周/每月/每年 选择,再点一次取消 ## 验证 scripts/test-ics-todo-recurrence.js:24 项全通过 (月末钳制、闰年、字段保留、取消循环、多 VTODO 定位、结构完整性)。 编译 BUILD SUCCESSFUL。 ## 说明 服务器若不支持 RRULE(如群晖 Calendar 规格里未提及循环任务), 则循环只在客户端可见,同步到其它客户端会退化为普通任务。
This commit is contained in:
@@ -74,6 +74,7 @@ struct Index {
|
||||
@State todoEditDueMs: number = 0; // 0 = 无截止时间
|
||||
@State todoEditBook: string = ''; // 所属本名(只读展示)
|
||||
@State todoEditWritable: boolean = true;
|
||||
@State todoEditRecur: string = ''; // 当前循环('' = 不循环),存 FREQ 值如 WEEKLY
|
||||
@State todoEditHasDue: boolean = false;
|
||||
@State todoEditPickShow: boolean = false; // 截止时间选择器
|
||||
@State todoEditPickDate: Date = new Date();
|
||||
@@ -772,6 +773,9 @@ struct Index {
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `同步完成(${ok} 个账号)` });
|
||||
}
|
||||
// 同步完成后重新读库并重建月格。
|
||||
// ⚠️ reloadAll() 内部已包含 reloadEvents(),这里不要重复调用 ——
|
||||
// 只在"同步确实拉到了新数据"时再刷一次,避免多余的重建开销。
|
||||
await this.reloadAll();
|
||||
// 同步后刷新服务卡片 + 重建提醒
|
||||
try {
|
||||
@@ -2756,6 +2760,51 @@ struct Index {
|
||||
this.openTodoDuePicker();
|
||||
})
|
||||
|
||||
// ⭐ 循环设置
|
||||
Row({ space: 8 }) {
|
||||
Text('重复')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text(this.todoEditRecur === '' ? '不重复' : this.recurLabel(`FREQ=${this.todoEditRecur}`))
|
||||
.fontSize(14)
|
||||
.fontColor(this.todoEditRecur === ''
|
||||
? $r('app.color.text_hint') : $r('app.color.brand_text'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 2, bottom: 2 })
|
||||
|
||||
// 四个循环选项(点同一个取消)
|
||||
Row({ space: 8 }) {
|
||||
ForEach([
|
||||
{ v: 'DAILY', label: '每天' },
|
||||
{ v: 'WEEKLY', label: '每周' },
|
||||
{ v: 'MONTHLY', label: '每月' },
|
||||
{ v: 'YEARLY', label: '每年' }
|
||||
], (opt: Record<string, string>): void => {
|
||||
Text(opt.label)
|
||||
.fontSize(13)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.borderRadius(14)
|
||||
.backgroundColor(this.todoEditRecur === opt.v
|
||||
? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
||||
.fontColor(this.todoEditRecur === opt.v
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
.onClick(() => {
|
||||
// 再点一次 = 取消循环
|
||||
this.todoEditRecur = this.todoEditRecur === opt.v ? '' : opt.v;
|
||||
})
|
||||
}, (opt: Record<string, string>): string => `rr_${opt.v}`)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.todoEditRecur !== '' && !this.todoEditHasDue) {
|
||||
Text('提示:循环任务需要有截止时间,才能算出下一次')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.error'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// 备注
|
||||
TextArea({ text: this.todoEditDesc, placeholder: '备注(可选)' })
|
||||
.fontSize(14)
|
||||
@@ -2998,6 +3047,7 @@ struct Index {
|
||||
this.todoEditHasDue = t.startTime > 0;
|
||||
this.todoEditBook = t.calName;
|
||||
this.todoEditWritable = t.writable;
|
||||
this.todoEditRecur = t.recurRule !== '' ? IcsUtil.freqOfRrule(t.recurRule) : '';
|
||||
// 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点)
|
||||
const base: Date = new Date();
|
||||
this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
|
||||
@@ -3042,6 +3092,13 @@ struct Index {
|
||||
this.todoEditTitle, this.todoEditDesc,
|
||||
this.todoEditHasDue ? this.todoEditDueMs : 0);
|
||||
if (ok) {
|
||||
// ⭐ 循环规则单独写:它与"改内容"是两回事,
|
||||
// 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次)
|
||||
const wantRule: string = this.todoEditRecur !== '' ? `FREQ=${this.todoEditRecur}` : '';
|
||||
const dueForRecur: number = this.todoEditHasDue ? this.todoEditDueMs : 0;
|
||||
if (dueForRecur > 0) {
|
||||
await TodoService.setRecurrence(ctx, this.todoEditId, wantRule, dueForRecur);
|
||||
}
|
||||
this.todoEditShow = false;
|
||||
this.getUIContext().getPromptAction().showToast({ message: '已保存' });
|
||||
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
||||
@@ -3106,7 +3163,29 @@ struct Index {
|
||||
private todoSubtitle(t: DisplayEvent): string {
|
||||
const due: string = t.startTime > 0 ? `截止 ${this.fmtDue(t.startTime)}` : '无截止时间';
|
||||
const book: string = t.calName !== '' ? ` · ${t.calName}` : '';
|
||||
return due + book;
|
||||
// ⭐ TODO_RECUR_UI 循环标记:让用户一眼看出"这条会重复"
|
||||
// 没有这个标记,循环任务看起来和普通待办完全一样,
|
||||
// 用户勾完成后发现"又冒出新的"会很困惑。
|
||||
const rec: string = t.recurRule !== '' ? ` ↻ ${this.recurLabel(t.recurRule)}` : '';
|
||||
return due + rec + book;
|
||||
}
|
||||
|
||||
/** RRULE → 中文标签(只处理 FREQ 四种,够用且不会误导) */
|
||||
private recurLabel(rrule: string): string {
|
||||
const f: string = IcsUtil.freqOfRrule(rrule);
|
||||
if (f === 'DAILY') {
|
||||
return '每天';
|
||||
}
|
||||
if (f === 'WEEKLY') {
|
||||
return '每周';
|
||||
}
|
||||
if (f === 'MONTHLY') {
|
||||
return '每月';
|
||||
}
|
||||
if (f === 'YEARLY') {
|
||||
return '每年';
|
||||
}
|
||||
return '循环';
|
||||
}
|
||||
|
||||
/** 待办完成状态切换:写回服务器成功后刷新列表;失败保持原状并提示。
|
||||
@@ -3119,11 +3198,27 @@ struct Index {
|
||||
}
|
||||
this.todoBusy = true;
|
||||
try {
|
||||
const ok: boolean = await TodoService.setCompleted(ctx, t.id, !t.completed);
|
||||
// ⭐ 循环待办勾"完成" = 这一轮做完了,推进到下一个周期(不结束)
|
||||
// 非循环 / 取消完成 → 走原来的 setCompleted
|
||||
const isRecurring: boolean = t.recurRule !== '';
|
||||
let ok: boolean = false;
|
||||
let nextDue: number = 0;
|
||||
if (isRecurring && !t.completed) {
|
||||
nextDue = await TodoService.completeRecurringRound(ctx, t.id);
|
||||
ok = nextDue > 0;
|
||||
} else {
|
||||
ok = await TodoService.setCompleted(ctx, t.id, !t.completed);
|
||||
}
|
||||
if (ok) {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: t.completed ? '已标记为未完成' : '已完成'
|
||||
});
|
||||
if (isRecurring && !t.completed) {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: nextDue > 0 ? `这一轮完成,下次 ${this.fmtDue(nextDue)}` : '这一轮已完成'
|
||||
});
|
||||
} else {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: t.completed ? '已标记为未完成' : '已完成'
|
||||
});
|
||||
}
|
||||
// 重新读库刷新列表(服务层已更新 completed)
|
||||
this.todos = await CalendarDataService.loadTodos(ctx, this.sources);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user