2 Commits
Author SHA1 Message Date
laoyang da0249d9f4 版本号升至 0.0.6(versionCode 600) 2026-10-10 08:30:12 +08:00
laoyang 17d7602d7d 重复日程:新增结束条件(到某天/共几次);修复仅这一次失效与标题被清空
新增能力:
- 新建重复日程时可设"结束条件":永不结束 / 重复到某天 / 共重复几次
  (之前只能选重复频率,无法指定次数或截止日期)
- 编辑既有重复日程会**回显**服务器上已有的 COUNT / UNTIL 并可修改

修复三个真 bug:
1. ⭐ "仅这一次"失效(实际改了全部):重复日程在列表里展开后,所有次共用
   主事件的 id,编辑页只拿到 id、拿不到"用户点的是哪一次" → occStartMs 被当成
   主事件的第一次发生 → EXDATE 排除的是第一次、覆盖行写在第一次上,
   用户当次既没排掉、系列属性又被动过,看起来就是"全改了"。
   修法:Index 打开编辑页时用 pendingOccurrenceTs 传入被点那次的 startTime;
   seriesBoundaries 再把该时刻"吸附"到真实发生(容差 12h),确保 EXDATE 能匹配。
2. ⭐ 已有标题被删空:original 快照漏拷贝 title/description/location/reminders,
   restoreRowById 于是把主事件行还原成空标题。
   修法:快照补全全部字段。
3. ⭐ setExdate/setRrule 被 restoreRowById 覆盖(顺序错误):
   先写 EXDATE 再还原 → 快照里的旧 exdate 又把排除覆盖掉。
   修法:一律"先还原主事件行、再写 EXDATE/截断 RRULE"。

