修复:编辑重复日程缺范围选择;同步并发不再误报失败

问题1(用户反馈):删除重复日程有"仅这一次/此次及以后/全部"三选项,编辑却没有。
根因:save() 把 validate() 与 chosenBook() 检查放在 isSeriesOccurrence() **之前**,
      任一不满足即提前 return,永远到不了范围选择弹层;delete 路径则是先判范围。
修法:
- save() 把"重复日程先选范围"提到最前(与删除路径一致),校验推迟到 doSave() 内
- chosenBook() 增加兜底:展开编辑时若该日程所属日历本不在"可写清单"里
  (被标记只读/写权限探测失败),用该日程自己的 calKey/href 构造目标;
  initPage 里还会用 CalendarDataBridge.lookupBook 把它补进选择列表(带真实名称/颜色)
- 新增 original 原始行快照:doSave 会就地改写 this.event 的 calKey/startTime,
  而 EXDATE/UNTIL/拆系列必须依据原值 —— 否则范围计算用错系列
- applySeriesEdit 的 one/future 分支在改写当前行后,用 EventDb.restoreRowById 还原原行,
  避免原系列残留幽灵行
- 加保存判定日志(脱敏)便于诊断

问题2(用户反馈):同步进行中再点手动同步/账号管理确定,提示"同步失败"造成误解。
根因:互斥锁抛的是普通 Error,三个调用点统一按"失败"提示。
修法:
- SyncEngine 新增 SyncBusyError 专用类型 + isSyncing/isAnySyncing 查询;
  锁命中时抛 SyncBusyError,且 catch 不再把它包成普通 Error
- Index.syncSingle / AccountsPage.syncSingleAccount:命中→提示
  "有同步任务正在进行中,请耐心等待",不显示失败
