feat(账号/待办): 日历本区分日历与待办类型,待办标签两级判定 #6

用户要求两点:
1. 添加/修改账号时,在颜色前面显示图标区分「日历本 / 待办本」
2. 没勾选任何待办本 → 待办标签不显示;选了待办本但没数据 → 也不显示

## 协议层:supported-calendar-component-set
CalDAV 用这个属性声明集合支持哪些组件:
    <C:supported-calendar-component-set>
      <C:comp name="VEVENT"/>   ← 纯日历本
      <C:comp name="VTODO"/>    ← 纯待办本
    </C:supported-calendar-component-set>

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 探测)。
This commit is contained in:
2026-11-02 22:32:51 +08:00
parent 24e33ed5c9
commit 09cb0f9664
4 changed files with 131 additions and 5 deletions
@@ -57,6 +57,14 @@ export class DavAccount {
calendarColors: string[] = []; calendarColors: string[] = [];
/** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */ /** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */
calendarWritable: string[] = []; calendarWritable: string[] = [];
/**
* ⭐ COMP_SET 各日历本支持的组件类型,与 calendarHrefs 一一对应:
* 'event' 纯日历本(只有 VEVENT)
* 'todo' 纯待办本(只有 VTODO)
* 'both' 混合本
* 'unknown' 服务器未声明 → 按日历对待(旧数据也走这里,保持兼容)
*/
calendarComps: string[] = [];
itemCount: number = 0; itemCount: number = 0;
lastSyncTime: string = ''; lastSyncTime: string = '';
} }
@@ -474,6 +482,9 @@ export class AccountStore {
if (parts.length >= 12) { if (parts.length >= 12) {
acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';'); acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';');
} }
if (parts.length >= 13) {
acc.calendarComps = parts[12] === '' ? [] : parts[12].split(';');
}
return acc; return acc;
} }
@@ -565,6 +576,7 @@ export class AccountStore {
a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : []; a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : [];
a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : []; a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : [];
a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] 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.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0;
a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : ''; a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : '';
if (a.id === '') { if (a.id === '') {
+45
View File
@@ -18,6 +18,15 @@ export class DavColorEntry {
color: string = ''; // 规范化后的 #RRGGBB,可能为空 color: string = ''; // 规范化后的 #RRGGBB,可能为空
writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写) writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写)
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测) 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(已补全域名) href: string = ''; // 完整 URL(已补全域名)
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段) displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
color: string = ''; // 规范化后的 #RRGGBB,可能为空 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/"><d:prop>' + 'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' + '<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<d:current-user-privilege-set/>' + '<d:current-user-privilege-set/>' +
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>'; '</d:prop></d:propfind>';
const httpRequest = http.createHttp(); const httpRequest = http.createHttp();
try { try {
@@ -104,6 +117,7 @@ export class DavClient {
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color')); color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
} }
entry.color = color; entry.color = color;
entry.comps = DavClient.parseComps(block);
// 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读 // 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读
const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set'); const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set');
if (privSet !== '') { 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)。 * PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
* *
@@ -136,6 +177,7 @@ export class DavClient {
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' + '<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' + 'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' + '<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
'</d:prop></d:propfind>'; '</d:prop></d:propfind>';
const httpRequest = http.createHttp(); const httpRequest = http.createHttp();
const out = new DavCalendarDiscovery(); const out = new DavCalendarDiscovery();
@@ -197,6 +239,9 @@ export class DavClient {
entry.href = href.startsWith('http') ? href : origin + href; entry.href = href.startsWith('http') ? href : origin + href;
entry.displayName = name; entry.displayName = name;
entry.color = color; entry.color = color;
entry.comps = DavClient.parseComps(block);
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
out.entries.push(entry); out.entries.push(entry);
} }
return out; return out;
+40 -2
View File
@@ -24,14 +24,27 @@ export class EditCalendarItem {
href: string; href: string;
name: string; name: string;
color: string; // 服务器定义的颜色(calendar-color),可能为空 color: string; // 服务器定义的颜色(calendar-color),可能为空
/** ⭐ COMP_SET 'event' / 'todo' / 'both' / 'unknown'(见 DavCalendarEntry.comps) */
comps: string;
selected: boolean; selected: boolean;
constructor(href: string, name: string, color: string) { constructor(href: string, name: string, color: string, comps: string = 'unknown') {
this.href = href; this.href = href;
this.name = name; this.name = name;
this.color = color; this.color = color;
this.comps = comps;
this.selected = false; 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; return;
} }
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem => 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) { if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本'); LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username); this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
@@ -246,6 +259,7 @@ struct EditAccountPage {
target.calendarHrefs = newHrefs; target.calendarHrefs = newHrefs;
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name); target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color); 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} 个日历本`); LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey // 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href): // ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
@@ -530,6 +544,30 @@ struct EditCalendarRow {
build() { build() {
Row({ space: 12 }) { 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 !== '') { if (this.item.color !== '') {
Circle() Circle()
.width(12) .width(12)
+34 -3
View File
@@ -1514,10 +1514,11 @@ struct Index {
this.modeTab('month', '月') this.modeTab('month', '月')
this.modeTab('week', '周') this.modeTab('week', '周')
this.modeTab('agenda', '列表') this.modeTab('agenda', '列表')
// ⭐ 待办页签按需显示:**没有待办就不显示**。 // ⭐ 待办页签两级判定(用户要求):
// ① 没勾选任何待办本(VTODO)→ 必然没待办,直接不显示
// ② 选了待办本,但实际待办数为 0 → 也不显示
// 否则用户点进去一片空白,会以为功能坏了。 // 否则用户点进去一片空白,会以为功能坏了。
// todos 已按"可见日历本"过滤,所以这里判定 = 用户实际能看到的待办数。 if (this.shouldShowTodoTab()) {
if (this.todos.length > 0) {
this.modeTab('todo', '待办') this.modeTab('todo', '待办')
} }
Blank() 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 { private isOnToday(): boolean {
return this.selectedDate === this.startOfDay(Date.now()); return this.selectedDate === this.startOfDay(Date.now());