diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index cc1cb1f..63fe7dc 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -341,6 +341,11 @@ export class CalendarDataService { const found = sources.find((s: CalSource): boolean => s.calKey === key); return found !== undefined ? found.color : '#9AA0A6'; }; + // ⭐ 待办按日历本分组显示需要"本名";原来只带 calKey,分组标题无法显示 + const nameOf = (key: string): string => { + const found = sources.find((s: CalSource): boolean => s.calKey === key); + return found !== undefined ? found.name : ''; + }; try { const rows: LocalEvent[] = await EventDb.queryTodos(context); for (const e of rows) { @@ -357,6 +362,7 @@ export class CalendarDataService { d.endTime = e.endTime; d.isAllDay = e.isAllDay; d.calKey = e.calKey; + d.calName = nameOf(e.calKey); d.color = colorOf(e.calKey); d.completed = e.completed; result.push(d); diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index 513ab33..4cd9e67 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -338,6 +338,22 @@ export class EventDb { /** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */ /** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */ /** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */ + /** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */ + static async updateTodoFields(context: common.Context, id: number, title: string, + desc: string, dueMs: number, etag: string): Promise { + const store = await EventDb.getDb(context); + const values: relationalStore.ValuesBucket = { + 'title': title, + 'description': desc, + 'start_time': dueMs, + 'etag': etag, + 'dirty': 0 + }; + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('id', id); + await store.update(values, predicates); + } + static async setTodoCompleted(context: common.Context, id: number, completed: boolean, etag: string): Promise { const store = await EventDb.getDb(context); diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 4ea708e..d1cb2db 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -327,6 +327,175 @@ export class IcsUtil { return text2.endsWith(crlf) ? text2 : text2 + crlf; } + /** + * 在**服务器原文**上改写 VTODO 的内容字段,返回新的整份 ICS。 + * + * 只改三项,其余(PRIORITY / CATEGORIES / X-* / STATUS / COMPLETED …)原样保留: + * - SUMMARY 标题 + * - DESCRIPTION 备注 + * - DUE 截止时间(dueMs<=0 → 删除该行) + * + * 与 rewriteTodoCompletion 相同的两遍处理: + * ① 先定位含目标 uid 的 VTODO 块(RFC 5545 不规定字段顺序,不能边扫边改) + * ② 再在块内替换/删除目标字段,缺失的在 END:VTODO 前补齐 + */ + static rewriteTodoFields(ics: string, uid: string, title: string, + desc: string, dueMs: number): string { + const crlf: string = '\r\n'; + const unfolded: string = ics.replace(/\r?\n[ \t]/g, ''); + const lines: string[] = unfolded.split(/\r?\n/).map((s: string): string => s.replace(/\r$/, '')); + const upperOf = (s: string): string => s.toUpperCase(); + + // ---- 第 1 遍:定位目标 VTODO 块 ---- + let targetBegin: number = -1; + let targetEnd: number = -1; + let curBegin: number = -1; + let curHasUid: boolean = false; + for (let i = 0; i < lines.length; i++) { + const u: string = upperOf(lines[i]); + if (u.startsWith('BEGIN:VTODO')) { + curBegin = i; + curHasUid = false; + continue; + } + if (u.startsWith('UID:') && curBegin >= 0) { + if (lines[i].substring(4).trim() === uid) { + curHasUid = true; + } + continue; + } + if (u.startsWith('END:VTODO')) { + if (curBegin >= 0 && curHasUid && targetBegin < 0) { + targetBegin = curBegin; + targetEnd = i; + } + curBegin = -1; + curHasUid = false; + } + } + if (targetBegin < 0) { + return ics; // 找不到目标,原样返回 + } + + // ---- 第 2 遍:块内改写 ---- + const out: string[] = []; + let sawSummary: boolean = false; + let sawDesc: boolean = false; + let sawDue: boolean = false; + for (let i = 0; i < lines.length; i++) { + const line: string = lines[i]; + const u: string = upperOf(line); + const inTarget: boolean = i > targetBegin && i < targetEnd; + if (!inTarget) { + out.push(line); + continue; + } + if (u.startsWith('SUMMARY')) { + sawSummary = true; + if (title !== '') { + out.push('SUMMARY:' + IcsUtil.escape(title)); + } + continue; + } + if (u.startsWith('DESCRIPTION')) { + sawDesc = true; + if (desc !== '') { + out.push('DESCRIPTION:' + IcsUtil.escape(desc)); + } + continue; + } + if (u.startsWith('DUE')) { + sawDue = true; + if (dueMs > 0) { + out.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false)); + } + continue; + } + out.push(line); + } + + // 缺失字段补齐 + const extra: string[] = []; + if (!sawSummary && title !== '') { + extra.push('SUMMARY:' + IcsUtil.escape(title)); + } + if (!sawDesc && desc !== '') { + extra.push('DESCRIPTION:' + IcsUtil.escape(desc)); + } + if (!sawDue && dueMs > 0) { + extra.push('DUE:' + IcsUtil.fmtIcsTime(dueMs, false)); + } + if (extra.length === 0) { + const plain: string = out.join(crlf); + return plain.endsWith(crlf) ? plain : plain + crlf; + } + + // 按**结构**定位插入点(不能按行号:上面可能删掉了 DUE 行,行号已不对齐) + const result: string[] = []; + let vtodoIdx: number = -1; + let targetOrdinal: number = -1; + // 重新数出目标的块序号 + { + let n: number = -1; + let inBlk: boolean = false; + let has: boolean = false; + for (let i = 0; i < out.length; i++) { + const u: string = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { + n++; + inBlk = true; + has = false; + continue; + } + if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { + has = true; + continue; + } + if (u.startsWith('END:VTODO') && inBlk) { + if (has && targetOrdinal < 0) { + targetOrdinal = n; + } + inBlk = false; + } + } + } + let inserted: boolean = false; + for (let i = 0; i < out.length; i++) { + const u: string = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { + vtodoIdx++; + result.push(out[i]); + continue; + } + if (u.startsWith('END:VTODO')) { + if (!inserted && vtodoIdx === targetOrdinal) { + for (const ex of extra) { + result.push(ex); + } + inserted = true; + } + vtodoIdx = -1; + result.push(out[i]); + continue; + } + result.push(out[i]); + } + if (!inserted) { + const tail0: string = result.join(crlf); + const idxVc: number = tail0.lastIndexOf('END:VCALENDAR'); + if (idxVc > 0) { + const merged: string = tail0.substring(0, idxVc) + extra.join(crlf) + crlf + + tail0.substring(idxVc); + return merged.endsWith(crlf) ? merged : merged + crlf; + } + for (const ex of extra) { + result.push(ex); + } + } + const text2: string = result.join(crlf); + return text2.endsWith(crlf) ? text2 : text2 + crlf; + } + /** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */ static parseTodos(ics: string): RemoteEvent[] { const unfolded: string = ics.replace(/\r?\n[ \t]/g, ''); diff --git a/entry/src/main/ets/common/TodoService.ets b/entry/src/main/ets/common/TodoService.ets index 6fa8fec..a8048ba 100644 --- a/entry/src/main/ets/common/TodoService.ets +++ b/entry/src/main/ets/common/TodoService.ets @@ -31,6 +31,116 @@ export class TodoService { * @param done true=标记完成;false=取消完成 * @returns 成功返回 true;失败返回 false(**本地状态不变**,避免界面与服务器不一致) */ + /** + * 更新待办内容(标题 / 备注 / 截止时间)。 + * + * 同样走 GET 原文 → 改字段 → PUT: + * VTODO 上有本地库没保存的字段(PRIORITY / CATEGORIES / X-* 等), + * 重建整份会把这些抹掉。 + * + * 只改 SUMMARY / DESCRIPTION / DUE 三项,**不动完成状态** + * (用户改的是内容,不该顺带把状态重置了)。 + * + * @param dueMs 0 = 清除截止时间(删除 DUE 行) + */ + static async updateTodo(context: common.Context, localId: number, title: string, + desc: string, dueMs: number): Promise { + const row: LocalEvent | null = await EventDb.queryById(context, localId); + if (row === null || row.kind !== 'todo') { + LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`); + return false; + } + if (row.href === '' || row.remotePath === '') { + LogUtil.write(`待办更新失败:缺少服务器路径 id=${localId}`); + return false; + } + const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href); + if (accResolved === null) { + LogUtil.write(`待办更新失败:找不到所属账号`); + return false; + } + const idx: number = accResolved.calendarHrefs.indexOf(row.href); + if (idx >= 0 && accResolved.calendarWritable.length > idx + && accResolved.calendarWritable[idx] === '0') { + LogUtil.write(`待办更新失败:该日历本只读 id=${localId}`); + return false; + } + const auth: string = DavClient.authHeader(accResolved.username, accResolved.password); + const url: string = row.href.endsWith('/') ? row.href + row.remotePath + : `${row.href}/${row.remotePath}`; + try { + const original: string = await DavClient.getRawStrict(url, auth); + if (!original.toUpperCase().includes('BEGIN:VTODO')) { + LogUtil.write(`待办更新失败:服务器内容不是 VTODO id=${localId}`); + return false; + } + const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs); + const newEtag: string = await DavClient.putEvent(url, auth, updated); + await EventDb.updateTodoFields(context, localId, title, desc, + dueMs > 0 ? dueMs : 0, newEtag); + LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`); + return true; + } catch (err) { + if (err instanceof DavHttpError) { + LogUtil.write(`待办更新失败 HTTP ${(err as DavHttpError).status} id=${localId}`); + } else { + LogUtil.write(`待办更新失败 id=${localId} ${(err as BusinessError).message ?? ''}`); + } + return false; + } + } + + /** 删除待办(DELETE 服务器资源 + 本地软删) */ + static async deleteTodo(context: common.Context, localId: number): Promise { + const row: LocalEvent | null = await EventDb.queryById(context, localId); + if (row === null || row.kind !== 'todo') { + LogUtil.write(`待办删除失败:行不存在或不是待办 id=${localId}`); + return false; + } + if (row.href === '' || row.remotePath === '') { + LogUtil.write(`待办删除失败:缺少服务器路径 id=${localId}`); + return false; + } + const accResolved: DavAccount | null = await TodoService.findAccount(context, row.href); + if (accResolved === null) { + LogUtil.write(`待办删除失败:找不到所属账号`); + return false; + } + const idx: number = accResolved.calendarHrefs.indexOf(row.href); + if (idx >= 0 && accResolved.calendarWritable.length > idx + && accResolved.calendarWritable[idx] === '0') { + LogUtil.write(`待办删除失败:该日历本只读 id=${localId}`); + return false; + } + const auth: string = DavClient.authHeader(accResolved.username, accResolved.password); + const url: string = row.href.endsWith('/') ? row.href + row.remotePath + : `${row.href}/${row.remotePath}`; + try { + await DavClient.deleteRemote(url, auth); + await EventDb.purge(context, localId); + LogUtil.write(`待办删除成功 id=${localId}(标题已脱敏)`); + return true; + } catch (err) { + if (err instanceof DavHttpError) { + LogUtil.write(`待办删除失败 HTTP ${(err as DavHttpError).status} id=${localId}`); + } else { + LogUtil.write(`待办删除失败 id=${localId} ${(err as BusinessError).message ?? ''}`); + } + return false; + } + } + + /** 按 href 找所属账号(updateTodo/deleteTodo 共用) */ + private static async findAccount(context: common.Context, href: string): Promise { + const accounts: DavAccount[] = await AccountStore.loadAll(context); + for (const a of accounts) { + if (a.calendarHrefs.includes(href)) { + return a; + } + } + return null; + } + static async setCompleted(context: common.Context, localId: number, done: boolean): Promise { const row: LocalEvent | null = await EventDb.queryById(context, localId); if (row === null) { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 4a44353..ec2c9e1 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -39,6 +39,18 @@ class MonthCell { hasEvent: boolean = false; } +/** + * 待办分组(按日历本聚合)。 + * 用 class 而不是 Map/匿名对象:ArkUI 的 ForEach 需要稳定结构, + * 用带类型的类更不容易在 ArkTS 严格模式下出错。 + */ +class TodoGroup { + calKey: string = ''; + name: string = ''; + color: string = '#007DFF'; + items: DisplayEvent[] = []; +} + const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日']; /** 视图前后顺序(决定手势/标签切换时的转场方向):月 → 周 → 列表 */ @@ -54,6 +66,19 @@ struct Index { @State events: DisplayEvent[] = []; @State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示) @State todoBusy: boolean = false; // 正在写回待办状态(防连点) + // ⭐ TODO_EDITOR 待办编辑弹框状态 + @State todoEditShow: boolean = false; + @State todoEditId: number = 0; + @State todoEditTitle: string = ''; + @State todoEditDesc: string = ''; + @State todoEditDueMs: number = 0; // 0 = 无截止时间 + @State todoEditBook: string = ''; // 所属本名(只读展示) + @State todoEditWritable: boolean = true; + @State todoEditHasDue: boolean = false; + @State todoEditPickShow: boolean = false; // 截止时间选择器 + @State todoEditPickDate: Date = new Date(); + @State todoEditPickHour: number = 9; + @State todoEditPickMin: number = 0; @State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(今天 ~ 未来2年) private agendaStale: boolean = true; // 全量数据是否需要重新加载 @State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月 @@ -491,6 +516,10 @@ struct Index { const gridStart: number = first - offset * 86400000; const todayStart: number = this.startOfDay(Date.now()); const eventDays: Set = this.buildEventDaySet(); + // DOT_DEBUG 临时:确认集合算出来了没有 + if (month >= 0) { + LogUtil.write(`月格点标记 ${year}-${month + 1}:事件 ${this.events.length} 条,有事件的日期 ${eventDays.size} 天`); + } const cells: MonthCell[] = []; for (let i = 0; i < 42; i++) { const dateMs: number = gridStart + i * 86400000; @@ -1118,6 +1147,11 @@ struct Index { this.consentGate() } + // ⭐ TODO_EDITOR_UI 待办编辑弹框(覆盖层方案:同一组件只能挂一个 bindSheet) + if (this.todoEditShow) { + this.todoEditorOverlay() + } + // 功能引导:整页覆盖层,放根 Stack 最后一层(盖住主界面与悬浮按钮) if (this.showTips) { this.tipsOverlay() @@ -1654,16 +1688,27 @@ struct Index { .backgroundColor(cell.dateMs === this.selectedDate ? $r('app.color.selected_bg') : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) - Text(cell.lunar) - .fontSize(8) - .fontColor($r('app.color.text_hint')) - .maxLines(1) + // 农历 + 圆点同一行(与月视图排版一致,避免周条变高) + Row({ space: 2 }) { + Text(cell.lunar) + .fontSize(8) + .fontColor($r('app.color.text_hint')) + .maxLines(1) + // ⭐ 圆点用主题色:它只表示"这天有安排",不代表某个日历本, + // 所以不取本的色(否则会误导成"只有那个本有日程")。 + // 选中日深底 → 换白点保对比度;无事件时透明占位保持行高一致。 + Circle({ width: 5, height: 5 }) + .fill(cell.dateMs === this.selectedDate + ? $r('app.color.button_text') : $r('app.color.brand')) + .opacity(cell.hasEvent ? 1 : 0) + } + .justifyContent(FlexAlign.Center) } .layoutWeight(1) .onClick(() => { this.selectedDate = cell.dateMs; }) - }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) + }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}_${cell.hasEvent}`) } .width('100%') .padding({ left: 12, right: 12 }) @@ -1682,6 +1727,9 @@ struct Index { const offset: number = (sel.getDay() + 6) % 7; const weekStart: number = this.selectedDate - offset * 86400000; const todayStart: number = this.startOfDay(Date.now()); + // ⭐ WEEK_DOT 周条也要显示圆点 ⇒ 与月视图共用同一份"有事件日期"集合, + // 口径完全一致(区间相交,跨天事件覆盖的每天都算)。 + const eventDays: Set = this.buildEventDaySet(); const cells: MonthCell[] = []; for (let i = 0; i < 7; i++) { const dateMs: number = weekStart + i * 86400000; @@ -1690,6 +1738,7 @@ struct Index { cell.day = new Date(dateMs).getDate(); cell.isToday = dateMs === todayStart; cell.lunar = LunarUtil.lunarDayText(dateMs); + cell.hasEvent = eventDays.has(this.startOfDay(dateMs)); cells.push(cell); } return cells; @@ -2520,27 +2569,63 @@ struct Index { /** 待办页:与日历视图完全分开,只读展示服务器端 VTODO */ @Builder + // ⭐ TODO_GROUPED 待办视图:未完成 / 已完成 两大段,段内再按**日历本**分组。 + // 用户要求"按日历本分类显示,未完成里也能按家庭、生活分开,要有区分度"。 + // 分组标题前带该本的颜色圆点 —— 这才是"区分度"的来源。 todoBody() { Scroll() { Column({ space: 8 }) { - Text('未完成') + Text(`未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`) .fontSize(13) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.text_secondary')) .width('100%') - ForEach(this.todos.filter((t: DisplayEvent): boolean => !t.completed), (t: DisplayEvent) => { - this.todoRow(t) - }, (t: DisplayEvent) => `t${t.id}_${t.completed}`) + ForEach(this.todoGroups(false), (g: TodoGroup) => { + Column({ space: 6 }) { + Row({ space: 6 }) { + Circle({ width: 8, height: 8 }).fill(g.color) + Text(`${g.name}(${g.items.length})`) + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + Blank() + } + .width('100%') + .padding({ top: 4 }) + ForEach(g.items, (t: DisplayEvent) => { + this.todoRow(t) + }, (t: DisplayEvent) => `u${t.id}_${t.completed}`) + } + .alignItems(HorizontalAlign.Start) + .width('100%') + }, (g: TodoGroup) => `g0_${g.calKey}`) + if (this.todos.some((t: DisplayEvent): boolean => t.completed)) { - Text('已完成') + Text(`已完成(${this.todos.filter((t: DisplayEvent): boolean => t.completed).length})`) .fontSize(13) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.text_secondary')) .width('100%') .margin({ top: 10 }) - ForEach(this.todos.filter((t: DisplayEvent): boolean => t.completed), (t: DisplayEvent) => { - this.todoRow(t) - }, (t: DisplayEvent) => `t${t.id}_${t.completed}`) + ForEach(this.todoGroups(true), (g: TodoGroup) => { + Column({ space: 6 }) { + Row({ space: 6 }) { + Circle({ width: 8, height: 8 }).fill(g.color) + Text(`${g.name}(${g.items.length})`) + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + Blank() + } + .width('100%') + .padding({ top: 4 }) + ForEach(g.items, (t: DisplayEvent) => { + this.todoRow(t) + }, (t: DisplayEvent) => `c${t.id}_${t.completed}`) + } + .alignItems(HorizontalAlign.Start) + .width('100%') + }, (g: TodoGroup) => `g1_${g.calKey}`) } if (this.todos.length === 0 && !this.loading) { Column({ space: 8 }) { @@ -2574,39 +2659,275 @@ struct Index { .align(Alignment.Top) } + /** + * 待办编辑覆盖层。 + * + * 为什么用覆盖层而不是 bindSheet: + * ArkUI 里**同一组件只能挂一个 bindSheet**,Index 已用 `detailShow` 挂了日程详情, + * 再挂一个会被覆盖(EventEditPage 顶部也记过这个坑)。 + * 覆盖层跟 tipsOverlay / consentGate 是同一套路,已验证可用。 + */ + @Builder + todoEditorOverlay() { + Stack({ alignContent: Alignment.Bottom }) { + // 半透明遮罩:点空白关闭 + Column() + .width('100%') + .height('100%') + // 与 tipsOverlay 同款遮罩色(资源里没有 scrim 这个色,用字面值保持一致) + .backgroundColor('#66000000') + .onClick(() => { + this.todoEditShow = false; + }) + + Column({ space: 12 }) { + // 标题行 + Row({ space: 8 }) { + Text('编辑待办') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + if (this.todoEditWritable) { + Text('删除') + .fontSize(14) + .fontColor($r('app.color.error')) + .onClick(() => { + this.askDeleteTodo(); + }) + } + } + .width('100%') + + if (this.todoEditBook !== '') { + Row({ space: 6 }) { + Text('日历本') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + Text(this.todoEditBook) + .fontSize(12) + .fontColor($r('app.color.text_primary')) + Blank() + } + .width('100%') + } + + if (!this.todoEditWritable) { + Text('该日历本为只读,无法修改') + .fontSize(12) + .fontColor($r('app.color.error')) + .width('100%') + } + + // 标题输入 + TextInput({ text: this.todoEditTitle, placeholder: '待办标题' }) + .fontSize(15) + .height(44) + .borderRadius(10) + .backgroundColor($r('app.color.page_bg')) + .onChange((v: string) => { + this.todoEditTitle = v; + }) + + // 截止时间 + Row({ space: 8 }) { + Text('截止时间') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + Blank() + Text(this.todoEditHasDue + ? this.fmtDueFull(this.todoEditDueMs) : '无') + .fontSize(14) + .fontColor(this.todoEditHasDue + ? $r('app.color.text_primary') : $r('app.color.text_hint')) + if (this.todoEditHasDue) { + Text('清除') + .fontSize(13) + .fontColor($r('app.color.brand_text')) + .onClick(() => { + this.todoEditHasDue = false; + this.todoEditDueMs = 0; + }) + } + } + .width('100%') + .padding({ top: 4, bottom: 4 }) + .onClick(() => { + this.openTodoDuePicker(); + }) + + // 备注 + TextArea({ text: this.todoEditDesc, placeholder: '备注(可选)' }) + .fontSize(14) + .height(88) + .borderRadius(10) + .backgroundColor($r('app.color.page_bg')) + .onChange((v: string) => { + this.todoEditDesc = v; + }) + + Row({ space: 10 }) { + Button('取消') + .layoutWeight(1) + .height(44) + .borderRadius(12) + .fontSize(15) + .fontColor($r('app.color.text_primary')) + .backgroundColor($r('app.color.chip_off_bg')) + .onClick(() => { + this.todoEditShow = false; + }) + Button(this.todoBusy ? '保存中…' : '保存') + .layoutWeight(1) + .height(44) + .borderRadius(12) + .fontSize(15) + .enabled(!this.todoBusy && this.todoEditWritable) + .onClick(() => { + this.saveTodoEdit(); + }) + } + .width('100%') + } + .width('100%') + .padding(18) + .borderRadius({ topLeft: 18, topRight: 18 }) + .backgroundColor($r('app.color.card_bg')) + .shadow({ radius: 16, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: -2 }) + + // 截止时间选择器(叠在弹框之上) + if (this.todoEditPickShow) { + Column({ space: 12 }) { + Text('选择截止时间') + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .width('100%') + .textAlign(TextAlign.Center) + Row({ space: 4 }) { + DatePicker({ + start: new Date(2000, 0, 1), + end: new Date(2050, 11, 31), + selected: this.todoEditPickDate + }) + .onDateChange((value: Date) => { + this.todoEditPickDate = 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(200) + TimePicker({ selected: new Date(2000, 0, 1, this.todoEditPickHour, this.todoEditPickMin) }) + .onChange((value: TimePickerResult) => { + this.todoEditPickHour = value.hour; + this.todoEditPickMin = value.minute; + }) + .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(200) + } + .width('100%') + Row({ space: 10 }) { + Button('取消') + .layoutWeight(1) + .height(42) + .borderRadius(12) + .fontSize(15) + .fontColor($r('app.color.text_primary')) + .backgroundColor($r('app.color.chip_off_bg')) + .onClick(() => { + this.todoEditPickShow = false; + }) + Button('确定') + .layoutWeight(1) + .height(42) + .borderRadius(12) + .fontSize(15) + .onClick(() => { + this.confirmTodoDue(); + }) + } + .width('100%') + } + .width('92%') + .padding(16) + .borderRadius(16) + .backgroundColor($r('app.color.card_bg')) + .shadow({ radius: 20, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 }) + } + } + .width('100%') + .height('100%') + } + + /** 截止时间完整显示(含时分) */ + private fmtDueFull(ms: number): string { + const d = new Date(ms); + const hh: string = d.getHours() < 10 ? `0${d.getHours()}` : `${d.getHours()}`; + const mm: string = d.getMinutes() < 10 ? `0${d.getMinutes()}` : `${d.getMinutes()}`; + return `${d.getMonth() + 1}月${d.getDate()}日 ${hh}:${mm}`; + } + @Builder todoRow(t: DisplayEvent) { + // ⭐ TODO_ROW_SPLIT 点击区域拆分: + // · 左侧圆圈 → 勾选/取消完成 + // · 其余部分 → 打开编辑弹框 + // 旧版把 onClick 挂在整行上,点标题、点截止时间、点空白都会改完成状态, + // 用户反馈"点任何地方都是完成",是明确的交互缺陷。 Row({ space: 10 }) { - // 勾选框(颜色跟随日历本) - if (t.completed) { - Text('✓') - .fontSize(13) - .fontColor($r('app.color.button_text')) - .width(22) - .height(22) - .textAlign(TextAlign.Center) - .borderRadius(11) - .backgroundColor(this.colorOfCal(t.calKey)) - } else { - Text('') - .width(22) - .height(22) - .borderRadius(11) - .border({ width: 2, color: this.colorOfCal(t.calKey) }) + // 勾选框(颜色跟随日历本)—— **只有这块触发完成切换** + Row() { + if (t.completed) { + Text('✓') + .fontSize(13) + .fontColor($r('app.color.button_text')) + .width(22) + .height(22) + .textAlign(TextAlign.Center) + .borderRadius(11) + .backgroundColor(this.colorOfCal(t.calKey)) + } else { + Text('') + .width(22) + .height(22) + .borderRadius(11) + .border({ width: 2, color: this.colorOfCal(t.calKey) }) + } } + .padding({ right: 2 }) + .onClick(() => { + this.toggleTodo(t); + }) + Column({ space: 3 }) { Text(t.title === '' ? '(无标题待办)' : t.title) .fontSize(15) .fontColor(t.completed ? $r('app.color.text_hint') : $r('app.color.text_primary')) .decoration({ type: t.completed ? TextDecorationType.LineThrough : TextDecorationType.None }) - .maxLines(1) + .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(t.startTime > 0 ? `截止 ${this.fmtDue(t.startTime)}` : '无截止时间') + Text(this.todoSubtitle(t)) .fontSize(12) .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) + // ⭐ 内容区点击 → 打开编辑(不改变完成状态) + .onClick(() => { + this.openTodoEditor(t); + }) } .width('100%') .padding(10) @@ -2614,9 +2935,178 @@ struct Index { .backgroundColor($r('app.color.card_bg')) .border({ width: 1, color: $r('app.color.shadow_color') }) .opacity(t.completed ? 0.7 : 1) - .onClick(() => { - this.toggleTodo(t); - }) + } + + /** + * 把待办按日历本聚合成组。 + * + * @param completed true=取已完成;false=取未完成 + * @returns 按"未完成优先、组内按截止时间升序"排好的分组 + * + * 为什么单独抽出来:视图里直接写 filter + group 会让 build() 里塞逻辑, + * 而且 ForEach 的 key 需要稳定(用 calKey),抽出来更好维护。 + */ + private todoGroups(completed: boolean): TodoGroup[] { + const list: DisplayEvent[] = this.todos.filter( + (t: DisplayEvent): boolean => t.completed === completed); + const map: Map = new Map(); + const order: string[] = []; + for (const t of list) { + const key: string = t.calKey === '' ? '__unknown' : t.calKey; + let g: TodoGroup | undefined = map.get(key); + if (g === undefined) { + const ng = new TodoGroup(); + ng.calKey = key; + ng.name = t.calName !== '' ? t.calName : '其他'; + ng.color = t.color; + map.set(key, ng); + order.push(key); + g = ng; + } + g.items.push(t); + } + const out: TodoGroup[] = []; + for (const k of order) { + const g = map.get(k); + if (g !== undefined) { + // 组内按截止时间升序(无截止时间的排最后) + g.items.sort((a: DisplayEvent, b: DisplayEvent): number => { + const av: number = a.startTime > 0 ? a.startTime : Number.MAX_SAFE_INTEGER; + const bv: number = b.startTime > 0 ? b.startTime : Number.MAX_SAFE_INTEGER; + return av - bv; + }); + out.push(g); + } + } + // 组之间按"组内最早截止时间"排序,最紧急的本排前面 + out.sort((a: TodoGroup, b: TodoGroup): number => { + const av: number = a.items.length > 0 && a.items[0].startTime > 0 + ? a.items[0].startTime : Number.MAX_SAFE_INTEGER; + const bv: number = b.items.length > 0 && b.items[0].startTime > 0 + ? b.items[0].startTime : Number.MAX_SAFE_INTEGER; + return av - bv; + }); + return out; + } + + /** 打开待办编辑弹框(点内容区触发;不会改变完成状态) */ + private openTodoEditor(t: DisplayEvent): void { + this.todoEditId = t.id; + this.todoEditTitle = t.title; + this.todoEditDesc = t.description; + this.todoEditDueMs = t.startTime > 0 ? t.startTime : 0; + this.todoEditHasDue = t.startTime > 0; + this.todoEditBook = t.calName; + this.todoEditWritable = t.writable; + // 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点) + const base: Date = new Date(); + this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1); + this.todoEditPickHour = 9; + this.todoEditPickMin = 0; + this.todoEditPickShow = false; + this.todoEditShow = true; + } + + /** 打开截止时间选择器(把当前值灌进滚轮初值) */ + private openTodoDuePicker(): void { + const d: Date = this.todoEditHasDue && this.todoEditDueMs > 0 + ? new Date(this.todoEditDueMs) : new Date(); + this.todoEditPickDate = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + this.todoEditPickHour = d.getHours(); + this.todoEditPickMin = d.getMinutes(); + this.todoEditPickShow = true; + } + + /** 确认截止时间 */ + private confirmTodoDue(): void { + const d: Date = this.todoEditPickDate; + this.todoEditDueMs = new Date(d.getFullYear(), d.getMonth(), d.getDate(), + this.todoEditPickHour, this.todoEditPickMin).getTime(); + this.todoEditHasDue = true; + this.todoEditPickShow = false; + } + + /** 保存待办编辑(标题 / 备注 / 截止时间) */ + private async saveTodoEdit(): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined || this.todoBusy || this.todoEditId <= 0) { + return; + } + if (!this.todoEditWritable) { + this.getUIContext().getPromptAction().showToast({ message: '该日历本为只读,无法修改' }); + return; + } + this.todoBusy = true; + try { + const ok: boolean = await TodoService.updateTodo(ctx, this.todoEditId, + this.todoEditTitle, this.todoEditDesc, + this.todoEditHasDue ? this.todoEditDueMs : 0); + if (ok) { + this.todoEditShow = false; + this.getUIContext().getPromptAction().showToast({ message: '已保存' }); + this.todos = await CalendarDataService.loadTodos(ctx, this.sources); + this.rebuildPages(); + } else { + this.getUIContext().getPromptAction().showToast({ + message: '保存失败:需联网,且该日历本需可写' + }); + } + } catch (err) { + this.getUIContext().getPromptAction().showToast({ message: '保存失败,请稍后重试' }); + } finally { + this.todoBusy = false; + } + } + + /** 删除待办(带二次确认) */ + private askDeleteTodo(): void { + this.getUIContext().showAlertDialog({ + title: '删除待办', + message: '将从服务器删除这条待办,删除后无法撤销。', + autoCancel: true, + alignment: DialogAlignment.Center, + primaryButton: { + value: '取消', + action: (): void => { + } + }, + secondaryButton: { + value: '删除', + action: (): void => { + this.doDeleteTodo(); + } + } + }); + } + + private async doDeleteTodo(): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined || this.todoBusy || this.todoEditId <= 0) { + return; + } + this.todoBusy = true; + try { + const ok: boolean = await TodoService.deleteTodo(ctx, this.todoEditId); + if (ok) { + this.todoEditShow = false; + this.getUIContext().getPromptAction().showToast({ message: '已删除' }); + this.todos = await CalendarDataService.loadTodos(ctx, this.sources); + this.rebuildPages(); + } else { + this.getUIContext().getPromptAction().showToast({ message: '删除失败,请稍后重试' }); + } + } catch (err) { + this.getUIContext().getPromptAction().showToast({ message: '删除失败,请稍后重试' }); + } finally { + this.todoBusy = false; + } + } + + /** 待办副标题:截止时间 · 所属日历本(分组后本名仍在,方便跨组辨识) */ + 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; } /** 待办完成状态切换:写回服务器成功后刷新列表;失败保持原状并提示。 @@ -2669,27 +3159,36 @@ struct Index { .backgroundColor(cell.dateMs === this.selectedDate ? $r('app.color.selected_bg') : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) - if (!this.wideLayout) { - // 宽屏下月历格子变窄,省略农历腾出空间 - Text(cell.lunar) - .fontSize(8) - .fontColor(cell.dateMs === this.selectedDate - ? $r('app.color.text_secondary') : $r('app.color.text_hint')) - .maxLines(1) + // ⭐ DOT_INLINE 有事件的日期标记:**每个日期最多一个**圆点,叠在农历行右侧。 + // 为什么不做成独立一行:实测格子高度已被"星期/日期/农历"占满 + // (175px 全部用尽),再插一行会把内容顶出格子、圆点不可见。 + // 无事件时圆点透明但仍占位,保证有/无事件的行高完全一致、不会跳。 + // 口径:hasEvent 由 buildEventDaySet() 统一算出,不区分日历本、不区分几条。 + Row({ space: 2 }) { + if (!this.wideLayout) { + // 宽屏下月历格子变窄,省略农历腾出空间 + Text(cell.lunar) + .fontSize(8) + .fontColor(cell.dateMs === this.selectedDate + ? $r('app.color.text_secondary') : $r('app.color.text_hint')) + .maxLines(1) + .layoutWeight(1) + .textAlign(TextAlign.Center) + } + // ⭐ DOT_CIRCLE 用 Circle 组件而不是空 Row: + // 空容器 + 透明背景不会产生实际绘制,dump 里根本看不到节点 + // (实测连改两版都"没渲染",其实是被优化掉了)。 + // Circle 是专门画圆的组件,一定落绘制节点。 + Circle({ width: this.wideLayout ? 4 : 5, height: this.wideLayout ? 4 : 5 }) + .fill(cell.hasEvent + ? (cell.dateMs === this.selectedDate + ? $r('app.color.button_text') // 选中日深底 → 白点保对比度 + : $r('app.color.brand')) // 其余用主题色,比纯红柔和 + : Color.Transparent) + .opacity(cell.hasEvent ? 1 : 0) } - // ⭐ EVENT_DOT 有事件的日期标记:**每个日期最多一个**圆点。 - // 只表达"这天有安排",不区分是哪个日历本、有几条。 - // (旧实现按日历本逐个画,3 个本 3 个点,15 个本满屏都是点,故被废弃。) - // 无事件时用等高的透明占位,避免有/无点导致格子高度跳动、整月行高不一致。 - Row() - .width(this.wideLayout ? 4 : 5) - .height(this.wideLayout ? 4 : 5) - .borderRadius(this.wideLayout ? 2 : 2.5) - .backgroundColor(cell.hasEvent - ? (cell.dateMs === this.selectedDate - ? $r('app.color.button_text') // 选中日深底 → 用白点保证对比度 - : $r('app.color.brand')) // 其余用主题色,比纯红柔和 - : Color.Transparent) + .width('100%') + .justifyContent(FlexAlign.Center) } .layoutWeight(1) .padding({ top: this.wideLayout ? 2 : 3, bottom: this.wideLayout ? 2 : 3 }) diff --git a/scripts/test-ics-todo-fields.js b/scripts/test-ics-todo-fields.js new file mode 100644 index 0000000..c68c93b --- /dev/null +++ b/scripts/test-ics-todo-fields.js @@ -0,0 +1,148 @@ +// 验证 IcsUtil.rewriteTodoFields(改标题/备注/截止时间) +function escape(s) { + return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); +} +function fmtIcsTime(ms) { + const pad = n => n < 10 ? '0' + n : String(n); + const d = new Date(ms); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function rewriteTodoFields(ics, uid, title, desc, dueMs) { + const crlf = '\r\n'; + const unfolded = ics.replace(/\r?\n[ \t]/g, ''); + const lines = unfolded.split(/\r?\n/).map(s => s.replace(/\r$/, '')); + const upperOf = s => s.toUpperCase(); + + let targetBegin = -1, targetEnd = -1, curBegin = -1, curHasUid = false; + for (let i = 0; i < lines.length; i++) { + const u = upperOf(lines[i]); + if (u.startsWith('BEGIN:VTODO')) { curBegin = i; curHasUid = false; continue; } + if (u.startsWith('UID:') && curBegin >= 0) { + if (lines[i].substring(4).trim() === uid) curHasUid = true; + continue; + } + if (u.startsWith('END:VTODO')) { + if (curBegin >= 0 && curHasUid && targetBegin < 0) { targetBegin = curBegin; targetEnd = i; } + curBegin = -1; curHasUid = false; + } + } + if (targetBegin < 0) return ics; + + const out = []; + let sawSummary = false, sawDesc = false, sawDue = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i], u = upperOf(line); + if (!(i > targetBegin && i < targetEnd)) { out.push(line); continue; } + if (u.startsWith('SUMMARY')) { sawSummary = true; if (title !== '') out.push('SUMMARY:' + escape(title)); continue; } + if (u.startsWith('DESCRIPTION')) { sawDesc = true; if (desc !== '') out.push('DESCRIPTION:' + escape(desc)); continue; } + if (u.startsWith('DUE')) { sawDue = true; if (dueMs > 0) out.push('DUE:' + fmtIcsTime(dueMs)); continue; } + out.push(line); + } + + const extra = []; + if (!sawSummary && title !== '') extra.push('SUMMARY:' + escape(title)); + if (!sawDesc && desc !== '') extra.push('DESCRIPTION:' + escape(desc)); + if (!sawDue && dueMs > 0) extra.push('DUE:' + fmtIcsTime(dueMs)); + if (extra.length === 0) { const p = out.join(crlf); return p.endsWith(crlf) ? p : p + crlf; } + + // 重新数目标块序号 + let targetOrdinal = -1; + { + let n = -1, inBlk = false, has = false; + for (let i = 0; i < out.length; i++) { + const u = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { n++; inBlk = true; has = false; continue; } + if (u.startsWith('UID:') && inBlk && out[i].substring(4).trim() === uid) { has = true; continue; } + if (u.startsWith('END:VTODO') && inBlk) { if (has && targetOrdinal < 0) targetOrdinal = n; inBlk = false; } + } + } + const result = []; + let vtodoIdx = -1, inserted = false; + for (let i = 0; i < out.length; i++) { + const u = upperOf(out[i]); + if (u.startsWith('BEGIN:VTODO')) { vtodoIdx++; result.push(out[i]); continue; } + if (u.startsWith('END:VTODO')) { + if (!inserted && vtodoIdx === targetOrdinal) { for (const e of extra) result.push(e); inserted = true; } + vtodoIdx = -1; result.push(out[i]); continue; + } + result.push(out[i]); + } + if (!inserted) { + const t0 = result.join(crlf); + const iv = t0.lastIndexOf('END:VCALENDAR'); + if (iv > 0) { + const m = t0.substring(0, iv) + extra.join(crlf) + crlf + t0.substring(iv); + return m.endsWith(crlf) ? m : m + crlf; + } + for (const e of extra) result.push(e); + } + const t2 = result.join(crlf); + return t2.endsWith(crlf) ? t2 : t2 + crlf; +} + +let pass = 0, fail = 0; +function ok(name, cond, extra) { + if (cond) { console.log(' ✅', name); pass++; } + else { console.log(' ❌', name, extra || ''); fail++; } +} +function structureIntact(ics) { + const i = ics.indexOf('END:VCALENDAR'); + if (i < 0) return false; + return ics.substring(i + 'END:VCALENDAR'.length).replace(/[\r\n]/g, '') === ''; +} +function balanced(ics, tag) { + const b = (ics.match(new RegExp('BEGIN:' + tag, 'g')) || []).length; + const e = (ics.match(new RegExp('END:' + tag, 'g')) || []).length; + return b === e; +} + +// 原始:含 PRIORITY / CATEGORIES / X-扩展 —— 这些必须原样保留(防丢数据) +const ics1 = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:旧标题\r\nDESCRIPTION:旧备注\r\nDUE:20260105T090000Z\r\nPRIORITY:1\r\nCATEGORIES:家务,生活\r\nX-MY-EXT:hello\r\nSTATUS:NEEDS-ACTION\r\nPERCENT-COMPLETE:0\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; + +const due = new Date(2026, 2, 15, 14, 30, 0).getTime(); +const r1 = rewriteTodoFields(ics1, 't1', '新标题', '新备注', due); + +ok('标题已更新', r1.includes('SUMMARY:新标题')); +ok('旧标题已消失', !r1.includes('SUMMARY:旧标题')); +ok('备注已更新', r1.includes('DESCRIPTION:新备注')); +ok('截止时间已更新', /DUE:\d{8}T\d{6}/.test(r1) && !r1.includes('DUE:20260105')); +ok('⭐ 保留 PRIORITY', r1.includes('PRIORITY:1')); +ok('⭐ 保留 CATEGORIES', r1.includes('CATEGORIES:家务,生活') || r1.includes('CATEGORIES:家务\\,生活')); +ok('⭐ 保留 X-MY-EXT', r1.includes('X-MY-EXT:hello')); +ok('⭐ 保留 STATUS', r1.includes('STATUS:NEEDS-ACTION')); +ok('⭐ 结构完整', structureIntact(r1)); +ok('BEGIN/END VTODO 配对', balanced(r1, 'VTODO')); + +// 清除截止时间 +const r2 = rewriteTodoFields(ics1, 't1', '旧标题', '旧备注', 0); +ok('截止时间可清除', !/DUE:\d{8}/.test(r2)); +ok('清除后仍保留其他字段', r2.includes('PRIORITY:1')); + +// 特殊字符转义(分号/逗号/换行) +const r3 = rewriteTodoFields(ics1, 't1', '标题;含逗号,和换行\n第二行', '', due); +ok('⭐ 特殊字符已转义', r3.includes('SUMMARY:标题\\;含逗号\\,和换行\\n第二行'), r3.match(/SUMMARY:[^\r]*/)?.[0]); + +// uid 不匹配 → 原文不动 +ok('uid 不匹配 → 原文不变', rewriteTodoFields(ics1, 'nope', 'x', 'y', due) === ics1); + +// 缺 SUMMARY/DESCRIPTION 的极简待办 → 补齐 +const ics4 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t2\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; +const r4 = rewriteTodoFields(ics4, 't2', '补的标题', '补的备注', due); +ok('缺字段能补齐', r4.includes('SUMMARY:补的标题') && r4.includes('DESCRIPTION:补的备注') && /DUE:/.test(r4)); +ok('⭐ 补齐在 VTODO 内', structureIntact(r4) && (() => { + const b = r4.substring(r4.indexOf('BEGIN:VTODO'), r4.indexOf('END:VTODO')); + return b.includes('SUMMARY:补的标题'); +})()); + +// 多 VTODO 只改目标 +const ics5 = 'BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:a1\r\nSUMMARY:第一个\r\nEND:VTODO\r\nBEGIN:VTODO\r\nUID:a2\r\nSUMMARY:第二个\r\nEND:VTODO\r\nEND:VCALENDAR\r\n'; +const r5 = rewriteTodoFields(ics5, 'a2', '改后', '', due); +ok('多 VTODO:a1 未动', r5.split('UID:a1')[1].split('END:VTODO')[0].includes('SUMMARY:第一个')); +ok('多 VTODO:a2 已改', r5.split('UID:a2')[1].split('END:VTODO')[0].includes('SUMMARY:改后')); +ok('⭐ 多 VTODO 结构完整', structureIntact(r5)); + +console.log(`\n结果:通过 ${pass},失败 ${fail}`); +console.log('\n--- 测试1 输出 ---'); +console.log(r1.replace(/\r\n/g, '\n')); +process.exit(fail === 0 ? 0 : 1); \ No newline at end of file