From 5e6f347fc6d0cec5b8ce5b492abad5752a46e0f3 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 9 Oct 2026 22:19:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=97=A5=E7=A8=8B=E7=BC=BA=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E9=80=89=E6=8B=A9=EF=BC=9B=E5=90=8C=E6=AD=A5=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=8A=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题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(不算失败),全部被跳过时也只提示等待 --- entry/src/main/ets/common/EventDb.ets | 28 ++++ entry/src/main/ets/common/SyncEngine.ets | 44 +++++- entry/src/main/ets/pages/AccountsPage.ets | 8 +- entry/src/main/ets/pages/EventEditPage.ets | 164 ++++++++++++++++++--- entry/src/main/ets/pages/Index.ets | 19 ++- 5 files changed, 242 insertions(+), 21 deletions(-) diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index c135183..24562db 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -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 { + 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`)。 diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index d84ede5..6a663ff 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -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 = 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}`); diff --git a/entry/src/main/ets/pages/AccountsPage.ets b/entry/src/main/ets/pages/AccountsPage.ets index d978e2e..a3bb4f9 100644 --- a/entry/src/main/ets/pages/AccountsPage.ets +++ b/entry/src/main/ets/pages/AccountsPage.ets @@ -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}` }); diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 6f158e3..b0a68bb 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -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 { - 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 { 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 { - 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 { + 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 { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index b64af14..a1321ba 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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} 个账号)` });