feat(待办): 按日历本分组 + 编辑/删除 + 修整行点击误完成 + 周视图圆点 #5 #6

## 1. 修 bug:整行点击都会勾选完成
上一版把 onClick 挂在最外层 Row 上,点标题/点截止时间/点空白都会改状态。
改为:
  · 只有左侧圆圈 → 勾选/取消完成
  · 其余内容区 → 打开编辑弹框(不改变完成状态)

## 2. 待办按日历本分组
用户:待办应按日历本分类,未完成里也能按家庭/生活分开,要有区分度。
未完成/已完成 两大段内部,再按日历本聚合:
  · 分组标题前带该本颜色圆点(这是区分度的来源)
  · 组标题显示 本名(数量)
  · 组内按截止时间升序,无截止时间排最后
  · 组之间按组内最早截止时间排序(最紧急的本在前)
CalendarDataService.loadTodos 补 d.calName(原来只带 calKey,分组标题没名字)。

## 3. 待办编辑弹框
点内容区弹出,可改:标题 / 备注 / 截止时间(含日期+时间选择器,可清除),
另有删除(二次确认)。只读本禁用编辑并提示。

⚠️ 用**整页覆盖层**而不是 bindSheet:ArkUI 同一组件只能挂一个 bindSheet,
Index 已用 detailShow 挂了日程详情,再挂会被覆盖(EventEditPage 顶部记过此坑)。

写入仍走「GET 原文 → 改字段 → PUT」,只动 SUMMARY/DESCRIPTION/DUE,
**不动 STATUS/COMPLETED**(改内容不该重置完成状态),
并保留 PRIORITY/CATEGORIES/X-* 等本地没存的字段。

## 4. 周视图圆点
weekStripDays 原来没算 hasEvent;补上与月视图**共用同一份**事件日期集合
(buildEventDaySet),口径完全一致。
颜色:用主题色 brand(选中日换白),**不取日历本的颜色** ——
圆点只表示这天有安排,若取本色会误导成只有那个本有日程。

## 验证
scripts/test-ics-todo-fields.js:19 项全通过
(保留 PRIORITY/CATEGORIES/X-扩展、特殊字符转义、多 VTODO 只改目标、
缺字段补齐位置、结构完整性)。编译 BUILD SUCCESSFUL。

