feat(待办): +号职责感知 + 新建待办(所有可写日历本铺开选择)

在「待办」视图下点 + 新建待办,其余视图仍新建日程。
新建时把全部可写 CalDAV 日历本铺开供选择,交互与新建日程一致。

## 原状
`TodoService` 只有 改/删/设重复/打勾,**没有新建方法**;
`saveTodoEdit` 开头 `if (... || this.todoEditId <= 0) return;` 直接挡掉新建。
+号固定 `addEvent()`,在待办页点它却跳到日程编辑页,与当前语境不符。

## 改动

### IcsUtil.buildTodo(新增)
从零构造 VTODO 资源。不能复用 `rewriteTodoCompletion`——后者是在服务器原文上
打补丁(保留未知字段),前提是原文已存在;新建时没有原文。

字段取舍:
- UID / DTSTAMP / SUMMARY 必写
- DESCRIPTION 为空则不写
- DUE 可选;只有日期时写 `DUE;VALUE=DATE`
- STATUS 固定 NEEDS-ACTION(完成态由 setCompleted 切换)
- 不写 PRIORITY,避免凭空替用户设优先级

⚠️ 时间格式必须与现有待办写入保持一致:本工程 `rewriteTodoSchedule` 用的是
`DUE:` + `fmtIcsTime(dueMs, false)`,即**浮动本地时间、不带 Z**。
若这里写成 UTC,同一字段出现两种格式,服务器上会显示成早 8 小时的待办。

### TodoService.createTodo(新增)
**先 PUT 服务器、成功后才落库**——与 updateTodo 的顺序相反。
更新时本地已有行可回滚;新建若先落库再 PUT 失败,会留下"本地有、服务器没有"
的孤儿待办,用户看着它存在却永远同步不出去。

同时校验:标题非空、href 非空、账号可定位、日历本可写(只读本提前拦下,避免 403)。

### Index.ets
- `todoCreating` 状态区分新建/编辑
- `openTodoCreator()`:收集全部可写 dav 本;默认选中 Inbox(名字含
  inbox/收件箱 者优先),找不到用第一个 —— 少点一步但仍可改
- `saveNewTodo()`:走 createTodo
- +号分流:`mode === 'todo'` → 新建待办,否则新建日程
- 弹层标题「新建待办」/按钮「创建」,新建时隐藏"删除"入口
- 日历本区:新建时 Flex 铺开色块胶囊(48% 宽,选中染本色调),
  编辑时保持原来只读显示

`CalSource` 没有 href 字段,`createTodo` 需要的 URL 由 calKey(`<accountId>_<index>`)
反解出账号与索引后从 `account.calendarHrefs[index]` 取,映射存在 `todoBookHrefs`。

## 真机验证(5NC0225B27004718)

待办视图点 + → 弹层标题「新建待办」、8 个日历本全部铺开、
按钮显示「创建」。填标题后点创建:

```
PUT .../synccalendartest/home_todo/todo-1793717941310-bl6ffh.ics → 201
待办新建成功 id=749 → todo-1793717941310-bl6ffh.ics
```

- 默认落到 `home_todo`(Inbox)✅ Inbox 智能匹配生效
- 服务器端复查该资源返回 **200** ✅ 真的创建了
- App 列表即时刷新:未完成 5→6 项,出现「群晖:Inbox(1)」✅

测试数据已删除(DELETE → 204),未在服务器留残留。

## 编译
`BUILD SUCCESSFUL`,本次改动无新增警告。
This commit is contained in:
2026-11-03 23:00:07 +08:00
parent 09e2373965
commit ca97ec7fcd
14 changed files with 1862 additions and 22 deletions
@@ -0,0 +1,198 @@
// entry/src/main/ets/pages/widget/TodoWidget6x4.ets
// 6x4 待办服务卡片:4x4 的横向扩展。左侧 = 统计概览,右侧 = 可滚动的待办列表(更多条目)。
// 每条待办左侧圆形勾选框,点击即完成并推送回服务器。
let storageTodo6x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem6x4 {
id: number = 0;
title: string = '';
dueText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo6x4)
@Component
struct TodoWidget6x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
private parseTodos(): TodoItem6x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem6x4[];
} catch (err) {
return [];
}
}
@Builder
addButton() {
Button() {
Text('+')
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
}
.width(30)
.height(30)
.borderRadius(15)
.padding(0)
.backgroundColor('#6A4C93')
.onClick(() => {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'addTodo' }
});
})
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
@Builder
checkbox(id: number) {
// ⚠️ 不用 Circle().stroke() —— 那是 SDK 26 的 API,本工程 compatibleSdkVersion 为
// 6.1.1(24),真机上 stroke 不生效会导致圆圈不可见。
// 改用 border + 50% 圆角,视觉一致且从 API 7 起就支持。
Column()
.width(20)
.height(20)
.borderRadius(10)
.border({ width: 1.5, color: '#B0B0B0' })
.backgroundColor(Color.Transparent)
.width(20)
.height(20)
.onClick(() => this.checkTodo(id))
}
/** 单条待办(宽版):勾选框 + 色条 + 标题 + 截止时间列 + 日历本名列 */
@Builder
todoRow(item: TodoItem6x4) {
Row({ space: 8 }) {
this.checkbox(item.id)
Column()
.width(3)
.height(34)
.borderRadius(2)
.backgroundColor(item.color)
Text(item.title)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(item.dueText)
.fontSize(11)
.fontColor(item.dueText.indexOf('逾期') >= 0 ? '#FF3B30' : '#8A8A8A')
.maxLines(1)
.width(88)
.textAlign(TextAlign.End)
Text(item.calName)
.fontSize(10)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width(80)
.textAlign(TextAlign.End)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 4, right: 12, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F5F7FA')
}
@Builder
headerBar() {
Row({ space: 8 }) {
Row({ space: 6 }) {
Text('待办')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Text(`${this.todoCount}`)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor('#6A4C93')
.borderRadius(9)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
Text(this.todoTodayCount > 0
? `${this.dateMd} ${this.weekday} · 今日到期 ${this.todoTodayCount} 项`
: `${this.dateMd} ${this.weekday} · 今日无到期`)
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
.layoutWeight(1)
this.addButton()
}
.width('100%')
}
@Builder
emptyState() {
Column({ space: 8 }) {
Text('✓')
.fontSize(30)
.fontColor('#C7C7CC')
Text(this.todoCount === 0 ? '所有待办都完成了' : '暂无待办')
.fontSize(13)
.fontColor('#8A8A8A')
Text('点右上角 + 新建')
.fontSize(10)
.fontColor('#B0B0B0')
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Column({ space: 8 }) {
this.headerBar()
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
if (this.parseTodos().length === 0) {
this.emptyState()
} else {
List({ space: 4 }) {
ForEach(this.parseTodos(), (item: TodoItem6x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem6x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
}