diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index 80196f4..b854108 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -13,6 +13,7 @@ import { IcsUtil } from './IcsUtil'; /** 界面展示用事件 */ export class DisplayEvent { id: number = 0; // 本地库 id 或系统日历事件 id + uid: string = ''; // 服务器 UID(待办父子关系用它匹配 parentUid) isSystem: boolean = false; title: string = ''; description: string = ''; @@ -36,6 +37,10 @@ export class DisplayEvent { categories: string = ''; /** 提醒提前分钟数(多选),空 = 不提醒 */ reminders: number[] = []; + /** 父任务 UID(空 = 顶层任务),对应 RELATED-TO */ + parentUid: string = ''; + /** 附件 JSON 数组字符串(含 base64),空 = 无附件 */ + attachments: string = ''; writable: boolean = true; // 所属日历本是否可写(只读时点击显示详情而非编辑) } @@ -368,6 +373,7 @@ export class CalendarDataService { } const d = new DisplayEvent(); d.id = e.id; + d.uid = e.uid; d.isSystem = false; d.title = e.title; d.description = e.description; @@ -382,6 +388,8 @@ export class CalendarDataService { d.priority = e.priority; d.categories = e.categories; d.reminders = e.reminders; + d.parentUid = e.parentUid; + d.attachments = e.attachments; d.recurRule = e.rrule; d.dtstart = e.dtstart; d.recurring = e.rrule !== ''; diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index c2bbade..acc0856 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -42,6 +42,12 @@ export class LocalEvent { categories: string = ''; reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒 reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序 + /** 父任务 UID(空 = 顶层任务)。对应 VTODO 的 RELATED-TO:RELTYPE=PARENT。 + * 用 UID 而非本地 id:父子关系是服务器资源属性,跨设备同步时 UID 才稳定。 */ + parentUid: string = ''; + /** 附件 JSON 数组字符串:[{name,mime,size,base64}],空 = 无附件。 + * base64 直接存库,同步时拼 ATTACH 行零转换。 */ + attachments: string = ''; } /** 远端事件条目(REPORT 解析结果) */ @@ -67,6 +73,10 @@ export class RemoteEvent { recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无) reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) reminders: number[] = []; // 全部提醒提前分钟数(多选) + /** 父任务 UID(空 = 顶层任务)。 */ + parentUid: string = ''; + /** 附件 JSON 数组字符串(含 base64),空 = 无附件。 */ + attachments: string = ''; /** * ⭐ REMOTE_PATH 服务器上的真实资源文件名(REPORT 返回的 href 末段,已含 URL 编码)。 * @@ -149,6 +159,16 @@ export class EventDb { } catch (err) { // 列已存在 } + try { + await store.executeSql("ALTER TABLE events ADD COLUMN parent_uid TEXT DEFAULT ''"); + } catch (err) { + // 列已存在 + } + try { + await store.executeSql("ALTER TABLE events ADD COLUMN attachments TEXT DEFAULT ''"); + } catch (err) { + // 列已存在 + } try { await store.executeSql('ALTER TABLE events ADD COLUMN priority INTEGER DEFAULT 0'); } catch (err) { @@ -246,6 +266,10 @@ export class EventDb { e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id')); e.reminder = rs.getLong(rs.getColumnIndex('reminder')); // 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒 + const pUid: string = rs.getString(rs.getColumnIndex('parent_uid')); + e.parentUid = (pUid === undefined || pUid === null) ? '' : pUid; + const att: string = rs.getString(rs.getColumnIndex('attachments')); + e.attachments = (att === undefined || att === null) ? '' : att; const remStr: string = rs.getString(rs.getColumnIndex('reminders')); if (remStr !== '') { e.reminders = remStr.split(',').map((s: string): number => Number(s)) @@ -280,7 +304,9 @@ export class EventDb { 'reminder': e.reminders.length > 0 ? e.reminders[0] : 0, 'reminders': e.reminders.map((n: number): string => String(n)).join(','), 'priority': e.priority, - 'categories': e.categories + 'categories': e.categories, + 'parent_uid': e.parentUid, + 'attachments': e.attachments }; return bucket; } @@ -515,6 +541,29 @@ export class EventDb { * - **必须 `completed=0`** —— 已完成的待办不该再响 * - 待办的提醒基准同样是 start_time(loadTodos 把 DUE 存进 startTime) */ + /** + * 单独更新待办的子任务/附件属性(updateTodoFields 只管标题/备注/截止)。 + * @param touchParent true=写 parent_uid(空串=清空),false=不动 + * @param touchAttach true=写 attachments(空串=清空),false=不动 + */ + static async setTodoHierarchy(context: common.Context, id: number, parentUid: string, + attachments: string, touchParent: boolean, touchAttach: boolean): Promise { + if (!touchParent && !touchAttach) { + return; + } + const store = await EventDb.getDb(context); + const bucket: relationalStore.ValuesBucket = {}; + if (touchParent) { + (bucket as Record)['parent_uid'] = parentUid; + } + if (touchAttach) { + (bucket as Record)['attachments'] = attachments; + } + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('id', id); + await store.update(bucket, predicates); + } + /** 单独更新待办的提醒列表(updateTodoFields 只管标题/备注/截止三字段) */ static async setTodoReminders(context: common.Context, id: number, reminders: number[]): Promise { @@ -675,11 +724,17 @@ export class EventDb { e.recurrenceId = r.recurrenceId; e.reminder = r.reminder; e.reminders = r.reminders; + e.priority = r.priority; + e.categories = r.categories; + e.parentUid = r.parentUid; + e.attachments = r.attachments; await store.insert('events', EventDb.toBucket(e)); added++; } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate || found.reminder !== r.reminder || found.recurring !== r.recurring || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders) + || found.priority !== r.priority || found.categories !== r.categories + || found.parentUid !== r.parentUid || found.attachments !== r.attachments || found.href !== href) { // 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填; // href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了, @@ -707,6 +762,10 @@ export class EventDb { found.recurrenceId = r.recurrenceId; found.reminder = r.reminder; found.reminders = r.reminders; + found.priority = r.priority; + found.categories = r.categories; + found.parentUid = r.parentUid; + found.attachments = r.attachments; const up = new relationalStore.RdbPredicates('events'); up.equalTo('id', found.id); await store.update(EventDb.toBucket(found), up); diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index f6233a3..903cf92 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -2,6 +2,13 @@ // iCalendar 解析与生成 import { LocalEvent, RemoteEvent } from './EventDb'; +/** 解析 ATTACH 行得到的单个附件元数据(Index.ets 弹层也会用到,故 export) */ +export class AttachMeta { + name: string = ''; + mime: string = ''; + base64: string = ''; +} + /** 解析出的远端日程(内部中间结构) */ class ParsedEvent { uid: string = ''; @@ -346,7 +353,9 @@ export class IcsUtil { */ static rewriteTodoFields(ics: string, uid: string, title: string, desc: string, dueMs: number, priority: number = -1, categories: string = '', - location: string = '', reminders: number[] = []): string { + location: string = '', reminders: number[] = [], parentUid: string = '', + attachments: string = '', touchParent: boolean = false, + touchAttach: boolean = false): string { // priority === -1 表示"本次不改优先级";0 表示"清空优先级"。 // 用 -1 而不是 0 做哨兵,因为 0 本身是合法语义(无优先级)。 const touchPriority: boolean = priority >= 0; @@ -432,6 +441,26 @@ export class IcsUtil { if (u.startsWith('END:VALARM')) { inAlarm = false; } continue; } + // 父任务:touched 时才改写,否则原样保留(不误删别人写的 RELATED-TO) + if (u.startsWith('RELATED-TO')) { + if (touchParent) { + if (parentUid !== '') { + out.push(IcsUtil.parentLine(parentUid)); + } + // parentUid 空 → 删除该行 + continue; + } + out.push(line); + continue; + } + // 附件:touched 时整段重建(删所有旧 ATTACH,末尾统一写新的) + if (u.startsWith('ATTACH')) { + if (touchAttach) { + continue; // 丢弃旧 ATTACH,末尾统一写 + } + out.push(line); + continue; + } if (u.startsWith('PRIORITY') && touchPriority) { sawPriority = true; if (priority >= 1 && priority <= 9) { @@ -473,6 +502,15 @@ export class IcsUtil { if (!sawLocation && touchLocation) { extra.push('LOCATION:' + IcsUtil.escape(location)); } + if (touchParent && parentUid !== '') { + extra.push(IcsUtil.parentLine(parentUid)); + } + if (touchAttach) { + const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments); + for (const m of metas) { + extra.push(IcsUtil.attachLine(m)); + } + } // 提醒整体重建(上面已把旧 VALARM 整段跳过) if (reminders.length > 0) { const alarms: string = IcsUtil.valarms(reminders); @@ -791,15 +829,21 @@ export class IcsUtil { const lines: string[] = unfolded.split(/\r?\n/); const parsed: RemoteEvent[] = []; let current: RemoteEvent | null = null; + let curAttaches: AttachMeta[] = []; for (const line of lines) { const upper: string = line.toUpperCase(); if (upper.startsWith('BEGIN:VTODO')) { current = new RemoteEvent(); current.isTodo = true; + curAttaches = []; continue; } if (upper.startsWith('END:VTODO')) { if (current !== null && current.uid !== '') { + // 附件:把收集到的 AttachMeta 序列化成 JSON 存进 RemoteEvent + if (curAttaches.length > 0) { + current.attachments = IcsUtil.attachMetaToJson(curAttaches); + } parsed.push(current); } current = null; @@ -857,6 +901,20 @@ export class IcsUtil { if (value.toUpperCase() === 'COMPLETED') { current.completed = true; } + } else if (propName === 'RELATED-TO') { + // 父任务 UID。RELTYPE 参数默认 PARENT(本应用的语义); + // 其他客户端可能不带参数,值就是父 UID,直接取。 + current.parentUid = value; + } else if (propName === 'ATTACH') { + // 只收集内联 base64;URI 形式(ATTACH:http://...)暂不落库(服务器不支持托管, + // 且外部链接意义不大)。别人客户端写入的 URI 附件这里跳过、不报错。 + if (propPart.toUpperCase().includes('ENCODING=BASE64')) { + const meta = new AttachMeta(); + meta.base64 = value; + meta.mime = IcsUtil.paramValue(propPart, 'FMTTYPE'); + meta.name = IcsUtil.paramValue(propPart, 'X-FILENAME'); + curAttaches.push(meta); + } } } return parsed; @@ -893,7 +951,8 @@ export class IcsUtil { */ static buildTodo(uid: string, title: string, desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0, categories: string = '', - location: string = '', reminders: number[] = []): string { + location: string = '', reminders: number[] = [], parentUid: string = '', + attachments: string = ''): string { const now: string = IcsUtil.fmtUtcNow(); let out: string = 'BEGIN:VCALENDAR\r\n' + 'VERSION:2.0\r\n' + @@ -916,6 +975,14 @@ export class IcsUtil { // 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED out += 'STATUS:NEEDS-ACTION\r\n'; out += IcsUtil.todoExtras(priority, categories, location); + if (parentUid !== '') { + out += IcsUtil.parentLine(parentUid) + '\r\n'; + } + // 附件:内联 base64,可多条 + const metas: AttachMeta[] = IcsUtil.jsonToAttachMeta(attachments); + for (const m of metas) { + out += IcsUtil.attachLine(m) + '\r\n'; + } // VALARM:VTODO 的 TRIGGER 相对 DUE(RFC 5545 §3.8.6.3), // 所以复用现有 valarm() 生成的 -PT5M 就是"到期前 5 分钟" out += IcsUtil.valarmsPublic(reminders); @@ -934,6 +1001,86 @@ export class IcsUtil { * @param categories 逗号分隔的标签;空 = 不写 * @param location 地点文本;空 = 不写 */ + /** 从属性串里取参数值(如 propPart = "ATTACH;FMTTYPE=text/plain;X-FILENAME=报告.pdf")。 */ + private static paramValue(propPart: string, paramName: string): string { + const parts: string[] = propPart.split(';'); + for (let i = 1; i < parts.length; i++) { + const seg: string = parts[i]; + const eq: number = seg.indexOf('='); + if (eq > 0 && seg.substring(0, eq).toUpperCase() === paramName.toUpperCase()) { + return seg.substring(eq + 1).replace(/^"|"$/g, ''); + } + } + return ''; + } + + /** AttachMeta[] → JSON 字符串(存库用)。 */ + static attachMetaToJson(list: AttachMeta[]): string { + if (list.length === 0) { + return ''; + } + const arr: string[] = []; + for (const a of list) { + arr.push(JSON.stringify({ name: a.name, mime: a.mime, base64: a.base64 })); + } + return '[' + arr.join(',') + ']'; + } + + /** JSON 字符串 → AttachMeta[](解析库值用)。 */ + static jsonToAttachMeta(json: string): AttachMeta[] { + if (json === '') { + return []; + } + try { + const arr = JSON.parse(json) as Record[]; + const out: AttachMeta[] = []; + for (const o of arr) { + const m = new AttachMeta(); + m.name = o.name ?? ''; + m.mime = o.mime ?? 'application/octet-stream'; + m.base64 = o.base64 ?? ''; + if (m.base64 !== '') { + out.push(m); + } + } + return out; + } catch (err) { + return []; + } + } + + /** 生成一条内联 base64 的 ATTACH 行(已折行)。 */ + private static attachLine(meta: AttachMeta): string { + let head: string = 'ATTACH'; + if (meta.mime !== '') { + head += `;FMTTYPE=${meta.mime}`; + } + head += ';ENCODING=BASE64;VALUE=BINARY'; + if (meta.name !== '') { + // 逗号/分号属性值里要转义;文件名通常是中文或普通字符,稳妥起见转义 + head += `;X-FILENAME=${meta.name}`; + } + return IcsUtil.foldLine(head + ':' + meta.base64); + } + + /** 生成 RELATED-TO 行(父任务)。 */ + private static parentLine(parentUid: string): string { + return `RELATED-TO;RELTYPE=PARENT:${parentUid}`; + } + + /** + * RFC 5545 折行:本工程**有意不折**,直通返回。 + * + * 为什么不做折行: + * 1. 现有 SUMMARY/DESCRIPTION/LOCATION/CATEGORIES 一贯整行输出,未做折行; + * 2. 目标服务器(群晖)实测 3792 字符整行 ATTACH 也能 PUT→201、GET 原样保留; + * 3. 折行是 RFC 5545 的 SHOULD 而非 MUST;一致性优先; + * 4. 折行若按 75 字符硬切,会把 UTF-8 中文名(如「报告.pdf」)拦腰切断成乱码。 + */ + static foldLine(line: string): string { + return line; + } + static todoExtras(priority: number, categories: string, location: string): string { let out: string = ''; if (priority >= 1 && priority <= 9) { diff --git a/entry/src/main/ets/common/TodoService.ets b/entry/src/main/ets/common/TodoService.ets index aa408c5..68485ea 100644 --- a/entry/src/main/ets/common/TodoService.ets +++ b/entry/src/main/ets/common/TodoService.ets @@ -45,7 +45,9 @@ export class TodoService { */ static async updateTodo(context: common.Context, localId: number, title: string, desc: string, dueMs: number, priority: number = -1, categories: string = '', - location: string = '', reminders: number[] = []): Promise { + location: string = '', reminders: number[] = [], parentUid: string = '', + attachments: string = '', touchParent: boolean = false, + touchAttach: boolean = false): Promise { const row: LocalEvent | null = await EventDb.queryById(context, localId); if (row === null || row.kind !== 'todo') { LogUtil.write(`待办更新失败:行不存在或不是待办 id=${localId}`); @@ -76,12 +78,18 @@ export class TodoService { return false; } const updated: string = IcsUtil.rewriteTodoFields(original, row.uid, title, desc, dueMs, - priority, categories, location, reminders); + priority, categories, location, reminders, parentUid, attachments, + touchParent, touchAttach); const newEtag: string = await DavClient.putEvent(url, auth, updated); await EventDb.updateTodoFields(context, localId, title, desc, dueMs > 0 ? dueMs : 0, newEtag); // 提醒单独落库(updateTodoFields 只处理三个字段) await EventDb.setTodoReminders(context, localId, reminders); + // 子任务与附件:只有 touch 时才更新本地库(否则保留原值,避免覆盖) + if (touchParent || touchAttach) { + await EventDb.setTodoHierarchy(context, localId, parentUid, attachments, + touchParent, touchAttach); + } LogUtil.write(`待办更新成功 id=${localId}(标题已脱敏)`); return true; } catch (err) { @@ -107,7 +115,8 @@ export class TodoService { static async createTodo(context: common.Context, href: string, title: string, desc: string, dueMs: number, dueIsDateOnly: boolean, priority: number = 0, categories: string = '', location: string = '', - reminders: number[] = []): Promise { + reminders: number[] = [], parentUid: string = '', + attachments: string = ''): Promise { const t: string = title.trim(); if (t === '' || href === '') { LogUtil.write(`待办新建失败:标题或日历本为空`); @@ -138,7 +147,7 @@ export class TodoService { try { // 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿 const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly, - priority, categories, location, reminders); + priority, categories, location, reminders, parentUid, attachments); const etag: string = await DavClient.putEvent(url, auth, ics); // 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送) @@ -160,6 +169,8 @@ export class TodoService { e.location = location; e.reminders = reminders; e.reminder = reminders.length > 0 ? reminders[0] : 0; + e.parentUid = parentUid; + e.attachments = attachments; e.dirty = false; // 已经推成功了,无需再推 e.deleted = false; const newId: number = await EventDb.insertLocal(context, e); @@ -344,6 +355,32 @@ export class TodoService { } } + /** + * 级联完成:把 parentUid 指向某父任务的所有**未完成**子任务全部置完成。 + * + * 用于父任务勾选完成时。只完成未完成的(已完成的跳过,幂等)。 + * 逐条调用 setCompleted(它本身幂等 + 会 GET/PUT 服务器 + 更新本地)。 + */ + static async completeChildren(context: common.Context, parentUid: string): Promise { + if (parentUid === '') { + return 0; + } + const rows: LocalEvent[] = await EventDb.queryTodos(context); + let done: number = 0; + for (const r of rows) { + if (r.parentUid === parentUid && !r.completed) { + const ok: boolean = await TodoService.setCompleted(context, r.id, true); + if (ok) { + done++; + } + } + } + if (done > 0) { + LogUtil.write(`级联完成 ${done} 个子任务(父 ${parentUid})`); + } + return done; + } + 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 d274758..f6bcdf1 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -3,6 +3,9 @@ // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" import { curves, display, router, window } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; +import { picker } from '@kit.CoreFileKit'; +import { fileIo as fs } from '@kit.CoreFileKit'; +import { util } from '@kit.ArkTS'; import { geoLocationManager } from '@kit.LocationKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; @@ -17,7 +20,7 @@ import { TipsPanel } from '../common/TipsPanel'; import { DocViewer } from '../common/DocViewer'; import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb'; import { RruleUtil } from '../common/RruleUtil'; -import { IcsUtil } from '../common/IcsUtil'; +import { IcsUtil, AttachMeta } from '../common/IcsUtil'; import { DavClient, RemoteItem } from '../common/DavClient'; import { SystemCalendarImport } from '../common/SystemCalendarImport'; import { SystemCalendarMirror } from '../common/SystemCalendarMirror'; @@ -121,6 +124,14 @@ struct Index { @State todoEditLocation: string = ''; /** 提醒:提前分钟数(多选),空 = 不提醒 */ @State todoEditReminders: number[] = []; + /** 当前编辑待办的父任务 UID(空 = 顶层任务) */ + @State todoEditParentUid: string = ''; + /** 附件 JSON(格式与数据层一致:[{name,mime,size,base64}]) */ + @State todoEditAttachments: string = ''; + /** 父任务选择弹层 */ + @State todoParentPickShow: boolean = false; + /** 附件选择中(防连点,文件选择器打开期间) */ + @State todoAttachBusy: boolean = false; @State todoEditHasDue: boolean = false; @State todoEditPickShow: boolean = false; // 截止时间选择器 @State todoEditPickDate: Date = new Date(); @@ -1271,6 +1282,11 @@ struct Index { this.todoEditorOverlay() } + // ⭐ 父任务选择弹层(子任务支持) + if (this.todoParentPickShow) { + this.todoParentPickerOverlay() + } + // 功能引导:整页覆盖层,放根 Stack 最后一层(盖住主界面与悬浮按钮) if (this.showTips) { this.tipsOverlay() @@ -2874,6 +2890,95 @@ struct Index { * 再挂一个会被覆盖(EventEditPage 顶部也记过这个坑)。 * 覆盖层跟 tipsOverlay / consentGate 是同一套路,已验证可用。 */ + /** + * 父任务选择覆盖层(子任务支持)。 + * 候选 = 未完成的顶层任务(不能选自己或自己的后代,防成环)。 + */ + @Builder + todoParentPickerOverlay() { + Stack({ alignContent: Alignment.Bottom }) { + Column() + .width('100%') + .height('100%') + .backgroundColor('#66000000') + .onClick(() => { + this.todoParentPickShow = false; + }) + + Column({ space: 10 }) { + Row({ space: 8 }) { + Text('选择父任务') + .fontSize(17) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('无父任务') + .fontSize(14) + .fontColor($r('app.color.brand_text')) + .onClick(() => { + this.todoEditParentUid = ''; + this.todoParentPickShow = false; + }) + } + .width('100%') + + if (this.todoParentCandidates().length === 0) { + Text('没有可选的顶层任务') + .fontSize(13) + .fontColor($r('app.color.text_hint')) + .padding(20) + } else { + List({ space: 6 }) { + ForEach(this.todoParentCandidates(), (c: DisplayEvent) => { + ListItem() { + Row({ space: 6 }) { + Circle({ width: 8, height: 8 }).fill(c.color) + Text(c.title === '' ? '(无标题待办)' : c.title) + .fontSize(14) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + if (this.todoEditParentUid === c.uid) { + Text('✓') + .fontSize(14) + .fontColor($r('app.color.brand_text')) + } + } + .width('100%') + .padding(10) + } + .width('100%') + .onClick(() => { + this.todoEditParentUid = c.uid; + this.todoParentPickShow = false; + }) + }, (c: DisplayEvent) => `pc_${c.uid}`) + } + .width('100%') + .constraintSize({ maxHeight: 340 }) + } + + Button('取消') + .width('100%') + .height(44) + .borderRadius(12) + .fontSize(15) + .fontColor($r('app.color.text_primary')) + .backgroundColor($r('app.color.chip_off_bg')) + .onClick(() => { + this.todoParentPickShow = false; + }) + } + .width('100%') + .padding(20) + .backgroundColor($r('app.color.card_bg')) + .borderRadius({ topLeft: 16, topRight: 16 }) + } + .width('100%') + .height('100%') + } + @Builder todoEditorOverlay() { Stack({ alignContent: Alignment.Bottom }) { @@ -3136,6 +3241,84 @@ struct Index { } .width('100%') + // ⭐ 父任务(子任务支持,对应 RELATED-TO) + Row({ space: 8 }) { + Text('父任务') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + .width(52) + Blank() + Text(this.parentName(this.todoEditParentUid)) + .fontSize(14) + .fontColor(this.todoEditParentUid === '' + ? $r('app.color.text_hint') : $r('app.color.text_primary')) + if (this.todoEditParentUid !== '') { + Text('清除') + .fontSize(13) + .fontColor($r('app.color.brand_text')) + .onClick(() => { + this.todoEditParentUid = ''; + }) + } else { + Text('选择') + .fontSize(13) + .fontColor($r('app.color.brand_text')) + .onClick(() => { + this.openTodoParentPicker(); + }) + } + } + .width('100%') + .padding({ top: 2, bottom: 2 }) + .onClick(() => { + this.openTodoParentPicker(); + }) + + // ⭐ 附件(内联 base64,随待办同步) + Column({ space: 6 }) { + Row({ space: 8 }) { + Text('附件') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + .width(52) + if (this.attachList().length < 3) { + Text('添加文件') + .fontSize(13) + .fontColor($r('app.color.brand_text')) + .onClick(() => { + const ctx = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + this.pickAttachment(ctx); + } + }) + } + Blank() + } + .width('100%') + + ForEach(this.attachList(), (m: AttachMeta, idx: number) => { + Row({ space: 8 }) { + Text(m.name) + .fontSize(13) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text('✕') + .fontSize(13) + .fontColor($r('app.color.text_hint')) + .onClick(() => { + this.removeAttach(idx); + }) + } + .width('100%') + .padding({ left: 8, right: 8, top: 6, bottom: 6 }) + .borderRadius(8) + .backgroundColor($r('app.color.chip_off_bg')) + }, (m: AttachMeta, idx: number) => `att_${idx}_${m.name}`) + } + .width('100%') + if (this.todoEditRecur !== '' && !this.todoEditHasDue) { Text('提示:循环任务需要有截止时间,才能算出下一次') .fontSize(11) @@ -3271,6 +3454,7 @@ struct Index { // · 其余部分 → 打开编辑弹框 // 旧版把 onClick 挂在整行上,点标题、点截止时间、点空白都会改完成状态, // 用户反馈"点任何地方都是完成",是明确的交互缺陷。 + // 子任务缩进:顶层不缩,子任务缩 24vp Row({ space: 10 }) { // 勾选框(颜色跟随日历本)—— **只有这块触发完成切换** Row() { @@ -3297,13 +3481,13 @@ struct Index { }) Column({ space: 3 }) { - Text(t.title === '' ? '(无标题待办)' : t.title) + Text((t.parentUid !== '' ? '∟ ' : '') + (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(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.todoSubtitle(t)) + Text(this.todoSubtitle(t) + this.subtaskProgress(t)) .fontSize(12) .fontColor($r('app.color.text_secondary')) .maxLines(1) @@ -3322,7 +3506,9 @@ struct Index { }) } .width('100%') - .padding(10) + .padding(t.parentUid !== '' + ? { left: 34, right: 10, top: 10, bottom: 10 } + : { left: 10, right: 10, top: 10, bottom: 10 }) .borderRadius(12) .backgroundColor($r('app.color.card_bg')) .border({ width: 1, color: $r('app.color.shadow_color') }) @@ -3458,6 +3644,8 @@ struct Index { this.todoEditCategories = ''; this.todoEditLocation = ''; this.todoEditReminders = []; + this.todoEditParentUid = ''; + this.todoEditAttachments = ''; this.todoEditPickShow = false; this.todoEditShow = true; } @@ -3478,6 +3666,9 @@ struct Index { this.todoEditCategories = t.categories; this.todoEditLocation = t.location; this.todoEditReminders = t.reminders.slice(); + // 子任务与附件 + this.todoEditParentUid = t.parentUid; + this.todoEditAttachments = t.attachments; // 默认值给"明天 09:00",与新建日程的口径一致(非今天用 9 点) const base: Date = new Date(); this.todoEditPickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1); @@ -3526,11 +3717,14 @@ struct Index { } this.todoBusy = true; try { + // 父任务/附件:编辑页显示什么就保存什么(touch 恒 true, + // 值来自弹层状态,用户点保存的意图就是让它们生效) const ok: boolean = await TodoService.updateTodo(ctx, this.todoEditId, this.todoEditTitle, this.todoEditDesc, this.todoEditHasDue ? this.todoEditDueMs : 0, this.todoEditPriority, this.todoEditCategories, this.todoEditLocation, - this.todoEditReminders); + this.todoEditReminders, this.todoEditParentUid, this.todoEditAttachments, + true, true); if (ok) { // ⭐ 循环规则单独写:它与"改内容"是两回事, // 而且要先有截止时间才有意义(没有 DUE 的循环任务无法算下一次) @@ -3580,6 +3774,210 @@ struct Index { } } + /** + * 选择附件:DocumentViewPicker → 读二进制 → base64 → 存入 todoEditAttachments。 + * + * 限制:最多 3 个、单文件 ≤ 5MB。内联 base64 会让 .ics 膨胀, + * 限制是为了防爆服务器与同步性能。 + */ + private async pickAttachment(ctx: common.Context): Promise { + if (this.todoAttachBusy) { + return; + } + this.todoAttachBusy = true; + try { + const pickerv = new picker.DocumentViewPicker(ctx); + const uris: string[] = await pickerv.select({ + maxSelectNumber: 3 + }); + if (uris.length === 0) { + return; + } + // 已有附件数 + 新选数不得超过 3 + const existing: AttachMeta[] = IcsUtil.jsonToAttachMeta(this.todoEditAttachments); + if (existing.length + uris.length > 3) { + this.getUIContext().getPromptAction().showToast({ message: '最多 3 个附件' }); + uris.splice(0); // 丢弃,不超过限制时已读的部分也不该要 + return; + } + const merged: AttachMeta[] = existing.slice(); + for (const uri of uris) { + const meta: AttachMeta | null = this.readAttach(uri); + if (meta === null) { + continue; + } + if (meta.base64.length > 5 * 1024 * 1024) { + this.getUIContext().getPromptAction().showToast({ message: '单文件不超过 5MB' }); + continue; + } + merged.push(meta); + } + this.todoEditAttachments = IcsUtil.attachMetaToJson(merged); + if (merged.length > existing.length) { + this.getUIContext().getPromptAction().showToast({ message: `已添加 ${merged.length - existing.length} 个附件` }); + } + } catch (err) { + // 用户取消选择也会走这里(返回空/抛错),不提示错误 + LogUtil.write(`选择附件取消或失败: ${(err as BusinessError).message ?? ''}`); + } finally { + this.todoAttachBusy = false; + } + } + + /** 读一个文件 URI → AttachMeta(文件名/mime/size/base64)。失败返回 null。 */ + private readAttach(uri: string): AttachMeta | null { + try { + const file = fs.openSync(uri, fs.OpenMode.READ_ONLY); + const stat = fs.statSync(file.fd); + const size: number = stat.size; + if (size <= 0 || size > 5 * 1024 * 1024) { + fs.closeSync(file.fd); + return null; + } + // 最多 5MB,一次性读完 + const buf: ArrayBuffer = new ArrayBuffer(size); + const readLen: number = fs.readSync(file.fd, buf); + fs.closeSync(file.fd); + if (readLen <= 0) { + return null; + } + // ArrayBuffer → base64 + const u8 = new Uint8Array(buf); + let bin: string = ''; + for (let i = 0; i < u8.length; i++) { + bin += String.fromCharCode(u8[i]); + } + const base64: string = new util.Base64Helper().encodeToStringSync( + new Uint8Array(buf)); + // 文件名:从 URI 末尾解析(去掉 querystring),拿不到就占位 + let name: string = uri.substring(uri.lastIndexOf('/') + 1); + const q: number = name.indexOf('?'); + if (q >= 0) { + name = name.substring(0, q); + } + if (name === '') { + name = '附件'; + } + // mime:按扩展名粗略推断,其余 application/octet-stream + const m = new AttachMeta(); + m.name = name; + m.mime = this.mimeOfName(name); + m.base64 = base64; + return m; + } catch (err) { + LogUtil.write(`读附件失败: ${(err as BusinessError).message ?? ''}`); + return null; + } + } + + /** 按扩展名推断 mime,能覆盖常见文档/图片即可。 */ + private mimeOfName(name: string): string { + const lower: string = name.toLowerCase(); + if (lower.endsWith('.pdf')) { return 'application/pdf'; } + if (lower.endsWith('.png')) { return 'image/png'; } + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { return 'image/jpeg'; } + if (lower.endsWith('.gif')) { return 'image/gif'; } + if (lower.endsWith('.txt')) { return 'text/plain'; } + if (lower.endsWith('.csv')) { return 'text/csv'; } + if (lower.endsWith('.zip')) { return 'application/zip'; } + if (lower.endsWith('.doc') || lower.endsWith('.docx')) { + return 'application/msword'; + } + if (lower.endsWith('.xls') || lower.endsWith('.xlsx')) { + return 'application/vnd.ms-excel'; + } + return 'application/octet-stream'; + } + + /** 父任务的子任务进度文案「 N/M」,无子任务返回空串。 */ + private subtaskProgress(t: DisplayEvent): string { + const children: DisplayEvent[] = this.todos.filter( + (x: DisplayEvent): boolean => x.parentUid === t.uid && x.parentUid !== ''); + if (children.length === 0) { + return ''; + } + const done: number = children.filter((x: DisplayEvent): boolean => x.completed).length; + return ` · ${done}/${children.length}`; + } + + /** 是否有子任务(按父 uid 找)。 */ + private hasChildren(uid: string): boolean { + return this.todos.some((x: DisplayEvent): boolean => x.parentUid === uid); + } + + /** 父任务显示名:按 uid 在 todos 里找;找不到显示「无」。 */ + private parentName(uid: string): string { + if (uid === '') { + return '无'; + } + for (const t of this.todos) { + if (t.uid === uid) { + return t.title === '' ? '(无标题)' : t.title; + } + } + return '(已不存在)'; + } + + /** 解析附件 JSON → AttachMeta[]。 */ + private attachList(): AttachMeta[] { + return IcsUtil.jsonToAttachMeta(this.todoEditAttachments); + } + + /** 删除第 idx 个附件,回写 JSON。 */ + private removeAttach(idx: number): void { + const list: AttachMeta[] = this.attachList(); + if (idx < 0 || idx >= list.length) { + return; + } + list.splice(idx, 1); + this.todoEditAttachments = IcsUtil.attachMetaToJson(list); + } + + /** 打开父任务选择弹层。候选 = 未完成的顶层任务,且不能是自己或自己的后代。 */ + private openTodoParentPicker(): void { + this.todoParentPickShow = true; + } + + /** 当前编辑待办的 uid(自己) */ + private todoEditUid(): string { + for (const t of this.todos) { + if (t.id === this.todoEditId) { + return t.uid; + } + } + return ''; + } + + /** 父任务候选(未完成、顶层、非自身/后代)。 */ + private todoParentCandidates(): DisplayEvent[] { + const selfUid: string = this.todoEditUid(); + // 收集"自己"及其所有后代的 uid,选父时排除(防成环) + const selfAndDesc: Set = new Set(); + if (selfUid !== '') { + selfAndDesc.add(selfUid); + const queue: string[] = [selfUid]; + let guard = 0; + while (queue.length > 0 && guard < 200) { + const cur: string = queue.shift() as string; + for (const t of this.todos) { + if (t.parentUid === cur && !selfAndDesc.has(t.uid)) { + selfAndDesc.add(t.uid); + queue.push(t.uid); + } + } + guard++; + } + } + const out: DisplayEvent[] = []; + for (const t of this.todos) { + if (t.completed) { continue; } + if (t.parentUid !== '') { continue; } // 只选顶层任务做父 + if (selfAndDesc.has(t.uid)) { continue; } // 不能选自己或后代 + out.push(t); + } + return out; + } + /** 保存新建的待办 */ private async saveNewTodo(ctx: common.Context): Promise { const title: string = this.todoEditTitle.trim(); @@ -3599,7 +3997,8 @@ struct Index { const dateOnly: boolean = dueMs > 0 && this.todoEditDueMs % 86400000 === 0; const newId: number = await TodoService.createTodo(ctx, href, title, this.todoEditDesc, dueMs, dateOnly, - this.todoEditPriority, this.todoEditCategories, this.todoEditLocation); + this.todoEditPriority, this.todoEditCategories, this.todoEditLocation, + this.todoEditReminders, this.todoEditParentUid, this.todoEditAttachments); if (newId > 0) { this.todoEditShow = false; this.todoCreating = false; @@ -3716,6 +4115,10 @@ struct Index { ok = nextDue > 0; } else { ok = await TodoService.setCompleted(ctx, t.id, !t.completed); + // ⭐ 父任务级联完成子任务(勾父任务→所有子任务一并完成) + if (ok && !t.completed && this.hasChildren(t.uid)) { + await TodoService.completeChildren(ctx, t.uid); + } } if (ok) { if (isRecurring && !t.completed) {