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

问题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}`);