- Index.syncAll:命中记为 busySkipped(不算失败),全部被跳过时也只提示等待
This commit is contained in:
2026-10-09 22:19:31 +08:00
parent 5c80c0b773
commit 5e6f347fc6
5 changed files with 242 additions and 21 deletions
+28
View File
@@ -734,6 +734,34 @@ export class EventDb {
return await EventDb.insertLocal(context, e);
}
/**
* ⭐ 用一份原始快照**还原**某一行的内容(仅内容字段,不动 id)。
* 用途:编辑重复日程的"仅这一次 / 此次及以后"时,`doSave` 会把当前行就地写成新值,
* 但该行属于"原系列"(被 EXDATE 排除或被 UNTIL 截断),必须还原,
* 否则原系列里会残留一条被改过的幽灵行。
*/
static async restoreRowById(context: common.Context, id: number, src: LocalEvent): Promise<void> {
const store = await EventDb.getDb(context);
const bucket: relationalStore.ValuesBucket = {
'title': src.title,
'description': src.description,
'location': src.location,
'start_time': src.startTime,
'end_time': src.endTime,
'is_all_day': src.isAllDay ? 1 : 0,
'rrule': src.rrule,
'exdate': src.exdate,
'recurrence_id': src.recurrenceId,
'recurring': src.recurring ? 1 : 0,
'reminder': src.reminders.length > 0 ? src.reminders[0] : src.reminder,
'reminders': src.reminders.map((n: number): string => String(n)).join(','),
'dirty': 1
};
const predicates = new relationalStore.RdbPredicates('events');
predicates.equalTo('id', id);
await store.update(bucket, predicates);
}
/**
* 清理孤儿行:calKey 既不是本机日历(local)、也不属于任何现有账号(accId_ 前缀)的历史残留数据。
* 典型来源:账号删除重建、日历重选后序号变化遗留的旧 calKey(如 `_13`、`acc1789274273124_0_13`)。
+43 -1
View File
@@ -10,12 +10,49 @@ import { LogUtil } from './LogUtil';
import { AppSettings } from './AppSettings';
import { SystemCalendarMirror } from './SystemCalendarMirror';
/**
* ⭐ "已有同步任务正在进行"专用错误类型。
*
* 背景(用户反馈):后台同步进行中时,用户再点「手动同步」或在账号管理点「保存并同步」,
* 会并发触发第二轮同步;互斥锁把它拦下来,但那时抛的是普通 Error → 界面统一显示
* "同步失败:该账号正在同步中,请稍后再试"。用户看到"失败"会误以为出了问题。
*
* 正确做法:并发被拦下时**不是失败**,而应提示"有同步任务正在进行中,请耐心等待"。
* 因此用这个独立类型与真正的同步故障区分开,界面据此改提示语(而非报错)。
*/
export class SyncBusyError extends Error {
constructor() {
super('SYNC_BUSY');
}
}
export class SyncEngine {
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
private static activeSyncs: Map<string, number> = new Map();
/** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */
private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000;
/** 某账号当前是否正在同步(供界面判断,不触发任何操作) */
static isSyncing(accId: string): boolean {
const heldSince: number | undefined = SyncEngine.activeSyncs.get(accId);
if (heldSince === undefined) {
return false;
}
return Date.now() - heldSince < SyncEngine.LOCK_STALE_MS;
}
/** 当前是否有任意账号正在同步 */
static isAnySyncing(): boolean {
const now: number = Date.now();
for (const id of SyncEngine.activeSyncs.keys()) {
const since: number | undefined = SyncEngine.activeSyncs.get(id);
if (since !== undefined && now - since < SyncEngine.LOCK_STALE_MS) {
return true;
}
}
return false;
}
/**
* 给异步操作加超时保护,防止网络挂起导致界面一直转圈
*/
@@ -38,7 +75,8 @@ export class SyncEngine {
const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id);
if (heldSince !== undefined) {
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
throw new Error('该账号正在同步中,请稍后再试');
// ⭐ 抛专用类型:界面据此提示"同步进行中,请耐心等待"而非"失败"
throw new SyncBusyError();
}
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
}
@@ -71,6 +109,10 @@ export class SyncEngine {
LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
return r;
} catch (err) {
// ⭐ "正在同步中"原样抛出(界面据此提示"请耐心等待",不当成失败)
if (err instanceof SyncBusyError) {
throw err;
}
const e = err as BusinessError;
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
+7 -1
View File
@@ -4,7 +4,7 @@ import { curves, router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore';
import { SyncEngine } from '../common/SyncEngine';
import { SyncEngine, SyncBusyError } from '../common/SyncEngine';
import { EditNavParams } from './EditAccountPage';
import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil';
@@ -91,6 +91,12 @@ struct AccountsPage {
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步完成` });
} catch (err) {
// ⭐ 已有同步在进行中 → 提示"请耐心等待",不报"失败"(避免用户误解)
if (err instanceof SyncBusyError) {
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
return;
}
const e = err as BusinessError;
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步失败:${e.message}` });
+146 -18
View File
@@ -54,6 +54,13 @@ struct EventEditPage {
@State scope: string = 'all';
@State scopeConfirmShow: boolean = false; // 保存重复日程时的范围选择弹层
private pendingDelete: boolean = false; // 该弹层是"保存"还是"删除"流程
/**
* ⭐ 载入时的**原始行快照**(不可变)。
* `doSave` 会就地改写 `this.event` 的 calKey/href/startTime 等字段,
* 而"重复系列"的范围计算(EXDATE / UNTIL / 拆系列)必须依据**原始**的
* 系列标识(原 calKey、原 href、原发生时刻),否则会算错。
*/
private original: LocalEvent | null = null;
/** 本次编辑的"目标发生时刻"(未改期时 = 原发生时刻;用户改了开始时间 → 新时刻) */
private occStartMs: number = 0;
private event: LocalEvent | null = null;
@@ -91,6 +98,22 @@ struct EventEditPage {
const loaded = await EventDb.getById(context, pendingId);
if (loaded !== null) {
this.event = loaded;
// ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event)
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.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;
this.original = snap;
this.isExisting = true;
this.title = loaded.title;
this.location = loaded.location;
@@ -99,6 +122,22 @@ struct EventEditPage {
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) {
@@ -125,7 +164,22 @@ struct EventEditPage {
}
private chosenBook(): BookChoice | null {
return this.books.find((b: BookChoice): boolean => b.calKey === this.chosenKey) ?? 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;
}
private fmtDate(ms: number): string {
@@ -250,24 +304,27 @@ struct EventEditPage {
* 非重复的普通日程返回 false,直接按原逻辑保存/删除。
*/
private isSeriesOccurrence(): boolean {
if (this.event === null) {
const src: LocalEvent | null = this.original ?? this.event;
if (src === null) {
return false;
}
return this.event.recurring || this.event.rrule !== '';
return src.recurring || src.rrule !== '';
}
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行) */
/** 取本系列的主事件行(带 RRULE 的那行;找不到则退回当前行)。
* ⚠️ 必须按**原始** calKey/uid 查找(doSave 会改写 this.event 的 calKey)。 */
private async loadMaster(context: common.Context): Promise<LocalEvent | null> {
if (this.event === null) {
const src: LocalEvent | null = this.original ?? this.event;
if (src === null) {
return null;
}
const rows: LocalEvent[] = await EventDb.queryByUid(context, this.event.calKey, this.event.uid);
const rows: LocalEvent[] = await EventDb.queryByUid(context, src.calKey, src.uid);
for (const r of rows) {
if (r.rrule !== '') {
return r;
}
}
return this.event;
return src;
}
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */
@@ -321,7 +378,10 @@ struct EventEditPage {
private async applySeriesEdit(context: common.Context, e: LocalEvent, isNew: boolean,
scope: string): Promise<void> {
const target: LocalEvent = e;
if (isNew || !this.isSeriesOccurrence() || this.event === null) {
// ⚠️ 用**原始快照**判据:doSave 已把 e(= this.event)的 calKey/href/startTime 改成新值,
// 直接用 this.event 会把"改后的日期/日历本"当成原系列,导致 EXDATE / UNTIL 算错。
const cur: LocalEvent | null = this.original;
if (isNew || cur === null || !this.isSeriesOccurrence()) {
if (isNew) {
await EventDb.insertLocal(context, target);
} else {
@@ -329,7 +389,6 @@ struct EventEditPage {
}
return;
}
const cur: LocalEvent = this.event;
const bounds: SeriesBounds = await this.seriesBoundaries(context);
if (scope === 'all') {
// 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容;
@@ -339,7 +398,8 @@ struct EventEditPage {
}
if (scope === 'one') {
if (cur.rrule === '' && cur.recurrenceId > 0) {
// 已经是覆盖实例 → 直接改它
// 已经是覆盖实例 → 直接改它(保留 recurrenceId)
target.recurrenceId = cur.recurrenceId;
await EventDb.updateLocal(context, target);
return;
}
@@ -354,6 +414,11 @@ struct EventEditPage {
exList.push(occIcs);
}
await EventDb.setExdate(context, master.id, exList.join(';'));
// ⚠️ 主事件行本身的内容保持不变("仅这一次"只影响这一次);
// 如果用户是在主事件行上改的,需要把主事件改回原值(target 已被写成新时间)
if (cur.rrule !== '') {
await EventDb.restoreRowById(context, master.id, cur);
}
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
const ov = new LocalEvent();
ov.uid = master.uid;
@@ -414,11 +479,32 @@ struct EventEditPage {
} else {
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
// ⚠️ 若用户编辑的正是"主事件这一行",target(= this.event)已被 doSave 写成新值,
// 而它属于被截断的旧系列 → 必须还原为原值,否则旧系列末尾会多出一条被改过的幽灵行。
if (cur.rrule !== '') {
await EventDb.restoreRowById(context, master.id, cur);
}
}
}
private async save(): Promise<void> {
if (this.isSaving || !this.validate()) {
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.scopeConfirmShow = true;
LogUtil.write('保存判定:重复系列 → 弹出范围选择');
return;
}
if (!this.validate()) {
LogUtil.write(`保存判定:校验未通过(${this.statusMsg})`);
return;
}
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
@@ -427,12 +513,7 @@ struct EventEditPage {
this.statusMsg = this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
return;
}
// ⭐ 重复日程:先让用户选择"改这一次 / 此次及以后 / 全部"
if (this.isSeriesOccurrence()) {
this.pendingDelete = false;
this.scopeConfirmShow = true;
LogUtil.write(`保存判定:日历本解析失败(选本=${this.books.length} chosenKey=${this.chosenKey})`);
return;
}
await this.doSave('all');
@@ -443,8 +524,16 @@ struct EventEditPage {
if (this.isSaving) {
return;
}
// ⭐ 范围确认后回到这里再校验一次:范围弹层打开时并未跳过校验,
// 只是把它推迟到"选完范围"之后(失败时弹层已关闭,错误显示在表单状态行)。
if (!this.validate()) {
return;
}
const book = this.chosenBook();
if (book === null) {
this.statusMsg = this.books.length === 0
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
: '请选择要同步到的 CalDAV 日历本';
return;
}
this.isSaving = true;
@@ -589,7 +678,7 @@ struct EventEditPage {
if (context === undefined) {
return;
}
const cur: LocalEvent = this.event;
const cur: LocalEvent = this.original ?? this.event;
const isSeries: boolean = this.isSeriesOccurrence();
if (!isSeries || scope === 'all') {
// 普通日程 / 删除全部:整行标记删除(系列删除由 pushSeries 处理成 DELETE 资源)
@@ -1171,6 +1260,45 @@ class CalendarDataBridge {
}
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';
}
class CalSourceWithHref extends CalSource {
+18 -1
View File
@@ -7,7 +7,7 @@ import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService';
import { SyncEngine } from '../common/SyncEngine';
import { SyncEngine, SyncBusyError } from '../common/SyncEngine';
import { LogUtil } from '../common/LogUtil';
import { LunarUtil } from '../common/LunarUtil';
import { CardDataService } from '../common/CardDataService';
@@ -584,6 +584,12 @@ struct Index {
this.getUIContext().getPromptAction()
.showToast({ message: `「${acc.name}」同步完成` });
} catch (err) {
// ⭐ 已有同步在进行中 → 提示"请耐心等待",不报"失败"
if (err instanceof SyncBusyError) {
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
return;
}
const e = err as BusinessError;
this.getUIContext().getPromptAction()
.showToast({ message: `同步失败:${e.message}` });
@@ -633,6 +639,8 @@ struct Index {
await ScreenKeeper.acquire(context as common.UIAbilityContext);
let ok: number = 0;
let failMsg: string = '';
// ⭐ 是否因为"已有同步在进行"而被跳过(这不算失败,只提示等待)
let busySkipped: boolean = false;
// 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器
try {
await SystemCalendarImport.importIfNeeded(context);
@@ -654,6 +662,11 @@ struct Index {
acc.lastSyncTime = this.formatNow();
ok++;
} catch (err) {
// ⭐ 并发被互斥锁拦下:不是失败,只是"已有同步在跑",稍后自动同步会覆盖,直接略过
if (err instanceof SyncBusyError) {
busySkipped = true;
continue;
}
const e = err as BusinessError;
failMsg = e.message;
}
@@ -684,6 +697,10 @@ struct Index {
if (failMsg !== '') {
this.getUIContext().getPromptAction()
.showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` });
} else if (busySkipped && ok === 0) {
// 全部账号都因"已有同步在进行"被跳过 → 只提示等待,不说失败
this.getUIContext().getPromptAction()
.showToast({ message: '有同步任务正在进行中,请耐心等待' });
} else if (ok > 0 && manual) {
this.getUIContext().getPromptAction()
.showToast({ message: `同步完成(${ok} 个账号)` });