From 09cb0f9664f7c4a426165457c633f1f349a48653 Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 2 Nov 2026 22:32:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B4=A6=E5=8F=B7/=E5=BE=85=E5=8A=9E):=20?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E6=9C=AC=E5=8C=BA=E5=88=86=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E4=B8=8E=E5=BE=85=E5=8A=9E=E7=B1=BB=E5=9E=8B=EF=BC=8C=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E6=A0=87=E7=AD=BE=E4=B8=A4=E7=BA=A7=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=20#6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求两点: 1. 添加/修改账号时,在颜色前面显示图标区分「日历本 / 待办本」 2. 没勾选任何待办本 → 待办标签不显示;选了待办本但没数据 → 也不显示 ## 协议层:supported-calendar-component-set CalDAV 用这个属性声明集合支持哪些组件: ← 纯日历本 ← 纯待办本 DavClient 改动: - PROPFIND 请求加该属性(两处:propfindColors / propfindCalendars) - 新增 `parseComps()` 归一成四类:event / todo / both / **unknown** - `DavColorEntry` 与 `DavCalendarEntry` 都加 comps 字段 ⭐ 为什么保留 unknown: 很多服务器(含自研实现)压根不返回这个属性。若武断判成"不是待办本", 用户原本能用的日历会被错误分类。unknown 一律按**日历**对待,保持兼容。 ## 存储 DavAccount 增加 `calendarComps: string[]`,与 calendarHrefs 一一对应。 JSON 存储天然支持;旧数据读出来是空数组 → 全部 unknown → 视为日历。 ## 界面 EditCalendarRow 在颜色圆点**前面**加类型图标,用户先看类型再看颜色: · 待办本 → ✓ 配成功色 · 混合本 → ▦✓ · 日历/unknown → ▦ ## 待办标签两级判定 1. `hasTodoBook()`:被选中的日历本里存在 comps 为 todo/both 的 —— 一个都没选 ⇒ 必然没待办,直接不显示 2. `todos.length > 0` —— 选了待办本但没数据,也不显示 比只看条数更准:没勾待办本时,同步可能拉到残留数据, 只看条数会误显示;第一级在"还没同步"时就能给出确定答案,不会闪一下。 ## 待确认(需真机日志) 服务器是否真的返回 supported-calendar-component-set。 诊断日志已加:`日历本组件类型 <名称> → <类型>`。 若全是 unknown,说明服务器没声明 ⇒ 图标会退化成日历图标, 此时需要改用其它判据(如按本内是否含 VTODO 探测)。 --- entry/src/main/ets/common/AccountStore.ets | 12 ++++++ entry/src/main/ets/common/DavClient.ets | 45 ++++++++++++++++++++ entry/src/main/ets/pages/EditAccountPage.ets | 42 +++++++++++++++++- entry/src/main/ets/pages/Index.ets | 37 ++++++++++++++-- 4 files changed, 131 insertions(+), 5 deletions(-) diff --git a/entry/src/main/ets/common/AccountStore.ets b/entry/src/main/ets/common/AccountStore.ets index 24e85a0..051b8a1 100644 --- a/entry/src/main/ets/common/AccountStore.ets +++ b/entry/src/main/ets/common/AccountStore.ets @@ -57,6 +57,14 @@ export class DavAccount { calendarColors: string[] = []; /** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */ calendarWritable: string[] = []; + /** + * ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应: + * 'event' 纯日历本(只有 VEVENT) + * 'todo' 纯待办本(只有 VTODO) + * 'both' 混合本 + * 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容) + */ + calendarComps: string[] = []; itemCount: number = 0; lastSyncTime: string = ''; } @@ -474,6 +482,9 @@ export class AccountStore { if (parts.length >= 12) { acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';'); } + if (parts.length >= 13) { + acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';'); + } return acc; } @@ -565,6 +576,7 @@ export class AccountStore { a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : []; a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : []; a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : []; + a.calendarComps = o['calendarComps'] !== undefined ? o['calendarComps'] as string[] : []; a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0; a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : ''; if (a.id === '') { diff --git a/entry/src/main/ets/common/DavClient.ets b/entry/src/main/ets/common/DavClient.ets index 9969c30..94022e9 100644 --- a/entry/src/main/ets/common/DavClient.ets +++ b/entry/src/main/ets/common/DavClient.ets @@ -18,6 +18,15 @@ export class DavColorEntry { color: string = ''; // 规范化后的 #RRGGBB,可能为空 writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写) privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测) + /** + * ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。 + * 'event' = 纯日历本(只有 VEVENT) + * 'todo' = 纯待办本(只有 VTODO) + * 'both' = 混合本(两者都有) + * 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容 + */ + comps: string = 'unknown'; + } /** 日历本集合条目("添加/编辑账号"页列日历本用) */ @@ -25,6 +34,8 @@ export class DavCalendarEntry { href: string = ''; // 完整 URL(已补全域名) displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段) color: string = ''; // 规范化后的 #RRGGBB,可能为空 + /** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */ + comps: string = 'unknown'; } /** @@ -61,6 +72,8 @@ export class DavClient { 'xmlns:ical="http://apple.com/ns/ical/">' + '' + '' + + // ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办 + '' + ''; const httpRequest = http.createHttp(); try { @@ -104,6 +117,7 @@ export class DavClient { color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color')); } entry.color = color; + entry.comps = DavClient.parseComps(block); // 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读 const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set'); if (privSet !== '') { @@ -123,6 +137,33 @@ export class DavClient { } } + /** + * ⭐ COMP_SET 解析 supported-calendar-component-set,归一成四类: + * event / todo / both / unknown + * + * 为什么 unknown 要单独存在:很多服务器(含自研实现)压根不返回这个属性。 + * 这时不能武断判成"不是待办本",否则用户原本能用的日历会被当成待办、 + * 或者反过来把纯日历标成待办 —— 保守按"日历"对待,不破坏既有行为。 + */ + static parseComps(block: string): string { + const seg: string = DavClient.extractTag(block, 'supported-calendar-component-set'); + if (seg === '') { + return 'unknown'; + } + const event: boolean = /name\s*=\s*["']VEVENT["']/i.test(seg); + const todo: boolean = /name\s*=\s*["']VTODO["']/i.test(seg); + if (event && todo) { + return 'both'; + } + if (todo) { + return 'todo'; + } + if (event) { + return 'event'; + } + return 'unknown'; + } + /** * PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。 * @@ -136,6 +177,7 @@ export class DavClient { '' + '' + + '' + ''; const httpRequest = http.createHttp(); const out = new DavCalendarDiscovery(); @@ -197,6 +239,9 @@ export class DavClient { entry.href = href.startsWith('http') ? href : origin + href; entry.displayName = name; entry.color = color; + entry.comps = DavClient.parseComps(block); + // COMPS_LOG 诊断:确认服务器有没有声明组件类型 + LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`); out.entries.push(entry); } return out; diff --git a/entry/src/main/ets/pages/EditAccountPage.ets b/entry/src/main/ets/pages/EditAccountPage.ets index c219eea..38d0920 100644 --- a/entry/src/main/ets/pages/EditAccountPage.ets +++ b/entry/src/main/ets/pages/EditAccountPage.ets @@ -24,14 +24,27 @@ export class EditCalendarItem { href: string; name: string; color: string; // 服务器定义的颜色(calendar-color),可能为空 + /** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */ + comps: string; selected: boolean; - constructor(href: string, name: string, color: string) { + constructor(href: string, name: string, color: string, comps: string = 'unknown') { this.href = href; this.name = name; this.color = color; + this.comps = comps; this.selected = false; } + + /** 是否待办本(纯待办或混合) */ + isTodoBook(): boolean { + return this.comps === 'todo' || this.comps === 'both'; + } + + /** 是否日历本(纯日历、混合、或服务器未声明) */ + isEventBook(): boolean { + return this.comps === 'event' || this.comps === 'both' || this.comps === 'unknown'; + } } /** @@ -179,7 +192,7 @@ struct EditAccountPage { return; } const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem => - new EditCalendarItem(e.href, e.displayName, e.color)); + new EditCalendarItem(e.href, e.displayName, e.color, e.comps)); if (list.length === 0) { LogUtil.write('编辑账号:未发现任何日历本'); this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username); @@ -246,6 +259,7 @@ struct EditAccountPage { target.calendarHrefs = newHrefs; target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name); target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color); + target.calendarComps = selectedItems.map((c: EditCalendarItem): string => c.comps); LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`); // 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey // ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href): @@ -530,6 +544,30 @@ struct EditCalendarRow { build() { Row({ space: 12 }) { + // ⭐ COMPS_ICON 类型图标:放在颜色圆点**前面**, + // 让用户先看清"这本是日历还是待办",再看它是什么颜色。 + // 混合本两个图标都显示(服务器同时支持 VEVENT 和 VTODO)。 + if (this.item.comps === 'todo') { + Text('✓') + .fontSize(13) + .fontColor($r('app.color.success')) + .width(16) + .textAlign(TextAlign.Center) + } else if (this.item.comps === 'both') { + Text('▦✓') + .fontSize(13) + .fontColor($r('app.color.text_secondary')) + .width(16) + .textAlign(TextAlign.Center) + } else { + // 'event' 或 'unknown':一律显示日历图标 + // (unknown 是服务器未声明,按日历对待,不要凭空标成待办) + Text('▦') + .fontSize(13) + .fontColor($r('app.color.text_secondary')) + .width(16) + .textAlign(TextAlign.Center) + } if (this.item.color !== '') { Circle() .width(12) diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index cdbdd84..3fa5c47 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -1514,10 +1514,11 @@ struct Index { this.modeTab('month', '月') this.modeTab('week', '周') this.modeTab('agenda', '列表') - // ⭐ 待办页签按需显示:**没有待办就不显示**。 + // ⭐ 待办页签两级判定(用户要求): + // ① 没勾选任何待办本(VTODO)→ 必然没待办,直接不显示 + // ② 选了待办本,但实际待办数为 0 → 也不显示 // 否则用户点进去一片空白,会以为功能坏了。 - // todos 已按"可见日历本"过滤,所以这里判定 = 用户实际能看到的待办数。 - if (this.todos.length > 0) { + if (this.shouldShowTodoTab()) { this.modeTab('todo', '待办') } Blank() @@ -2070,6 +2071,36 @@ struct Index { } } + /** + * ⭐ HAS_TODO_BOOK 是否勾选了"待办本"。 + * + * 判定:该账号的 calendarComps 里,**被选中**的日历本中存在 'todo' 或 'both'。 + * 一个都没选 ⇒ 必然没有待办,待办标签直接不显示。 + * + * 兼容:calendarComps 为空(旧账号数据没这个字段)或值 'unknown' + * 时不判定为待办本,退回由"待办条数>0"来决定,避免误隐藏。 + */ + private hasTodoBook(): boolean { + for (const acc of this.accounts) { + const comps: string[] = acc.calendarComps; + if (comps === undefined || comps.length === 0) { + continue; // 旧数据:交给条数判定 + } + for (let i = 0; i < acc.calendarHrefs.length && i < comps.length; i++) { + const c: string = comps[i]; + if (c === 'todo' || c === 'both') { + return true; + } + } + } + return false; + } + + /** 待办标签是否该显示:必须"选了待办本" **且** "确实有待办数据" */ + private shouldShowTodoTab(): boolean { + return this.hasTodoBook() && this.todos.length > 0; + } + /** 当前选中日是否就是今天(用于「今天」按钮的视觉状态) */ private isOnToday(): boolean { return this.selectedDate === this.startOfDay(Date.now());