## 未做
子任务(父子层级 / RELATED-TO):需先确认服务器是否支持,另开工单。
This commit is contained in:
2026-11-02 15:47:03 +08:00
parent 98c4b6a96f
commit 9066a12bbc
6 changed files with 1002 additions and 54 deletions
@@ -341,6 +341,11 @@ export class CalendarDataService {
const found = sources.find((s: CalSource): boolean => s.calKey === key); const found = sources.find((s: CalSource): boolean => s.calKey === key);
return found !== undefined ? found.color : '#9AA0A6'; 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 { try {
const rows: LocalEvent[] = await EventDb.queryTodos(context); const rows: LocalEvent[] = await EventDb.queryTodos(context);
for (const e of rows) { for (const e of rows) {
@@ -357,6 +362,7 @@ export class CalendarDataService {
d.endTime = e.endTime; d.endTime = e.endTime;
d.isAllDay = e.isAllDay; d.isAllDay = e.isAllDay;
d.calKey = e.calKey; d.calKey = e.calKey;
d.calName = nameOf(e.calKey);
d.color = colorOf(e.calKey); d.color = colorOf(e.calKey);
d.completed = e.completed; d.completed = e.completed;
result.push(d); result.push(d);
+16
View File
@@ -338,6 +338,22 @@ export class EventDb {
/** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */ /** 全部未删除的待办(VTODO),未完成在前、按截止时间升序 */
/** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */ /** 按本地行 id 查一行(待办完成时用来取 uid / href / remotePath / etag) */
/** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */ /** 更新待办的完成状态与 etag(推送成功后调用,保证本地与服务器一致) */
/** 更新待办内容字段(标题 / 备注 / 截止时间)与 etag */
static async updateTodoFields(context: common.Context, id: number, title: string,
desc: string, dueMs: number, etag: string): Promise<void> {
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, static async setTodoCompleted(context: common.Context, id: number, completed: boolean,
etag: string): Promise<void> { etag: string): Promise<void> {
const store = await EventDb.getDb(context); const store = await EventDb.getDb(context);
+169
View File
@@ -327,6 +327,175 @@ export class IcsUtil {
return text2.endsWith(crlf) ? text2 : text2 + crlf; 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 待办列表(待办为只读展示,不参与推送) */ /** 解析 VCALENDAR 中的 VTODO 待办列表(待办为只读展示,不参与推送) */
static parseTodos(ics: string): RemoteEvent[] { static parseTodos(ics: string): RemoteEvent[] {
const unfolded: string = ics.replace(/\r?\n[ \t]/g, ''); const unfolded: string = ics.replace(/\r?\n[ \t]/g, '');
+110
View File
@@ -31,6 +31,116 @@ export class TodoService {
* @param done true=标记完成;false=取消完成 * @param done true=标记完成;false=取消完成
* @returns 成功返回 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<boolean> {
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<boolean> {
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<DavAccount | null> {
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<boolean> { static async setCompleted(context: common.Context, localId: number, done: boolean): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId); const row: LocalEvent | null = await EventDb.queryById(context, localId);
if (row === null) { if (row === null) {
+522 -23
View File
@@ -39,6 +39,18 @@ class MonthCell {
hasEvent: boolean = false; hasEvent: boolean = false;
} }
/**
* 待办分组(按日历本聚合)。
* 用 class 而不是 Map/匿名对象:ArkUI 的 ForEach 需要稳定结构,
* 用带类型的类更不容易在 ArkTS 严格模式下出错。
*/
class TodoGroup {
calKey: string = '';
name: string = '';
color: string = '#007DFF';
items: DisplayEvent[] = [];
}
const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日']; const WEEK_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
/** 视图前后顺序(决定手势/标签切换时的转场方向):月 → 周 → 列表 */ /** 视图前后顺序(决定手势/标签切换时的转场方向):月 → 周 → 列表 */
@@ -54,6 +66,19 @@ struct Index {
@State events: DisplayEvent[] = []; @State events: DisplayEvent[] = [];
@State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示) @State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示)
@State todoBusy: boolean = false; // 正在写回待办状态(防连点) @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年) @State agendaEvents: DisplayEvent[] = []; // 列表视图全量数据(今天 ~ 未来2年)
private agendaStale: boolean = true; // 全量数据是否需要重新加载 private agendaStale: boolean = true; // 全量数据是否需要重新加载
@State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月 @State monthPages: MonthCell[][] = [[], [], []]; // 上月/本月/下月
@@ -491,6 +516,10 @@ struct Index {
const gridStart: number = first - offset * 86400000; const gridStart: number = first - offset * 86400000;
const todayStart: number = this.startOfDay(Date.now()); const todayStart: number = this.startOfDay(Date.now());
const eventDays: Set<number> = this.buildEventDaySet(); const eventDays: Set<number> = this.buildEventDaySet();
// DOT_DEBUG 临时:确认集合算出来了没有
if (month >= 0) {
LogUtil.write(`月格点标记 ${year}-${month + 1}:事件 ${this.events.length} 条,有事件的日期 ${eventDays.size} 天`);
}
const cells: MonthCell[] = []; const cells: MonthCell[] = [];
for (let i = 0; i < 42; i++) { for (let i = 0; i < 42; i++) {
const dateMs: number = gridStart + i * 86400000; const dateMs: number = gridStart + i * 86400000;
@@ -1118,6 +1147,11 @@ struct Index {
this.consentGate() this.consentGate()
} }
// ⭐ TODO_EDITOR_UI 待办编辑弹框(覆盖层方案:同一组件只能挂一个 bindSheet)
if (this.todoEditShow) {
this.todoEditorOverlay()
}
// 功能引导:整页覆盖层,放根 Stack 最后一层(盖住主界面与悬浮按钮) // 功能引导:整页覆盖层,放根 Stack 最后一层(盖住主界面与悬浮按钮)
if (this.showTips) { if (this.showTips) {
this.tipsOverlay() this.tipsOverlay()
@@ -1654,16 +1688,27 @@ struct Index {
.backgroundColor(cell.dateMs === this.selectedDate .backgroundColor(cell.dateMs === this.selectedDate
? $r('app.color.selected_bg') ? $r('app.color.selected_bg')
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
// 农历 + 圆点同一行(与月视图排版一致,避免周条变高)
Row({ space: 2 }) {
Text(cell.lunar) Text(cell.lunar)
.fontSize(8) .fontSize(8)
.fontColor($r('app.color.text_hint')) .fontColor($r('app.color.text_hint'))
.maxLines(1) .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) .layoutWeight(1)
.onClick(() => { .onClick(() => {
this.selectedDate = cell.dateMs; this.selectedDate = cell.dateMs;
}) })
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`) }, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}_${cell.hasEvent}`)
} }
.width('100%') .width('100%')
.padding({ left: 12, right: 12 }) .padding({ left: 12, right: 12 })
@@ -1682,6 +1727,9 @@ struct Index {
const offset: number = (sel.getDay() + 6) % 7; const offset: number = (sel.getDay() + 6) % 7;
const weekStart: number = this.selectedDate - offset * 86400000; const weekStart: number = this.selectedDate - offset * 86400000;
const todayStart: number = this.startOfDay(Date.now()); const todayStart: number = this.startOfDay(Date.now());
// ⭐ WEEK_DOT 周条也要显示圆点 ⇒ 与月视图共用同一份"有事件日期"集合,
// 口径完全一致(区间相交,跨天事件覆盖的每天都算)。
const eventDays: Set<number> = this.buildEventDaySet();
const cells: MonthCell[] = []; const cells: MonthCell[] = [];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const dateMs: number = weekStart + i * 86400000; const dateMs: number = weekStart + i * 86400000;
@@ -1690,6 +1738,7 @@ struct Index {
cell.day = new Date(dateMs).getDate(); cell.day = new Date(dateMs).getDate();
cell.isToday = dateMs === todayStart; cell.isToday = dateMs === todayStart;
cell.lunar = LunarUtil.lunarDayText(dateMs); cell.lunar = LunarUtil.lunarDayText(dateMs);
cell.hasEvent = eventDays.has(this.startOfDay(dateMs));
cells.push(cell); cells.push(cell);
} }
return cells; return cells;
@@ -2520,27 +2569,63 @@ struct Index {
/** 待办页:与日历视图完全分开,只读展示服务器端 VTODO */ /** 待办页:与日历视图完全分开,只读展示服务器端 VTODO */
@Builder @Builder
// ⭐ TODO_GROUPED 待办视图:未完成 / 已完成 两大段,段内再按**日历本**分组。
// 用户要求"按日历本分类显示,未完成里也能按家庭、生活分开,要有区分度"。
// 分组标题前带该本的颜色圆点 —— 这才是"区分度"的来源。
todoBody() { todoBody() {
Scroll() { Scroll() {
Column({ space: 8 }) { Column({ space: 8 }) {
Text('未完成') Text(`未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`)
.fontSize(13) .fontSize(13)
.fontWeight(FontWeight.Bold) .fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
.width('100%') .width('100%')
ForEach(this.todos.filter((t: DisplayEvent): boolean => !t.completed), (t: DisplayEvent) => { 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) this.todoRow(t)
}, (t: DisplayEvent) => `t${t.id}_${t.completed}`) }, (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)) { if (this.todos.some((t: DisplayEvent): boolean => t.completed)) {
Text('已完成') Text(`已完成(${this.todos.filter((t: DisplayEvent): boolean => t.completed).length})`)
.fontSize(13) .fontSize(13)
.fontWeight(FontWeight.Bold) .fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
.width('100%') .width('100%')
.margin({ top: 10 }) .margin({ top: 10 })
ForEach(this.todos.filter((t: DisplayEvent): boolean => t.completed), (t: DisplayEvent) => { 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) this.todoRow(t)
}, (t: DisplayEvent) => `t${t.id}_${t.completed}`) }, (t: DisplayEvent) => `c${t.id}_${t.completed}`)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}, (g: TodoGroup) => `g1_${g.calKey}`)
} }
if (this.todos.length === 0 && !this.loading) { if (this.todos.length === 0 && !this.loading) {
Column({ space: 8 }) { Column({ space: 8 }) {
@@ -2574,10 +2659,234 @@ struct Index {
.align(Alignment.Top) .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 @Builder
todoRow(t: DisplayEvent) { todoRow(t: DisplayEvent) {
// ⭐ TODO_ROW_SPLIT 点击区域拆分:
// · 左侧圆圈 → 勾选/取消完成
// · 其余部分 → 打开编辑弹框
// 旧版把 onClick 挂在整行上,点标题、点截止时间、点空白都会改完成状态,
// 用户反馈"点任何地方都是完成",是明确的交互缺陷。
Row({ space: 10 }) { Row({ space: 10 }) {
// 勾选框(颜色跟随日历本) // 勾选框(颜色跟随日历本)—— **只有这块触发完成切换**
Row() {
if (t.completed) { if (t.completed) {
Text('✓') Text('✓')
.fontSize(13) .fontSize(13)
@@ -2594,19 +2903,31 @@ struct Index {
.borderRadius(11) .borderRadius(11)
.border({ width: 2, color: this.colorOfCal(t.calKey) }) .border({ width: 2, color: this.colorOfCal(t.calKey) })
} }
}
.padding({ right: 2 })
.onClick(() => {
this.toggleTodo(t);
})
Column({ space: 3 }) { Column({ space: 3 }) {
Text(t.title === '' ? '(无标题待办)' : t.title) Text(t.title === '' ? '(无标题待办)' : t.title)
.fontSize(15) .fontSize(15)
.fontColor(t.completed ? $r('app.color.text_hint') : $r('app.color.text_primary')) .fontColor(t.completed ? $r('app.color.text_hint') : $r('app.color.text_primary'))
.decoration({ type: t.completed ? TextDecorationType.LineThrough : TextDecorationType.None }) .decoration({ type: t.completed ? TextDecorationType.LineThrough : TextDecorationType.None })
.maxLines(1) .maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
Text(t.startTime > 0 ? `截止 ${this.fmtDue(t.startTime)}` : '无截止时间') Text(this.todoSubtitle(t))
.fontSize(12) .fontSize(12)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
} }
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.layoutWeight(1) .layoutWeight(1)
// ⭐ 内容区点击 → 打开编辑(不改变完成状态)
.onClick(() => {
this.openTodoEditor(t);
})
} }
.width('100%') .width('100%')
.padding(10) .padding(10)
@@ -2614,9 +2935,178 @@ struct Index {
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') }) .border({ width: 1, color: $r('app.color.shadow_color') })
.opacity(t.completed ? 0.7 : 1) .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<string, TodoGroup> = new Map<string, TodoGroup>();
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<void> {
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<void> {
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,6 +3159,12 @@ struct Index {
.backgroundColor(cell.dateMs === this.selectedDate .backgroundColor(cell.dateMs === this.selectedDate
? $r('app.color.selected_bg') ? $r('app.color.selected_bg')
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent)) : (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
// ⭐ DOT_INLINE 有事件的日期标记:**每个日期最多一个**圆点,叠在农历行右侧。
// 为什么不做成独立一行:实测格子高度已被"星期/日期/农历"占满
// (175px 全部用尽),再插一行会把内容顶出格子、圆点不可见。
// 无事件时圆点透明但仍占位,保证有/无事件的行高完全一致、不会跳。
// 口径:hasEvent 由 buildEventDaySet() 统一算出,不区分日历本、不区分几条。
Row({ space: 2 }) {
if (!this.wideLayout) { if (!this.wideLayout) {
// 宽屏下月历格子变窄,省略农历腾出空间 // 宽屏下月历格子变窄,省略农历腾出空间
Text(cell.lunar) Text(cell.lunar)
@@ -2676,20 +3172,23 @@ struct Index {
.fontColor(cell.dateMs === this.selectedDate .fontColor(cell.dateMs === this.selectedDate
? $r('app.color.text_secondary') : $r('app.color.text_hint')) ? $r('app.color.text_secondary') : $r('app.color.text_hint'))
.maxLines(1) .maxLines(1)
.layoutWeight(1)
.textAlign(TextAlign.Center)
} }
// ⭐ EVENT_DOT 有事件的日期标记:**每个日期最多一个**圆点。 // ⭐ DOT_CIRCLE 用 Circle 组件而不是空 Row:
// 只表达"这天有安排",不区分是哪个日历本、有几条。 // 空容器 + 透明背景不会产生实际绘制,dump 里根本看不到节点
// (旧实现按日历本逐个画,3 个本 3 个点,15 个本满屏都是点,故被废弃。) // (实测连改两版都"没渲染",其实是被优化掉了)。
// 无事件时用等高的透明占位,避免有/无点导致格子高度跳动、整月行高不一致。 // Circle 是专门画圆的组件,一定落绘制节点。
Row() Circle({ width: this.wideLayout ? 4 : 5, height: this.wideLayout ? 4 : 5 })
.width(this.wideLayout ? 4 : 5) .fill(cell.hasEvent
.height(this.wideLayout ? 4 : 5)
.borderRadius(this.wideLayout ? 2 : 2.5)
.backgroundColor(cell.hasEvent
? (cell.dateMs === this.selectedDate ? (cell.dateMs === this.selectedDate
? $r('app.color.button_text') // 选中日深底 → 用白点保证对比度 ? $r('app.color.button_text') // 选中日深底 → 白点保对比度
: $r('app.color.brand')) // 其余用主题色,比纯红柔和 : $r('app.color.brand')) // 其余用主题色,比纯红柔和
: Color.Transparent) : Color.Transparent)
.opacity(cell.hasEvent ? 1 : 0)
}
.width('100%')
.justifyContent(FlexAlign.Center)
} }
.layoutWeight(1) .layoutWeight(1)
.padding({ top: this.wideLayout ? 2 : 3, bottom: this.wideLayout ? 2 : 3 }) .padding({ top: this.wideLayout ? 2 : 3, bottom: this.wideLayout ? 2 : 3 })
+148
View File
@@ -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);