其它:
- 编辑页现在显示"被点那一次"的实际时间(此前显示的是主事件第一次的时间)
- 'all' 分支改为把改动写到主事件行,整系列一起改
- 复用唯一底部弹层(新增 sheetMode='repeatEnd' 选截止日期),仍保持只挂 1 个 bindSheet
2026-10-10 07:05:32 +08:00
3 changed files with 359 additions and 33 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 500, "versionCode": 600,
"versionName": "0.0.5", "versionName": "0.0.6",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+353 -31
View File
@@ -44,6 +44,19 @@ struct EventEditPage {
@State statusMsg: string = ''; @State statusMsg: string = '';
@State isExisting: boolean = false; @State isExisting: boolean = false;
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom @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[] = []; // 提前提醒分钟数(多选),空 = 不提醒 @State reminderList: number[] = []; // 提前提醒分钟数(多选),空 = 不提醒
/** /**
* ⭐ 底部弹层:**整个页面只能挂一个 `bindSheet`**。 * ⭐ 底部弹层:**整个页面只能挂一个 `bindSheet`**。
@@ -106,13 +119,18 @@ struct EventEditPage {
const loaded = await EventDb.getById(context, pendingId); const loaded = await EventDb.getById(context, pendingId);
if (loaded !== null) { if (loaded !== null) {
this.event = loaded; this.event = loaded;
// ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event) // ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event)。
// ⚠️ 必须是**完整**拷贝 —— 曾经漏了 title/description/location/reminders,
// 导致"只改这一次"时把这一行还原成空标题(BUG:已有标题被删没)。
const snap = new LocalEvent(); const snap = new LocalEvent();
snap.id = loaded.id; snap.id = loaded.id;
snap.uid = loaded.uid; snap.uid = loaded.uid;
snap.calKey = loaded.calKey; snap.calKey = loaded.calKey;
snap.href = loaded.href; snap.href = loaded.href;
snap.remotePath = loaded.remotePath; snap.remotePath = loaded.remotePath;
snap.title = loaded.title;
snap.description = loaded.description;
snap.location = loaded.location;
snap.startTime = loaded.startTime; snap.startTime = loaded.startTime;
snap.endTime = loaded.endTime; snap.endTime = loaded.endTime;
snap.recurrenceId = loaded.recurrenceId; snap.recurrenceId = loaded.recurrenceId;
@@ -121,7 +139,10 @@ struct EventEditPage {
snap.isAllDay = loaded.isAllDay; snap.isAllDay = loaded.isAllDay;
snap.recurring = loaded.recurring; snap.recurring = loaded.recurring;
snap.deleted = loaded.deleted; snap.deleted = loaded.deleted;
snap.reminder = loaded.reminder;
snap.reminders = loaded.reminders.slice();
this.original = snap; this.original = snap;
this.originalRrule = loaded.rrule;
this.isExisting = true; this.isExisting = true;
this.title = loaded.title; this.title = loaded.title;
this.location = loaded.location; this.location = loaded.location;
@@ -152,10 +173,32 @@ struct EventEditPage {
this.statusMsg = '这条日程原先只存在「本机」(不同步)。请选一个 CalDAV 日历本,保存后即会同步到服务器。'; this.statusMsg = '这条日程原先只存在「本机」(不同步)。请选一个 CalDAV 日历本,保存后即会同步到服务器。';
} }
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none'; this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
this.parseRepeatEnd(loaded.rrule);
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
: (loaded.reminder > 0 ? [loaded.reminder] : []); : (loaded.reminder > 0 ? [loaded.reminder] : []);
// ⭐ 目标发生时刻:优先用 RECURRENCE-ID(覆盖行=被改写的原始时刻),否则用行本身开始时间 // ⭐ 目标发生时刻("仅这一次"要排除/覆盖的那一次):
this.occStartMs = loaded.recurrenceId > 0 ? loaded.recurrenceId : loaded.startTime; // ① 覆盖行 → 用 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);
return; return;
} }
} }
@@ -242,6 +285,106 @@ struct EventEditPage {
return 'custom'; return 'custom';
} }
/** 从已有 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 { private reminderLabel(m: number): string {
if (m === 0) { if (m === 0) {
return '不提醒'; return '不提醒';
@@ -336,13 +479,17 @@ struct EventEditPage {
return src; return src;
} }
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */ /** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分)。
* ⭐ 会用展开结果**校正 occStartMs**:调用方传来的时刻可能带秒/毫秒偏差
* (列表里点开的那次发生时间是精确的,但主事件运行到卡片等路径可能有偏差),
* 必须吸附到"真实存在的那一次",否则 EXDATE 写出的时刻匹配不上任何发生 →
* 表现为"选了仅这一次,却把所有次都改了"。 */
private async seriesBoundaries(context: common.Context): Promise<SeriesBounds> { private async seriesBoundaries(context: common.Context): Promise<SeriesBounds> {
const b = new SeriesBounds(); const b = new SeriesBounds();
const occ: number = this.occStartMs; let occ: number = this.occStartMs;
b.thisOcc = occ; b.thisOcc = occ;
const master: LocalEvent | null = await this.loadMaster(context); const master: LocalEvent | null = await this.loadMaster(context);
if (master === null) { if (master === null || master.rrule === '') {
b.beforeThis = occ - 1000; b.beforeThis = occ - 1000;
b.nextOcc = 0; b.nextOcc = 0;
return b; return b;
@@ -356,10 +503,30 @@ struct EventEditPage {
} }
} }
} }
// 展开窗口:主事件开始 ~ "该次"之后一年的发生,用于找"前后相邻"的两次 // 展开窗口:主事件开始 ~ "该次"之后一年(覆盖主事件的首次发生)
const winEnd: number = occ + 366 * 86400000; const winEnd: number = Math.max(occ, master.startTime) + 366 * 86400000;
const occs: number[] = RruleUtil.expand(master.rrule, master.startTime, const occs: number[] = RruleUtil.expand(master.rrule, master.startTime,
master.startTime, winEnd, exNums, 3000); 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 prev: number = 0;
let next: number = 0; let next: number = 0;
for (const o of occs) { for (const o of occs) {
@@ -390,6 +557,10 @@ struct EventEditPage {
// ⚠️ 用**原始快照**判据:doSave 已把 e(= this.event)的 calKey/href/startTime 改成新值, // ⚠️ 用**原始快照**判据:doSave 已把 e(= this.event)的 calKey/href/startTime 改成新值,
// 直接用 this.event 会把"改后的日期/日历本"当成原系列,导致 EXDATE / UNTIL 算错。 // 直接用 this.event 会把"改后的日期/日历本"当成原系列,导致 EXDATE / UNTIL 算错。
const cur: LocalEvent | null = this.original; const cur: LocalEvent | null = this.original;
// 用户点开的是"某一次发生"(occProvided=true)时,list/周视图里的行 id 是**主事件 id**,
// 所以"改全部"也必须给用户明确的确认——但语义上就是改整条系列:
// · 点开的是主事件行本身(occProvided=false)→ 直接改这一行就是改整个系列,无需特殊处理
// · 点开的是某一次发生 且选了"全部" → 把改动写到主事件行上(整系列平移/改名)
if (isNew || cur === null || !this.isSeriesOccurrence()) { if (isNew || cur === null || !this.isSeriesOccurrence()) {
if (isNew) { if (isNew) {
await EventDb.insertLocal(context, target); await EventDb.insertLocal(context, target);
@@ -400,8 +571,24 @@ struct EventEditPage {
} }
const bounds: SeriesBounds = await this.seriesBoundaries(context); const bounds: SeriesBounds = await this.seriesBoundaries(context);
if (scope === 'all') { if (scope === 'all') {
// 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容; // 全部:改到**主事件行**上(整系列一起改)。
// 若当前行是主事件 → 更新主事件(DTSTART 若被改 → 整系列平移,符合"修改全部"直觉) // ⚠️ 不能直接 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); await EventDb.updateLocal(context, target);
return; return;
} }
@@ -417,17 +604,18 @@ struct EventEditPage {
if (master === null) { if (master === null) {
return; return;
} }
const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';'); // ⚠️ **顺序很重要**:先把主事件行还原为原值(用户在编辑页改的标题/时间只属于"这一次",
// 不能污染主事件),**再**写 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); const occIcs: string = IcsUtil.fmtIcsTime(bounds.thisOcc, master.isAllDay);
if (!exList.includes(occIcs)) { if (!exList.includes(occIcs)) {
exList.push(occIcs); exList.push(occIcs);
} }
await EventDb.setExdate(context, master.id, exList.join(';')); await EventDb.setExdate(context, master.id, exList.join(';'));
// ⚠️ 主事件行本身的内容保持不变("仅这一次"只影响这一次);
// 如果用户是在主事件行上改的,需要把主事件改回原值(target 已被写成新时间)
if (cur.rrule !== '') {
await EventDb.restoreRowById(context, master.id, cur);
}
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻 // 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
const ov = new LocalEvent(); const ov = new LocalEvent();
ov.uid = master.uid; ov.uid = master.uid;
@@ -455,6 +643,8 @@ struct EventEditPage {
} }
// 主事件本身被改期 → "此次及以后"里包含主事件(第一次发生)本身, // 主事件本身被改期 → "此次及以后"里包含主事件(第一次发生)本身,
// 需要让新系列从"改到的时间"开始(否则原 DTSTART 会被原系列继续展开)。 // 需要让新系列从"改到的时间"开始(否则原 DTSTART 会被原系列继续展开)。
// ⚠️ 判据要用**原始主事件的开始时间**(cur.startTime 是"载入时的行时间",
// 对主事件行而言就是它的第一次发生时刻)。
const startedAtThis: boolean = const startedAtThis: boolean =
(cur.rrule !== '' && Math.abs(cur.startTime - bounds.thisOcc) < 1000); (cur.rrule !== '' && Math.abs(cur.startTime - bounds.thisOcc) < 1000);
// 1) 先把"此次及以后"的覆盖实例行搬到新 uid(主事件行 recurrenceId=0,不受影响) // 1) 先把"此次及以后"的覆盖实例行搬到新 uid(主事件行 recurrenceId=0,不受影响)
@@ -475,7 +665,8 @@ struct EventEditPage {
newMaster.endTime = target.endTime; newMaster.endTime = target.endTime;
newMaster.isAllDay = target.isAllDay; newMaster.isAllDay = target.isAllDay;
newMaster.recurring = true; newMaster.recurring = true;
// 新的重复规则:沿用原系列(用户没显式改规则时),保持"此次及以后"的重复节奏 // 新的重复规则:优先用界面上设置的(含"重复到某天 / 共几次"),
// 否则沿用原系列的重复节奏,保持"此次及以后"不变
newMaster.rrule = target.rrule !== '' ? target.rrule : master.rrule; newMaster.rrule = target.rrule !== '' ? target.rrule : master.rrule;
newMaster.reminders = target.reminders; newMaster.reminders = target.reminders;
newMaster.reminder = target.reminder; newMaster.reminder = target.reminder;
@@ -486,13 +677,13 @@ struct EventEditPage {
if (startedAtThis) { if (startedAtThis) {
await EventDb.markDeleted(context, master.id); await EventDb.markDeleted(context, master.id);
} else { } else {
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis); // ⚠️ 顺序:先还原主事件行(本次编辑只属于"此次及以后"的新系列,
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated); // 不能留在原主事件上),**再**截断 RRULE;反过来会被还原覆盖掉截断结果。
// ⚠️ 若用户编辑的正是"主事件这一行",target(= this.event)已被 doSave 写成新值, if (cur.rrule !== '' && master.id === cur.id) {
// 而它属于被截断的旧系列 → 必须还原为原值,否则旧系列末尾会多出一条被改过的幽灵行。
if (cur.rrule !== '') {
await EventDb.restoreRowById(context, master.id, cur); 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);
} }
} }
@@ -564,13 +755,8 @@ struct EventEditPage {
// 一律写入所选 CalDAV 日历本(新建/编辑/从「本机」迁过来都一样) // 一律写入所选 CalDAV 日历本(新建/编辑/从「本机」迁过来都一样)
e.calKey = book.calKey; e.calKey = book.calKey;
e.href = book.href; e.href = book.href;
// 重复规则与提醒 // 重复规则与提醒(含"重复到某天 / 共几次"的结束条件)
if (this.repeatMode === 'custom') { e.rrule = this.effectiveRrule();
// 无法识别的既有规则原样保留
e.rrule = this.event !== null ? this.event.rrule : '';
} else {
e.rrule = this.rruleForMode(this.repeatMode);
}
e.recurring = e.rrule !== ''; e.recurring = e.rrule !== '';
e.reminders = EventDb.normalizeReminders(this.reminderList); e.reminders = EventDb.normalizeReminders(this.reminderList);
e.reminder = e.reminders.length > 0 ? e.reminders[0] : 0; e.reminder = e.reminders.length > 0 ? e.reminders[0] : 0;
@@ -845,6 +1031,71 @@ struct EventEditPage {
} }
} }
.width('100%') .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) .alignItems(HorizontalAlign.Start)
.width('100%') .width('100%')
@@ -1002,23 +1253,69 @@ struct EventEditPage {
// 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。 // 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
// 这里用 sheetMode 在同一个弹层里切换内容。 // 这里用 sheetMode 在同一个弹层里切换内容。
.bindSheet($$this.sheetShow, this.bottomSheet(), { .bindSheet($$this.sheetShow, this.bottomSheet(), {
height: this.sheetMode === 'scope' ? 320 : 380, height: this.sheetMode === 'scope' ? 320 : (this.sheetMode === 'repeatEnd' ? 340 : 380),
showClose: false, showClose: false,
dragBar: true, dragBar: true,
backgroundColor: $r('app.color.card_bg') backgroundColor: $r('app.color.card_bg')
}) })
} }
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"或"重复范围选择" */ /** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"/"重复范围选择"/"重复截止日期" */
@Builder @Builder
bottomSheet() { bottomSheet() {
if (this.sheetMode === 'scope') { if (this.sheetMode === 'scope') {
this.scopeSheet() this.scopeSheet()
} else if (this.sheetMode === 'repeatEnd') {
this.repeatEndSheet()
} else { } else {
this.dateTimePickerSheet() 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 @Builder
scopeSheet() { scopeSheet() {
@@ -1135,6 +1432,31 @@ struct EventEditPage {
.backgroundColor(this.repeatMode === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg')) .backgroundColor(this.repeatMode === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
.onClick(() => { .onClick(() => {
this.repeatMode = mode; 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();
}
}) })
} }
+4
View File
@@ -1030,9 +1030,13 @@ struct Index {
.borderRadius(12) .borderRadius(12)
.onClick(() => { .onClick(() => {
const id: number = this.detailEvent !== null ? this.detailEvent.id : 0; const id: number = this.detailEvent !== null ? this.detailEvent.id : 0;
// ⭐ 把"用户点的是哪一次发生"一起带过去:重复日程展开后所有次共用主事件的 id,
// 不带发生时刻的话,编辑页无法知道用户点的是哪一次 → "仅这一次"会算错。
const occ: number = this.detailEvent !== null ? this.detailEvent.startTime : 0;
this.detailShow = false; this.detailShow = false;
if (id > 0) { if (id > 0) {
AppStorage.setOrCreate<number>('pendingEventId', id); AppStorage.setOrCreate<number>('pendingEventId', id);
AppStorage.setOrCreate<number>('pendingOccurrenceTs', occ);
router.pushUrl({ url: 'pages/EventEditPage' }); router.pushUrl({ url: 'pages/EventEditPage' });
} }
}) })