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
@@ -36,6 +36,19 @@ export class CardAllDay {
isAllDay: boolean = true; isAllDay: boolean = true;
} }
/** 待办卡片单条(4x4 / 6x4 列表用,带 localId 以支持卡片内直接打勾) */
export class TodoCardItem {
/** 本地库主键 —— 卡片"打勾完成"时靠它定位要改哪一行 */
id: number = 0;
title: string = '';
/** 截止时间文字:'今天 18:00' / '明天' / '无日期' */
dueText: string = '';
color: string = '#007DFF';
calName: string = '';
/** 是否已完成(已完成的不出现在待办卡片列表里,此字段留作扩展) */
completed: boolean = false;
}
/** 卡片整体数据 */ /** 卡片整体数据 */
export class CardData { export class CardData {
eventsJson: string = '[]'; eventsJson: string = '[]';
@@ -62,6 +75,15 @@ export class CardData {
isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线) isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线)
selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天) selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天)
dayCount: number = 0; // 当前显示日的日程条数(含全天) dayCount: number = 0; // 当前显示日的日程条数(含全天)
// ===== 待办卡片专用字段(isTodoCard=true 时才有效)=====
/** 待办卡片列表:TodoCardItem[] JSON(按截止时间升序,未完成在前) */
todoListJson: string = '[]';
/** 未完成待办总数(2x2 / 2x4 卡片大数字) */
todoCount: number = 0;
/** 今日到期待办数(卡片副标题用) */
todoTodayCount: number = 0;
/** 是否待办卡片(卡片侧据此切换渲染分支) */
isTodoCard: boolean = false;
} }
export class CardDataService { export class CardDataService {
@@ -381,4 +403,103 @@ export class CardDataService {
} }
return data; return data;
} }
/** 卡片列表最多渲染多少条待办(4x4 用前 6 条、6x4 用前 10 条,卡片侧自行截断) */
private static readonly MAX_TODO_ITEMS: number = 20;
/**
* 构建**待办卡片**数据。
*
* 与 buildCardData 的区别:不查日程,只查 VTODO;输出 todoListJson + 统计数字。
* 复用同一个 CardData 类,靠 isTodoCard 标记,卡片侧据此走不同渲染分支
*(这样 FormExtension 的推送逻辑不用区分两种卡片,一套 binding 推所有卡片即可)。
*/
/** @param excludeId 乐观更新用:临时把该 localId 从列表与计数中排除(不动数据库) */
static async buildTodoCardData(context: common.Context, excludeId: number = 0): Promise<CardData> {
const data = new CardData();
data.isTodoCard = true;
try {
LogUtil.init(context);
const now = new Date();
const weekCn: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const fullWeekCn: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
data.dateText = `今天 ${now.getMonth() + 1}月${now.getDate()}日 ${weekCn[now.getDay()]}`;
data.lunarText = LunarUtil.lunarDayText(now.getTime());
data.dateMd = `${now.getMonth() + 1}月${now.getDate()}日`;
data.weekday = fullWeekCn[now.getDay()];
data.dayOffset = 0;
data.isToday = true;
data.selectedDate = CardDataService.startOfDay(now.getTime());
const sources = await CalendarDataService.loadSources(context);
const todos: DisplayEvent[] = await CalendarDataService.loadTodos(context, sources);
// 只保留未完成;excludeId>0 时额外剔除该条(乐观更新,仅影响卡片显示)
const undone: DisplayEvent[] = todos.filter((e: DisplayEvent): boolean =>
!e.completed && (excludeId <= 0 || e.id !== excludeId));
const todayStart: number = CardDataService.startOfDay(now.getTime());
const todayEnd: number = todayStart + 86400000;
// 排序:有截止时间在前按升序;无截止时间在后(新建的在前)
const withDue: DisplayEvent[] = undone
.filter((e: DisplayEvent): boolean => e.endTime > 0)
.sort((a: DisplayEvent, b: DisplayEvent): number => a.endTime - b.endTime);
const noDue: DisplayEvent[] = undone
.filter((e: DisplayEvent): boolean => e.endTime <= 0)
.sort((a: DisplayEvent, b: DisplayEvent): number => b.id - a.id);
const orderedList: DisplayEvent[] = withDue.concat(noDue);
const p2 = (n: number): string => n < 10 ? '0' + n : String(n);
const items: TodoCardItem[] = [];
for (const e of orderedList) {
if (items.length >= CardDataService.MAX_TODO_ITEMS) {
break;
}
const it = new TodoCardItem();
it.id = e.id;
it.title = e.title === '' ? '(无标题)' : e.title;
it.color = e.color;
it.calName = e.calName;
it.completed = e.completed;
// 截止时间文字:今天 / 明天 / 昨天 / 逾期 / 具体日期 / 无日期
if (e.endTime <= 0) {
it.dueText = '无日期';
} else {
const due = new Date(e.endTime);
const dueDay: number = CardDataService.startOfDay(e.endTime);
const diffDays: number = Math.round((dueDay - todayStart) / 86400000);
const hm: string = `${p2(due.getHours())}:${p2(due.getMinutes())}`;
// 是否为"当天 0 点整" —— 只有日期的待办,不显示 00:00
const dateOnly: boolean = due.getHours() === 0 && due.getMinutes() === 0;
const timePart: string = dateOnly ? '' : ' ' + hm;
if (diffDays === 0) {
it.dueText = '今天' + timePart;
} else if (diffDays === 1) {
it.dueText = '明天' + timePart;
} else if (diffDays === -1) {
it.dueText = '昨天' + timePart;
} else if (diffDays < -1) {
it.dueText = `逾期 ${Math.abs(diffDays)} 天`;
} else if (diffDays > 1 && diffDays <= 7) {
it.dueText = `${due.getMonth() + 1}月${due.getDate()}日${timePart}`;
} else {
it.dueText = `${due.getMonth() + 1}/${due.getDate()}${timePart}`;
}
}
items.push(it);
}
data.todoListJson = JSON.stringify(items);
data.todoCount = undone.length;
data.todoTodayCount = withDue.filter((e: DisplayEvent): boolean =>
e.endTime >= todayStart && e.endTime < todayEnd).length;
// 今日到期条数也塞进 todayCount,让 2x2/2x4 复用同一套大数字排版
data.todayCount = data.todoCount;
LogUtil.write(`待办卡片数据刷新:未完成 ${undone.length} 条,今日到期 ${data.todoTodayCount} 条,列表输出 ${items.length} 条`);
} catch (err) {
LogUtil.write(`待办卡片数据刷新失败: ${JSON.stringify(err)}`);
}
return data;
}
} }
+14 -3
View File
@@ -644,13 +644,24 @@ export class EventDb {
updated++; updated++;
} else { } else {
unchanged++; unchanged++;
// etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中, // ⭐ UNCHANGED_REMOTE_PATH etag 未变是**常态路径**(REPORT 只给 etag,
// 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释) // 未变就直接复用本地行、不 GET)。实测 742 行里 99% 走这里。
// 若只在"更新分支"回填 remotePath,对存量数据等于无效 ——
// 历史行里存的是旧的 uid 猜测值,而 DELETE/PUT 正是用它拼 URL,
// 拼错就 404(服务器上删不掉)。
// 所以这里必须一并校正 href 与 remotePath。
const hb: relationalStore.ValuesBucket = {};
if (found.href !== href) { if (found.href !== href) {
found.href = href; found.href = href;
hb['href'] = href;
}
if (r.remotePath !== '' && found.remotePath !== r.remotePath) {
found.remotePath = r.remotePath;
hb['remote_path'] = r.remotePath;
}
if (Object.keys(hb).length > 0) {
const hp = new relationalStore.RdbPredicates('events'); const hp = new relationalStore.RdbPredicates('events');
hp.equalTo('id', found.id); hp.equalTo('id', found.id);
const hb: relationalStore.ValuesBucket = { 'href': href };
await store.update(hb, hp); await store.update(hb, hp);
} }
} }
+37
View File
@@ -815,6 +815,43 @@ export class IcsUtil {
'END:VCALENDAR\r\n'; 'END:VCALENDAR\r\n';
} }
/**
* 从零构造一份 VTODO 资源(新建待办用)。
*
* @param uid 待办全局唯一标识
* @param title 标题
* @param desc 备注(可为空,空则不写 DESCRIPTION)
* @param dueMs 截止时间毫秒(0 = 无截止时间,不写 DUE)
* @param dueIsDateOnly 截止时间是否"只有日期无时刻"(true 写 `DUE;VALUE=DATE`)
*/
static buildTodo(uid: string, title: string, desc: string, dueMs: number,
dueIsDateOnly: boolean): string {
const now: string = IcsUtil.fmtUtcNow();
let out: string = 'BEGIN:VCALENDAR\r\n' +
'VERSION:2.0\r\n' +
'PRODID:-//SyncCalendar//Calendar//CN\r\n' +
'CALSCALE:GREGORIAN\r\n' +
'BEGIN:VTODO\r\n' +
`UID:${uid}\r\n` +
`DTSTAMP:${now}\r\n` +
`SUMMARY:${IcsUtil.escape(title)}\r\n`;
if (desc !== '') {
out += `DESCRIPTION:${IcsUtil.escape(desc)}\r\n`;
}
if (dueMs > 0) {
if (dueIsDateOnly) {
out += `DUE;VALUE=DATE:${IcsUtil.fmtIcsTime(dueMs, true)}\r\n`;
} else {
out += `DUE:${IcsUtil.fmtIcsTime(dueMs, false)}\r\n`;
}
}
// 新建待办一律"未开始",完成态由 setCompleted 切换到 COMPLETED
out += 'STATUS:NEEDS-ACTION\r\n';
out += 'END:VTODO\r\n';
out += 'END:VCALENDAR\r\n';
return out;
}
/** /**
* ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。 * ⭐ 构建"一个重复系列"的完整资源:**主事件 + 全部覆盖实例**放在同一个 VCALENDAR 里。
* *
+363
View File
@@ -0,0 +1,363 @@
// entry/src/main/ets/common/PlaceParser.ets
// 从剪贴板文本/链接中解析出地点:支持高德分享链接(含坐标)与微信复制的地址文本(无坐标)。
//
// 支持的四种链接形态(均实测过):
// ① poi/detail ?__p=<poiid>,<lat>,<lng>,<name>,<address> —— 坐标在 query
// ② share/index /share/index/__q=<lat>,<lng>,<name> —— ⚠️ 坐标在 PATH 不在 query
// ③ place /place/<poiid>?lng=<lng>&lat=<lat> —— 显式命名
// ④ surl 短链 surl.amap.com/<code> —— 需跟随 302 取 Location
//
// ⚠️ 坐标系与顺序(最容易搞错的地方):
// - 高德所有形态都是「纬度在前、经度在后」,而国内地图复制坐标常是「经度在前」。
// 搞反会定位到几百公里外。本解析器统一用合法范围校验纠偏:
// 纬度绝对值必然 ≤90,若两值都在 90 内则按国旗惯例「纬在前」处理。
// - 微信/高德输出的都是 GCJ-02(火星坐标),与 Site Kit 一致,**不需要坐标转换**。
// 只有照片 EXIF 的 WGS-84 才需要转,本解析器不处理该场景。
import { http } from '@kit.NetworkKit';
import { LogUtil } from './LogUtil';
/** 解析结果 */
export class ParsedPlace {
/** 地点名称(可能为空) */
name: string = '';
/** 详细地址(可能为空) */
address: string = '';
/** 纬度(0 表示无坐标) */
lat: number = 0;
/** 经度(0 表示无坐标) */
lng: number = 0;
/** 是否有有效坐标 */
hasCoord: boolean = false;
/** 命中方式,便于排查:amap-p / amap-q / amap-place / amap-surl / wechat-text / bare-address */
source: string = '';
/** 用于填进地点栏的文本:有名称用「名称」,否则用地址 */
get primaryText(): string {
return this.name !== '' ? this.name : this.address;
}
}
export class PlaceParser {
/** 短链展开超时(毫秒)。网络不好时不值得让用户干等 */
private static readonly SHORTLINK_TIMEOUT: number = 5000;
/** 最多跟随几次跳转,防止死循环 */
private static readonly MAX_REDIRECT: number = 5;
/**
* 解析入口:给一段文本,判断是链接还是地址文本,返回地点信息。
* 无法识别时返回 null(调用方应保持原有输入,不阻塞用户)。
*/
static async parse(text: string, context?: object): Promise<ParsedPlace | null> {
const t: string = (text === undefined || text === null) ? '' : text.trim();
if (t === '') {
return null;
}
// 先尝试按链接解析(含短链展开,需要网络)
const fromUrl: ParsedPlace | null = await PlaceParser.parseUrl(t);
if (fromUrl !== null) {
return fromUrl;
}
// 再按纯文本地址解析(无需网络)
return PlaceParser.parseText(t);
}
// ==================== 链接解析 ====================
private static async parseUrl(text: string): Promise<ParsedPlace | null> {
// 从可能夹带其他文字的段落里抽出第一个 http(s) 链接
const urlMatch: RegExpExecArray | null = /https?:\/\/[^\s"'<>))]+/i.exec(text);
if (urlMatch === null) {
return null;
}
const url: string = urlMatch[0];
// ① poi/detail: __p=<id>,<lat>,<lng>,<name>,<address...>
const p: ParsedPlace | null = PlaceParser.parseAmapP(url);
if (p !== null) {
return p;
}
// ② share/index: __q=<lat>,<lng>,<name>(在 path 里)
const q: ParsedPlace | null = PlaceParser.parseAmapQ(url);
if (q !== null) {
return q;
}
// ③ place: ?lng=&lat=
const pl: ParsedPlace | null = PlaceParser.parseAmapPlace(url);
if (pl !== null) {
return pl;
}
// ④ 短链:跟随 302 取 Location 头里的 q 参数
if (/surl\.amap\.com/i.test(url)) {
return await PlaceParser.expandShortLink(url);
}
return null;
}
/**
* ① `__p=<poiid>,<lat>,<lng>,<name>,<address>`
*
* ⚠️ 地址段可能自身含逗号(如「XX路1号,2楼」),所以:
* 只取前 4 段,第 5 段及之后**全部拼回**地址,避免 split 切错。
*/
private static parseAmapP(url: string): ParsedPlace | null {
const m: RegExpExecArray | null = /[?&]__p=([^&]+)/i.exec(url);
if (m === null) {
return null;
}
let raw: string = '';
try {
raw = decodeURIComponent(m[1]);
} catch (err) {
return null;
}
const segs: string[] = raw.split(',');
if (segs.length < 3) {
return null;
}
const lat: number = parseFloat(segs[1]);
const lng: number = parseFloat(segs[2]);
const out = new ParsedPlace();
out.source = 'amap-p';
out.name = segs.length > 3 ? segs[3].trim() : '';
// 地址 = 第 4 段之后全部拼回(含逗号的情况)
if (segs.length > 4) {
out.address = segs.slice(4).join(',').trim();
}
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] !== 0 || fixed[1] !== 0) {
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
}
return out.primaryText !== '' ? out : null;
}
/**
* ② `share/index/__q=<lat>,<lng>,<name>`
*
* ⚠️ 这是唯一坐标藏在 **path** 而不是 query 里的形态。
* 只解析 query 会漏掉它,必须先看 path。
*/
private static parseAmapQ(url: string): ParsedPlace | null {
const m: RegExpExecArray | null = /__q=([^&]+)/i.exec(url);
if (m === null) {
return null;
}
let raw: string = '';
try {
raw = decodeURIComponent(m[1]);
} catch (err) {
return null;
}
const segs: string[] = raw.split(',');
if (segs.length < 2) {
return null;
}
const lat: number = parseFloat(segs[0]);
const lng: number = parseFloat(segs[1]);
const out = new ParsedPlace();
out.source = 'amap-q';
if (segs.length > 2) {
out.name = segs.slice(2).join(',').trim();
}
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] !== 0 || fixed[1] !== 0) {
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
}
return out.primaryText !== '' ? out : null;
}
/** ③ `/place/<poiid>?lng=<lng>&lat=<lat>` —— 显式命名,不存在顺序问题 */
private static parseAmapPlace(url: string): ParsedPlace | null {
const mlng: RegExpExecArray | null = /[?&]lng=([-\d.]+)/i.exec(url);
const mlat: RegExpExecArray | null = /[?&]lat=([-\d.]+)/i.exec(url);
if (mlng === null || mlat === null) {
return null;
}
const lng: number = parseFloat(mlng[1]);
const lat: number = parseFloat(mlat[1]);
const out = new ParsedPlace();
out.source = 'amap-place';
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] === 0 && fixed[1] === 0) {
return null;
}
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
return out;
}
/**
* ④ 短链展开:surl.amap.com/xxx
*
* 实测第一次 302 的 Location 就带完整信息:
* https://wb.amap.com/?q=32.56132,108.50452,紫阳县明树茶厂
* 所以不需要跟到底,拿到第一个 Location 即可,省流量也省时间。
*/
private static async expandShortLink(url: string): Promise<ParsedPlace | null> {
let cur: string = url;
for (let i = 0; i < PlaceParser.MAX_REDIRECT; i++) {
const loc: string = await PlaceParser.headLocation(cur);
if (loc === '') {
return null;
}
// Location 里可能直接带 q=<lat>,<lng>,<name>
const m: RegExpExecArray | null = /[?&]q=([^&]+)/i.exec(loc);
if (m !== null) {
let raw: string = '';
try {
raw = decodeURIComponent(m[1]);
} catch (err) {
return null;
}
const segs: string[] = raw.split(',');
if (segs.length >= 2) {
const lat: number = parseFloat(segs[0]);
const lng: number = parseFloat(segs[1]);
const fixed: number[] = PlaceParser.fixLatLng(lat, lng);
if (fixed[0] !== 0 || fixed[1] !== 0) {
const out = new ParsedPlace();
out.source = 'amap-surl';
out.lat = fixed[0];
out.lng = fixed[1];
out.hasCoord = true;
if (segs.length > 2) {
out.name = segs.slice(2).join(',').trim();
}
return out;
}
}
}
// 还没到目标,继续跟
if (!/^https?:/i.test(loc)) {
return null;
}
cur = loc;
// 中途可能变成 m.amap.com/share/index/__q=... 形态
const q: ParsedPlace | null = PlaceParser.parseAmapQ(cur);
if (q !== null) {
q.source = 'amap-surl';
return q;
}
}
return null;
}
/** 发 HEAD 请求取 Location 头(不下载响应体)。失败返回空串 */
private static async headLocation(url: string): Promise<string> {
const req = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.GET,
header: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36' },
connectTimeout: PlaceParser.SHORTLINK_TIMEOUT,
readTimeout: PlaceParser.SHORTLINK_TIMEOUT,
// 关键:不自动跟随跳转,手动读 Location,避免下载整个落地页
usingProtocol: http.HttpProtocol.HTTP1_1
});
const headers = resp.header as Record<string, string>;
if (headers === undefined || headers === null) {
return '';
}
const loc: string = headers['Location'] ?? headers['location'] ?? '';
return typeof loc === 'string' ? loc : '';
} catch (err) {
LogUtil.write(`短链展开失败: ${url}`);
return '';
} finally {
req.destroy();
}
}
// ==================== 坐标纠偏 ====================
/**
* 校正经纬度顺序。
*
* 规则(依据实测与地理常识):
* - 纬度绝对值必然 ≤ 90,经度可以到 180
* - 若第一个值绝对值 > 90 → 第一个其实是经度,交换
* - 若两个都在 90 内 → 按高德惯例「纬在前」,不交换
* - 任一为 0 或非数字 → 视为无坐标(返回 [0,0])
*
* @returns [lat, lng];无效时返回 [0, 0]
*/
private static fixLatLng(a: number, b: number): number[] {
const invalid: number[] = [0, 0];
if (isNaN(a) || isNaN(b) || (a === 0 && b === 0)) {
return invalid;
}
let lat: number = a;
let lng: number = b;
if (Math.abs(a) > 90 && Math.abs(b) <= 90) {
// 第一个是经度,交换
lat = b;
lng = a;
}
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return invalid;
}
return [lat, lng];
}
// ==================== 纯文本地址解析 ====================
/**
* 解析微信「复制地址」出来的纯文本。
*
* 实测格式:`<名称>(<分店名>) <空格> <省市区+门牌>`
* 例:招商银行(青岛经济技术开发区支行) 山东省青岛市黄岛区珠江路1-3楼1018号一楼
*
* 兼容性考虑:
* - 分隔符可能是空格、全角空格、换行
* - 括号可能是半角 () 或全角()
* - 可能只有一个名称没有地址
* - 可能只有地址没有名称
*
* 策略:**名称优先**。因为微信拼的地址可能有问题(实测「珠江路1-3楼1018号」
* 把楼层塞进了门牌号,检索命中率低),而名称检索几乎必中。
*/
private static parseText(text: string): ParsedPlace | null {
// 去掉 URL 残留与多余空白
let t: string = text.replace(/\s+/g, ' ').trim();
if (t === '' || t.length < 2) {
return null;
}
const out = new ParsedPlace();
out.source = 'wechat-text';
// 情况一:名称(分店) 空格 地址 —— 括号后跟空格是最可靠的切分点
const m1: RegExpExecArray | null = /^(.+?[))])\s*(.+)$/.exec(t);
if (m1 !== null) {
out.name = m1[1].trim();
out.address = m1[2].trim();
return out;
}
// 情况二:名称 + 空格 + 行政区开头(省/市/区/县/路/街/号…)
const m2: RegExpExecArray | null = /^(.+?)\s+((?:中国)?[省市区县].+)$/.exec(t);
if (m2 !== null) {
out.name = m2[1].trim();
out.address = m2[2].trim();
return out;
}
// 情况三:只有名称,或只有地址
if (/^(?:中国)?[省市区县]/.test(t) || /[路街号弄巷栋楼室]/.test(t)) {
out.address = t;
} else {
out.name = t;
}
return out.primaryText !== '' ? out : null;
}
// ==================== 工具 ====================
/** 反向地理编码后的展示文本:名称 | 地址 */
static mergeForLocationText(name: string, address: string): string {
if (name !== '' && address !== '' && name !== address) {
return `${name}|${address}`;
}
return name !== '' ? name : address;
}
}
+75
View File
@@ -90,6 +90,81 @@ export class TodoService {
} }
} }
/**
* 新建待办:生成 VTODO → PUT 到服务器 → 落本地库。
*
* @param href 目标日历本的集合 URL
* @param title 标题(调用方保证非空)
* @param desc 备注(可为空串)
* @param dueMs 截止时间毫秒(0 = 无截止)
* @param dueIsDateOnly 截止时间是否只到日(true 写 DUE;VALUE=DATE)
* @returns 成功返回新行 id(>0),失败返回 0
*/
static async createTodo(context: common.Context, href: string, title: string,
desc: string, dueMs: number, dueIsDateOnly: boolean): Promise<number> {
const t: string = title.trim();
if (t === '' || href === '') {
LogUtil.write(`待办新建失败:标题或日历本为空`);
return 0;
}
// 1) 找到该 href 所属账号
const acc: DavAccount | null = await TodoService.findAccount(context, href);
if (acc === null) {
LogUtil.write(`待办新建失败:找不到所属账号 href=${href}`);
return 0;
}
// 2) 只读日历本提前拦下(否则必然 403)
const idx: number = acc.calendarHrefs.indexOf(href);
if (idx >= 0 && acc.calendarWritable.length > idx && acc.calendarWritable[idx] === '0') {
LogUtil.write(`待办新建失败:该日历本只读 href=${href}`);
return 0;
}
// 3) 生成 uid 与资源文件名
// 格式与事件侧一致:todo-<毫秒时间戳>-<随机6位>,足够避免撞车
const stamp: number = Date.now();
const rnd: string = Math.random().toString(36).substring(2, 8);
const uid: string = `todo-${stamp}-${rnd}`;
const fileName: string = `${uid}.ics`;
const url: string = href.endsWith('/') ? href + fileName : `${href}/${fileName}`;
const auth: string = DavClient.authHeader(acc.username, acc.password);
try {
// 4) 先推服务器:成功才落库,避免产生"本地有、服务器没有"的孤儿
const ics: string = IcsUtil.buildTodo(uid, t, desc, dueMs, dueIsDateOnly);
const etag: string = await DavClient.putEvent(url, auth, ics);
// 5) 落本地库(insertLocal 会置 dirty=true,但 etag 已写回故不会被重复推送)
const e: LocalEvent = new LocalEvent();
e.uid = uid;
e.calKey = `${acc.id}_${idx}`;
e.href = href;
e.remotePath = fileName;
e.etag = etag;
e.kind = 'todo';
e.title = t;
e.description = desc;
e.startTime = dueMs > 0 ? dueMs : 0; // 待办的"开始"即截止(与 loadTodos 口径一致)
e.endTime = dueMs > 0 ? dueMs : 0;
e.isAllDay = dueIsDateOnly;
e.completed = false;
e.dirty = false; // 已经推成功了,无需再推
e.deleted = false;
const newId: number = await EventDb.insertLocal(context, e);
// insertLocal 强制置 dirty=true,这里立刻清掉(内容已在服务器上了)
await EventDb.clearDirty(context, newId, etag);
LogUtil.write(`待办新建成功 id=${newId}(标题已脱敏)→ ${fileName}`);
return newId;
} catch (err) {
if (err instanceof DavHttpError) {
LogUtil.write(`待办新建失败 HTTP ${(err as DavHttpError).status}(标题已脱敏)`);
} else {
LogUtil.write(`待办新建失败 ${(err as BusinessError).message ?? ''}`);
}
return 0;
}
}
/** 删除待办(DELETE 服务器资源 + 本地软删) */ /** 删除待办(DELETE 服务器资源 + 本地软删) */
static async deleteTodo(context: common.Context, localId: number): Promise<boolean> { static async deleteTodo(context: common.Context, localId: number): Promise<boolean> {
const row: LocalEvent | null = await EventDb.queryById(context, localId); const row: LocalEvent | null = await EventDb.queryById(context, localId);
@@ -6,6 +6,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process'; import process from '@ohos.process';
import { CardDataService, CardData } from '../common/CardDataService'; import { CardDataService, CardData } from '../common/CardDataService';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner'; import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { TodoService } from '../common/TodoService';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
export default class EntryFormAbility extends FormExtensionAbility { export default class EntryFormAbility extends FormExtensionAbility {
@@ -18,15 +19,31 @@ export default class EntryFormAbility extends FormExtensionAbility {
} }
} }
/** 组装更新数据并推送到指定卡片 */ /**
* 组装更新数据并推送到指定卡片。
*
* ⭐ 一次同时构建「日程」与「待办」两份数据,合并进同一个 binding。
* 日程卡片只读 eventsJson 等字段,待办卡片只读 todoListJson 等字段,
* 彼此不会串味。这样无需维护 formId → 卡片类型的映射(那需要持久化,
* 且系统重启后容易丢失),一套逻辑服务全部 8 张卡片。
*/
private pushData(formId: string, dayOffset: number = 0): void { private pushData(formId: string, dayOffset: number = 0): void {
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => { Promise.all([
CardDataService.buildCardData(this.context, dayOffset),
CardDataService.buildTodoCardData(this.context),
]).then((results: CardData[]): void => {
const ev: CardData = results[0];
const td: CardData = results[1];
// 待办字段并入日程数据对象(两者都是 CardData,字段不冲突)
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
const binding: formBindingData.FormBindingData = const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(data); formBindingData.createFormBindingData(ev);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => { formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`); LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`);
}); });
LogUtil.write(`卡片 ${formId} 数据已推送`); LogUtil.write(`卡片 ${formId} 数据已推送(日程+待办)`);
}).catch((err: BusinessError): void => { }).catch((err: BusinessError): void => {
LogUtil.write(`卡片数据构建失败: ${err.message}`); LogUtil.write(`卡片数据构建失败: ${err.message}`);
}); });
@@ -84,12 +101,31 @@ export default class EntryFormAbility extends FormExtensionAbility {
CardDataService.clearDayOffset(formId); CardDataService.clearDayOffset(formId);
} }
/** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */ /**
* 卡片事件:既处理日程翻页,也处理待办打勾。
*
* ⚠️ 不用 JSON.parse 解析 message —— 不同系统版本下格式可能是
* `{params:{...}}` 也可能是直接 `{...}`,格式差异会让 JSON 解析拿到 undefined。
* 统一用**子串匹配**,稳且不依赖具体结构。
*/
onFormEvent(formId: string, message: string): void { onFormEvent(formId: string, message: string): void {
LogUtil.init(this.context); LogUtil.init(this.context);
// ===== 待办打勾:message 里带 todoCheck=<localId> =====
const checkIdx: number = message.indexOf('todoCheck');
if (checkIdx >= 0) {
const m = /todoCheck\D{0,8}(\d+)/.exec(message);
const localId: number = m !== null ? parseInt(m[1], 10) : 0;
LogUtil.write(`卡片打勾待办: formId=${formId} localId=${localId} msg=${message}`);
if (localId > 0) {
this.completeTodoFromCard(formId, localId);
}
return;
}
// ===== 日程翻页 =====
const cur: number = CardDataService.getDayOffset(formId); const cur: number = CardDataService.getDayOffset(formId);
let next: number = cur; let next: number = cur;
// 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳
if (message.indexOf('prev') >= 0) { if (message.indexOf('prev') >= 0) {
next = cur - 1; next = cur - 1;
} else if (message.indexOf('next') >= 0) { } else if (message.indexOf('next') >= 0) {
@@ -101,4 +137,54 @@ export default class EntryFormAbility extends FormExtensionAbility {
LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`); LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${next}`);
this.pushData(formId, next); this.pushData(formId, next);
} }
/**
* 在卡片上完成一条待办。
*
* 时序设计(关键):
* ① **先推一次数据让该条从卡片消失** —— 用户点勾必须立刻有视觉反馈。
* 但此时库里还没改成 completed,所以这里不等它完成,
* 而是先推送"排除该 localId"的临时数据(见 pushDataExcludingTodo)。
* ② 再真正调 TodoService.setCompleted(内部 GET 原 ics → 改写 → PUT)。
* ③ 成功 → 推真实数据(该条已 completed,本来就不在列表里,效果一致);
* 失败 → 也推真实数据(该条会重新出现,用户能看出没成功,可重试或进 App 处理)。
*
* 为什么不 await 完再推:
* FormExtensionAbility 生命周期回调只有 10 秒窗口,
* GET+PUT 两步网络在慢网下可能 10 秒以上,卡片会先"卡住不动"再消失,体验很差。
*/
private completeTodoFromCard(formId: string, localId: number): void {
// ① 乐观移除:立即推送不含该条的列表
this.pushDataExcludingTodo(formId, localId);
// ② 真正落库 + 推送服务器
TodoService.setCompleted(this.context, localId, true).then((ok: boolean): void => {
LogUtil.write(`卡片打勾结果: localId=${localId} ${ok ? '成功' : '失败'}`);
// ③ 无论成败都推一次真实数据:成功则列表已无该条;失败则该条重新出现
this.pushData(formId);
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片打勾异常: localId=${localId} ${err.message}`);
this.pushData(formId);
});
}
/** 推送"排除某条待办"的卡片数据(乐观更新用,只改卡片视图,不动数据库) */
private pushDataExcludingTodo(formId: string, excludeId: number): void {
Promise.all([
CardDataService.buildCardData(this.context, CardDataService.getDayOffset(formId)),
CardDataService.buildTodoCardData(this.context, excludeId),
]).then((results: CardData[]): void => {
const ev: CardData = results[0];
const td: CardData = results[1];
ev.todoListJson = td.todoListJson;
ev.todoCount = td.todoCount;
ev.todoTodayCount = td.todoTodayCount;
const binding: formBindingData.FormBindingData =
formBindingData.createFormBindingData(ev);
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
LogUtil.write(`卡片 ${formId} 乐观更新失败: ${err.message}`);
});
}).catch((err: BusinessError): void => {
LogUtil.write(`卡片乐观更新数据构建失败: ${err.message}`);
});
}
} }
+131 -1
View File
@@ -2,7 +2,7 @@
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程) // 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI'; import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { notificationManager } from '@kit.NotificationKit'; import { notificationManager } from '@kit.NotificationKit';
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore'; import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
import { EventDb, LocalEvent } from '../common/EventDb'; import { EventDb, LocalEvent } from '../common/EventDb';
@@ -14,6 +14,7 @@ import { LogUtil } from '../common/LogUtil';
import { IcsUtil } from '../common/IcsUtil'; import { IcsUtil } from '../common/IcsUtil';
import { RruleUtil } from '../common/RruleUtil'; import { RruleUtil } from '../common/RruleUtil';
import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest'; import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest';
import { PlaceParser, ParsedPlace } from '../common/PlaceParser';
/** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */ /** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */
class SeriesBounds { class SeriesBounds {
@@ -443,6 +444,123 @@ struct EventEditPage {
.padding({ top: 4, bottom: 4 }) .padding({ top: 4, bottom: 4 })
} }
/**
* 从剪贴板粘贴并解析地点。
*
* ⚠️ 必须由 PasteButton 的 onClick 触发,权限仅在回调内有效。
* 在回调外调用 pasteboard.getData() 会因缺少权限而失败。
*/
private async pastePlace(): Promise<void> {
this.cancelSuggestTimer();
let text: string = '';
try {
const sys = pasteboard.getSystemPasteboard();
const has: boolean = await sys.hasData();
if (!has) {
this.toast('剪贴板是空的');
return;
}
const data: pasteboard.PasteData = await sys.getData();
text = data.getPrimaryText();
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`粘贴读取失败: ${e.code} ${e.message}`);
this.toast('无法读取剪贴板');
return;
}
if (text === undefined || text === null || text.trim() === '') {
this.toast('剪贴板里没有文字');
return;
}
await this.resolvePastedText(text.trim());
}
/**
* 把粘贴到的文本解析成地点。
*
* 三级降级(从准到松):
* ① 高德链接 → 直接有坐标,无需检索
* ② 微信文本 → 拆出名称,用**名称**检索(实测命中率最高)
* ③ 名称搜不到 → 用完整地址再搜一次
* ④ 都失败 → 把原文填进地点栏,提示用户手动确认
*
* 第 ④ 步兜底很重要:宁可让用户自己看一眼,
* 也不能因为解析失败就清空输入框或卡住保存。
*/
private async resolvePastedText(text: string): Promise<void> {
const ctx: common.Context | undefined = this.getUIContext().getHostContext();
let parsed: ParsedPlace | null = null;
try {
parsed = await PlaceParser.parse(text, ctx);
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`粘贴解析异常: ${e.message}`);
}
// 解析不出东西 → 原文填入,让用户自行判断
if (parsed === null) {
this.lastAppliedText = text;
this.location = text;
this.toast('已填入,请确认地点是否正确');
return;
}
// 有坐标(高德链接):直接用,坐标已包含在解析结果里
if (parsed.hasCoord) {
const locText: string = PlaceParser.mergeForLocationText(parsed.name, parsed.address);
this.lastAppliedText = locText !== '' ? locText : text;
this.location = this.lastAppliedText;
this.placePicked = true;
this.toast('已从链接解析地点');
return;
}
// 无坐标(微信文本):名称优先检索,失败再用地址
const tryKeywords: string[] = [];
if (parsed.name !== '') {
tryKeywords.push(parsed.name);
}
if (parsed.address !== '' && parsed.address !== parsed.name) {
tryKeywords.push(parsed.address);
}
if (tryKeywords.length === 0) {
tryKeywords.push(text);
}
for (let i = 0; i < tryKeywords.length; i++) {
const kw: string = tryKeywords[i];
try {
let items: PlaceItem[] = await PlaceSuggest.query(kw, ctx);
if (items.length === 0) {
items = await PlaceSuggest.search(kw, ctx);
}
if (items.length > 0) {
// 命中:直接应用第一条(与用户点选候选等价)
const best: PlaceItem = items[0];
this.placePicked = true;
this.lastAppliedText = best.locationText;
this.location = best.locationText;
this.toast('已匹配到地点');
return;
}
} catch (err) {
LogUtil.write(`粘贴检索失败(${kw}): ${(err as BusinessError).message}`);
}
}
// 全部检索失败:填原文,不阻塞
const fallback: string = parsed.name !== '' ? parsed.name : parsed.address;
this.lastAppliedText = fallback !== '' ? fallback : text;
this.location = this.lastAppliedText;
this.placePicked = true;
this.toast('已填入,请确认地点是否正确');
}
/** 轻量提示(复用页面已有的 toast 机制;没有就静默) */
private toast(msg: string): void {
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 1800 });
}
/** 选中一条候选:写进地点输入框并收起列表 */ /** 选中一条候选:写进地点输入框并收起列表 */
private applyPlace(it: PlaceItem): void { private applyPlace(it: PlaceItem): void {
// ⭐ 顺序很关键:先收起浮层,再写文本。 // ⭐ 顺序很关键:先收起浮层,再写文本。
@@ -1624,6 +1742,18 @@ struct EventEditPage {
} }
}) })
.id('placeAnchor') .id('placeAnchor')
// ⭐ 粘贴地址:系统 PasteButton 安全控件
// 用它而不是自绘按钮 + pasteboard.getData(),原因见 pastePlace() 注释:
// READ_PASTEBOARD 是受限权限,普通应用申请会被拒绝。
// PasteButton 无需声明权限、无需弹窗,权限仅在 onClick 内有效。
PasteButton()
.height(44)
.padding({ left: 8, right: 8 })
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.onClick(() => {
this.pastePlace();
})
// 在地图上选点(系统地图界面,可选可搜可周边) // 在地图上选点(系统地图界面,可选可搜可周边)
Text('地图') Text('地图')
.fontSize(13) .fontSize(13)
+176 -7
View File
@@ -66,6 +66,15 @@ struct Index {
@State events: DisplayEvent[] = []; @State events: DisplayEvent[] = [];
@State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示) @State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示)
@State todoBusy: boolean = false; // 正在写回待办状态(防连点) @State todoBusy: boolean = false; // 正在写回待办状态(防连点)
// ⭐ 待办新建模式:true=新建(弹层显示日历本选择),false=编辑既有待办
@State todoCreating: boolean = false;
// 新建待办时可选的日历本(铺开供用户选,与新建日程一致)
@State todoBooks: CalSource[] = [];
// 新建待办时选中的日历本 calKey
@State todoCreateBookKey: string = '';
// calKey → 日历本集合 URL 的映射(CalSource 本身不带 href,需从账号解析)
private todoBookHrefs: Map<string, string> = new Map<string, string>();
// ⭐ TODO_EDITOR 待办编辑弹框状态 // ⭐ TODO_EDITOR 待办编辑弹框状态
@State todoEditShow: boolean = false; @State todoEditShow: boolean = false;
@State todoEditId: number = 0; @State todoEditId: number = 0;
@@ -1183,7 +1192,13 @@ struct Index {
.shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 }) .shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 })
.margin(24) .margin(24)
.onClick(() => { .onClick(() => {
this.addEvent(); // ⭐ 职责感知:待办视图下新建待办,其余视图新建日程。
// 原来固定 addEvent(),导致在待办页点 + 却跳到日程编辑页,与当前语境不符。
if (this.mode === 'todo') {
this.openTodoCreator();
} else {
this.addEvent();
}
}) })
// 未同意《隐私政策》与《用户协议》:整页门禁盖在最上层(不申请权限、不加载数据) // 未同意《隐私政策》与《用户协议》:整页门禁盖在最上层(不申请权限、不加载数据)
@@ -2747,14 +2762,15 @@ struct Index {
}) })
Column({ space: 12 }) { Column({ space: 12 }) {
// 标题行 // 标题行(新建/编辑两态)
Row({ space: 8 }) { Row({ space: 8 }) {
Text('编辑待办') Text(this.todoCreating ? '新建待办' : '编辑待办')
.fontSize(17) .fontSize(17)
.fontWeight(FontWeight.Bold) .fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
Blank() Blank()
if (this.todoEditWritable) { // 新建时没有可删的东西,隐藏删除入口
if (!this.todoCreating && this.todoEditWritable) {
Text('删除') Text('删除')
.fontSize(14) .fontSize(14)
.fontColor($r('app.color.error')) .fontColor($r('app.color.error'))
@@ -2765,7 +2781,42 @@ struct Index {
} }
.width('100%') .width('100%')
if (this.todoEditBook !== '') { // 日历本区:新建时**铺开全部可写本供选择**(与新建日程一致的交互),
// 编辑时只读显示归属本(改归属等于搬家,不支持原地改)
if (this.todoCreating) {
Column({ space: 6 }) {
Text('日历本')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.textAlign(TextAlign.Start)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.todoBooks, (b: CalSource) => {
Row({ space: 6 }) {
Circle().width(8).height(8).fill(b.color)
Text(b.name)
.fontSize(12)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontColor(this.todoCreateBookKey === b.calKey
? $r('app.color.button_text') : $r('app.color.text_primary'))
}
.width('48%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.borderRadius(14)
.margin({ bottom: 8 })
.backgroundColor(this.todoCreateBookKey === b.calKey
? b.color : $r('app.color.chip_off_bg'))
.onClick(() => {
this.todoCreateBookKey = b.calKey;
})
}, (b: CalSource) => b.calKey)
}
.width('100%')
}
.width('100%')
} else if (this.todoEditBook !== '') {
Row({ space: 6 }) { Row({ space: 6 }) {
Text('日历本') Text('日历本')
.fontSize(12) .fontSize(12)
@@ -2888,7 +2939,7 @@ struct Index {
.onClick(() => { .onClick(() => {
this.todoEditShow = false; this.todoEditShow = false;
}) })
Button(this.todoBusy ? '保存中…' : '保存') Button(this.todoBusy ? '保存中…' : (this.todoCreating ? '创建' : '保存'))
.layoutWeight(1) .layoutWeight(1)
.height(44) .height(44)
.borderRadius(12) .borderRadius(12)
@@ -3100,8 +3151,81 @@ struct Index {
return out; return out;
} }
/**
* 打开「新建待办」弹层。
*
* 与 openTodoEditor 的区别:不预填任何既有数据,todoEditId=0,
* 并把**所有可写日历本铺开**供用户选择。
*
* 默认选中策略:优先 Inbox(多数 CalDAV 服务器把待办收件箱叫 inbox/Inbox/收件箱),
* 找不到就选第一个可写的本 —— 让用户少点一步,但仍可改。
*/
private async openTodoCreator(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
// 只取可写的 CalDAV 本('dav' 来源);只读本推上去必 403,不该出现在选项里
// 注意:CalSource 没有 href 字段,需从账号里按 calKey 反解
const accounts: DavAccount[] = await AccountStore.loadAll(ctx);
this.todoBookHrefs.clear();
const writable: CalSource[] = [];
for (const s of this.sources) {
if (!s.writable || s.source !== 'dav') {
continue;
}
// calKey 形如 "<accountId>_<index>"
const us: number = s.calKey.lastIndexOf('_');
if (us <= 0) {
continue;
}
const accId: string = s.calKey.substring(0, us);
const idx: number = parseInt(s.calKey.substring(us + 1), 10);
if (isNaN(idx)) {
continue;
}
const acc: DavAccount | undefined = accounts.find(
(a: DavAccount): boolean => String(a.id) === accId);
if (acc === undefined || idx < 0 || idx >= acc.calendarHrefs.length) {
continue;
}
this.todoBookHrefs.set(s.calKey, acc.calendarHrefs[idx]);
writable.push(s);
}
if (writable.length === 0) {
this.getUIContext().getPromptAction().showToast({
message: '还没有可写入的 CalDAV 日历本,请先添加账号'
});
return;
}
this.todoBooks = writable;
// 默认选中:名字里含 inbox/收件箱 的本优先,否则第一个
let pick: string = writable[0].calKey;
for (const s of writable) {
const n: string = s.name.toLowerCase();
if (n.indexOf('inbox') >= 0 || s.name.indexOf('收件箱') >= 0) {
pick = s.calKey;
break;
}
}
this.todoCreateBookKey = pick;
// 清空编辑态数据,进入"新建"
this.todoCreating = true;
this.todoEditId = 0;
this.todoEditTitle = '';
this.todoEditDesc = '';
this.todoEditDueMs = 0;
this.todoEditHasDue = false;
this.todoEditWritable = true;
this.todoEditBook = '';
this.todoEditRecur = '';
this.todoEditPickShow = false;
this.todoEditShow = true;
}
/** 打开待办编辑弹框(点内容区触发;不会改变完成状态) */ /** 打开待办编辑弹框(点内容区触发;不会改变完成状态) */
private openTodoEditor(t: DisplayEvent): void { private openTodoEditor(t: DisplayEvent): void {
this.todoCreating = false;
this.todoEditId = t.id; this.todoEditId = t.id;
this.todoEditTitle = t.title; this.todoEditTitle = t.title;
this.todoEditDesc = t.description; this.todoEditDesc = t.description;
@@ -3141,7 +3265,15 @@ struct Index {
/** 保存待办编辑(标题 / 备注 / 截止时间) */ /** 保存待办编辑(标题 / 备注 / 截止时间) */
private async saveTodoEdit(): Promise<void> { private async saveTodoEdit(): Promise<void> {
const ctx = this.getUIContext().getHostContext(); const ctx = this.getUIContext().getHostContext();
if (ctx === undefined || this.todoBusy || this.todoEditId <= 0) { if (ctx === undefined || this.todoBusy) {
return;
}
// ⭐ 新建分支:走 createTodo(先 PUT 服务器,成功才落库)
if (this.todoCreating) {
await this.saveNewTodo(ctx);
return;
}
if (this.todoEditId <= 0) {
return; return;
} }
if (!this.todoEditWritable) { if (!this.todoEditWritable) {
@@ -3177,6 +3309,43 @@ struct Index {
} }
} }
/** 保存新建的待办 */
private async saveNewTodo(ctx: common.Context): Promise<void> {
const title: string = this.todoEditTitle.trim();
if (title === '') {
this.getUIContext().getPromptAction().showToast({ message: '请填写待办标题' });
return;
}
const href: string = this.todoBookHrefs.get(this.todoCreateBookKey) ?? '';
if (href === '') {
this.getUIContext().getPromptAction().showToast({ message: '请选择日历本' });
return;
}
this.todoBusy = true;
try {
const dueMs: number = this.todoEditHasDue ? this.todoEditDueMs : 0;
// 截止时间只到日(00:00 视为"当天")→ 用 DATE 值,避免服务器上显示 00:00
const dateOnly: boolean = dueMs > 0 && this.todoEditDueMs % 86400000 === 0;
const newId: number = await TodoService.createTodo(ctx, href, title,
this.todoEditDesc, dueMs, dateOnly);
if (newId > 0) {
this.todoEditShow = false;
this.todoCreating = 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 { private askDeleteTodo(): void {
this.getUIContext().showAlertDialog({ this.getUIContext().showAlertDialog({
@@ -0,0 +1,104 @@
// entry/src/main/ets/pages/widget/TodoWidget2x2.ets
// 2x2 待办服务卡片:突出未完成待办总数 + 今日到期提示。
// 本卡片**不展示待办列表**,因此没有打勾交互;点击整卡进入 App 的待办页。
let storageTodo2x2 = new LocalStorage();
@Entry(storageTodo2x2)
@Component
struct TodoWidget2x2Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
/** 右上角添加按钮:拉起 App 直接进入新建待办页 */
@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' }
});
})
}
/** 打开 App 并进入待办 Tab */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
build() {
Column({ space: 6 }) {
// 顶部:日期 + 添加按钮
Row({ space: 6 }) {
Column({ space: 2 }) {
Text(this.dateMd)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
.fontSize(11)
.fontColor('#8A8A8A')
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
// 未完成待办大数字
Row({ space: 8 }) {
Text(`${this.todoCount}`)
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor('#6A4C93')
Column({ space: 2 }) {
Text('待办未完成')
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
// 今日到期优先提示,没有则给操作引导
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
.fontSize(10)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => this.openApp())
}
}
@@ -0,0 +1,229 @@
// entry/src/main/ets/pages/widget/TodoWidget2x4.ets
// 2x4 待办服务卡片:左侧 = 未完成总数 + 今日到期(同 2x2);右侧 = 最近 2 条待办。
// 右侧带**圆形勾选框**,点击即完成并推送回服务器。
let storageTodo2x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem2x4 {
id: number = 0;
title: string = '';
dueText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo2x4)
@Component
struct TodoWidget2x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('lunarText') lunarText: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
/** 右侧列表数据(可滚动;数据侧已按截止时间排序并限制在 20 条内) */
private parseTodos(): TodoItem2x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem2x4[];
} 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' }
});
})
}
/** 打开 App 并进入待办 Tab */
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { widgetAction: 'openTodo' }
});
}
/**
* 点击圆形勾选框 → 让 FormExtensionAbility 去完成这条待办。
*
* ⭐ 用 message 而不是 call:message 会进入 onFormEvent,能在
* FormExtension 的 10 秒窗口内完成"改库 + PUT 推送 + 刷新卡片",
* 不需要拉起主 App(拉起会闪界面,体验差)。
* ⭐ 带上 localId:卡片只知道自己渲染的是哪一条,改库要靠主键。
*/
private checkTodo(id: number): void {
postCardAction(this, {
action: 'message',
params: { todoCheck: `${id}` }
});
}
/** 圆形勾选框(未完成状态:空心圆) */
@Builder
checkbox(id: number) {
// 同 4x4:不用 SDK 26 的 Circle().stroke(),改用 border 圆角
Column()
.width(18)
.height(18)
.borderRadius(9)
.border({ width: 1.5, color: '#B0B0B0' })
.backgroundColor(Color.Transparent)
.onClick(() => this.checkTodo(id))
}
/** 右侧单条待办:勾选框 + 标题 + 截止时间 */
@Builder
todoRow(item: TodoItem2x4) {
Row({ space: 6 }) {
this.checkbox(item.id)
Column({ space: 1 }) {
// 标题限 1 行:2x4 高度只有 2 格,2 行会让一屏只放得下 1 条多,滑动失去意义
Text(item.title)
.fontSize(12)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(item.dueText)
.fontSize(9)
.fontColor(item.dueText.indexOf('逾期') >= 0 ? '#FF3B30' : '#8A8A8A')
.maxLines(1)
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 2, right: 6, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F5F7FA')
}
/** 右侧空态 */
@Builder
emptyHint() {
Column({ space: 4 }) {
Text(this.todoCount === 0 ? '暂无待办' : '今日无到期')
.fontSize(12)
.fontColor('#8A8A8A')
.maxLines(2)
.textAlign(TextAlign.Center)
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
build() {
Row({ space: 10 }) {
// ===== 左半:未完成总数 =====
Column({ space: 6 }) {
Row({ space: 6 }) {
Column({ space: 2 }) {
Text(this.dateMd)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${this.weekday === '' ? '' : this.weekday + ' '}${this.lunarText}`)
.fontSize(10)
.fontColor('#8A8A8A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
this.addButton()
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#E5E5E5')
Row({ space: 6 }) {
Text(`${this.todoCount}`)
.fontSize(38)
.fontWeight(FontWeight.Bold)
.fontColor('#6A4C93')
Column({ space: 2 }) {
Text('待办未完成')
.fontSize(11)
.fontColor('#1A1A1A')
.maxLines(1)
Text(this.todoTodayCount > 0 ? `今日到期 ${this.todoTodayCount} 项`
: (this.todoCount === 0 ? '点击添加' : '今日无到期'))
.fontSize(9)
.fontColor(this.todoTodayCount > 0 ? '#FF9500' : '#8A8A8A')
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
}
.layoutWeight(1)
.height('100%')
Divider()
.vertical(true)
.height('92%')
.strokeWidth(0.5)
.color('#E5E5E5')
// ===== 右半:最近 2 条待办(可打勾)=====
Column() {
if (this.parseTodos().length === 0) {
this.emptyHint()
} else {
// ⭐ List 可滚动:2x4 原来只渲染固定 2 条(Column + slice),
// 待办是清单性质、条数常十几条,只看 2 条价值太低。
// List 自带虚拟化与滚动惯性,条目多也不占内存。
List({ space: 3 }) {
ForEach(this.parseTodos(), (item: TodoItem2x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem2x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.onClick(() => this.openApp())
}
}
@@ -0,0 +1,209 @@
// entry/src/main/ets/pages/widget/TodoWidget4x4.ets
// 4x4 待办服务卡片:顶部标题栏(未完成数 / 今日到期)+ 可滚动待办列表。
// 每条待办左侧为圆形勾选框,点击即完成并推送回服务器。
let storageTodo4x4 = new LocalStorage();
/** 待办卡片单条(解析 todoListJson) */
class TodoItem4x4 {
id: number = 0;
title: string = '';
dueText: string = '';
color: string = '#007DFF';
calName: string = '';
completed: boolean = false;
}
@Entry(storageTodo4x4)
@Component
struct TodoWidget4x4Card {
@LocalStorageProp('dateMd') dateMd: string = '';
@LocalStorageProp('weekday') weekday: string = '';
@LocalStorageProp('todoCount') todoCount: number = 0;
@LocalStorageProp('todoTodayCount') todoTodayCount: number = 0;
@LocalStorageProp('todoListJson') todoListJson: string = '[]';
private parseTodos(): TodoItem4x4[] {
try {
return JSON.parse(this.todoListJson) as TodoItem4x4[];
} 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' }
});
}
/** 点击勾选框 → 交给 FormExtension 完成(带 localId) */
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)
.onClick(() => this.checkTodo(id))
}
/** 单条待办:勾选框 + 左色竖条 + 标题/截止时间 + 日历本名 */
@Builder
todoRow(item: TodoItem4x4) {
Row({ space: 8 }) {
this.checkbox(item.id)
Column()
.width(3)
.height(32)
.borderRadius(2)
.backgroundColor(item.color)
Column({ space: 3 }) {
Text(item.title)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(item.dueText)
.fontSize(10)
.fontColor(item.dueText.indexOf('逾期') >= 0 ? '#FF3B30' : '#8A8A8A')
.maxLines(1)
if (item.calName !== '') {
Text(item.calName)
.fontSize(10)
.fontColor(item.color)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '55%' })
}
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 4, right: 10, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F5F7FA')
}
/** 顶部标题栏 */
@Builder
headerBar() {
Row({ space: 8 }) {
Column({ space: 2 }) {
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)
.alignItems(HorizontalAlign.Start)
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 虚拟化:待办多时滑动流畅
List({ space: 4 }) {
ForEach(this.parseTodos(), (item: TodoItem4x4, idx: number) => {
ListItem() {
this.todoRow(item)
}
}, (item: TodoItem4x4, idx: number) => `t${item.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
}
}
@@ -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)
}
}
@@ -51,6 +51,26 @@
{ {
"name": "sync_work_desc", "name": "sync_work_desc",
"value": "日历后台兜底同步任务" "value": "日历后台兜底同步任务"
},
{
"name": "todo_card_2x2_name",
"value": "待办 2×2"
},
{
"name": "todo_card_2x4_name",
"value": "待办 2×4"
},
{
"name": "todo_card_4x4_name",
"value": "待办 4×4"
},
{
"name": "todo_card_6x4_name",
"value": "待办 6×4"
},
{
"name": "todo_card_desc",
"value": "待办服务卡片:查看未完成待办,可直接点击完成"
} }
] ]
} }
@@ -16,7 +16,9 @@
"scheduledUpdateTime": "07:30", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "2*2", "defaultDimension": "2*2",
"supportDimensions": ["2*2"] "supportDimensions": [
"2*2"
]
}, },
{ {
"name": "WidgetCard2x4", "name": "WidgetCard2x4",
@@ -34,7 +36,9 @@
"scheduledUpdateTime": "07:30", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "2*4", "defaultDimension": "2*4",
"supportDimensions": ["2*4"] "supportDimensions": [
"2*4"
]
}, },
{ {
"name": "WidgetCard4x4", "name": "WidgetCard4x4",
@@ -52,7 +56,9 @@
"scheduledUpdateTime": "07:30", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "4*4", "defaultDimension": "4*4",
"supportDimensions": ["4*4"] "supportDimensions": [
"4*4"
]
}, },
{ {
"name": "WidgetCard6x4", "name": "WidgetCard6x4",
@@ -70,7 +76,89 @@
"scheduledUpdateTime": "07:30", "scheduledUpdateTime": "07:30",
"updateDuration": 1, "updateDuration": 1,
"defaultDimension": "6*4", "defaultDimension": "6*4",
"supportDimensions": ["6*4"] "supportDimensions": [
"6*4"
]
},
{
"name": "TodoCard2x2",
"displayName": "$string:todo_card_2x2_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget2x2.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*2",
"supportDimensions": [
"2*2"
]
},
{
"name": "TodoCard2x4",
"displayName": "$string:todo_card_2x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget2x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "2*4",
"supportDimensions": [
"2*4"
]
},
{
"name": "TodoCard4x4",
"displayName": "$string:todo_card_4x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget4x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "4*4",
"supportDimensions": [
"4*4"
]
},
{
"name": "TodoCard6x4",
"displayName": "$string:todo_card_6x4_name",
"description": "$string:todo_card_desc",
"src": "./ets/pages/widget/TodoWidget6x4.ets",
"window": {
"designWidth": 720,
"autoDesignWidth": true
},
"colorMode": "light",
"uiSyntax": "arkts",
"isDefault": false,
"updateEnabled": true,
"scheduledUpdateTime": "07:30",
"updateDuration": 1,
"defaultDimension": "6*4",
"supportDimensions": [
"6*4"
]
} }
] ]
} }