feat(待办): 子任务(RELATED-TO)+ 附件(内联 base64 ATTACH)(#43 #44)

补齐待办模块最后两项协议能力。

## #43 子任务(父子结构)

### 协议
`RELATED-TO;RELTYPE=PARENT:<父uid>`,值用**父任务 UID**(不是本地 id——
父子关系是服务器资源属性,跨设备同步时 UID 才稳定)。

### 数据层
- `LocalEvent` / `RemoteEvent` / `DisplayEvent` 加 `parentUid`
- 表加 `parent_uid` 列,`toBucket`/`fromRow`/`applyRemote` 双向映射

### UI
- 列表:子任务缩进 34vp + 标题前「∟」连线符
- 父任务副行追加子任务进度「N/M」
- 编辑弹层加「父任务」行 → 覆盖层选择(候选 = 未完成顶层任务,
  排除自己及后代,防成环)
- **父任务勾选完成 → 级联完成所有未完成子任务**(用户选定的语义)

## #44 附件(内联 base64)

### 查证 + 实测(已更新 #44 工单)
- iCalendar RFC 5545 原生支持 ATTACH,两种形式:URI / **内联 base64**
- RFC 8607 托管附件需服务器特殊支持(群晖没有)
- **实测群晖**:PUT 带 `ATTACH;...;ENCODING=BASE64;VALUE=BINARY:<b64>` → 201,
  GET 回原文原样保留(4558 字符整行、含中文文件名「报告.pdf」都不丢)

### 实现(按标准协议,直接上传同步)
- 附件 = VTODO 的 ATTACH 行(内联 base64),随 .ics 一起 PUT 同步
- 不需要自己传文件贴链接
- `buildTodo` / `rewriteTodoFields` 读写 ATTACH;`parseTodos` 解析回填
- 数据层 `attachments` 存 JSON([{name,mime,base64}]),base64 直接存库零转换
- UI:弹层加「附件」区,文件选择器选文件 → 读二进制 → base64 编码 → 随待办保存

### 限制
最多 3 个附件、单文件 ≤5MB(内联 base64 会让 .ics 膨胀,限制防爆)。

### 折行决策
**不折行**。原因:① 工程现有 SUMMARY/DESCRIPTION/LOCATION 一贯整行不折;
② 群晖实测 4558 字符整行也能存;③ RFC 折行是 SHOULD 非 MUST,一致性优先;
④ 折行若按 75 octets 硬切会把 UTF-8 中文名(「报告.pdf」)拦腰切断成乱码。

## 兼容性
`rewriteTodoFields` 新增 `touchParent` / `touchAttach` 哨兵:
编辑只改标题时传 false,服务器上**其他客户端写的 RELATED-TO / ATTACH 原样保留**;
只有用户实际编辑了父任务/附件才 touch=true 重写。

## 验证
- 编译 `BUILD SUCCESSFUL`
- 协议往返(Python 等价复刻):生成→解析 无损,附件 base64 还原正确
- 服务器端到端:PUT(201) + GET 原样返回 RELATED-TO + 中文文件名 ATTACH

## 未验证
真机 UI(设备离线)。需人工验:子任务缩进、级联完成、文件选择器上传、
附件随待办同步到另一客户端。
This commit is contained in:
2026-11-04 17:19:43 +08:00
parent b623a876dd
commit d9db8b476a
5 changed files with 667 additions and 13 deletions
+409 -6
View File
@@ -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<void> {
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<string> = new Set<string>();
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<void> {
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) {