From ca97ec7fcdd423a7bea4951aa217650c5707cb35 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 3 Nov 2026 23:00:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BE=85=E5=8A=9E):=20=EF=BC=8B=E5=8F=B7?= =?UTF-8?q?=E8=81=8C=E8=B4=A3=E6=84=9F=E7=9F=A5=20+=20=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=EF=BC=88=E6=89=80=E6=9C=89=E5=8F=AF=E5=86=99?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E6=9C=AC=E9=93=BA=E5=BC=80=E9=80=89=E6=8B=A9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在「待办」视图下点 + 新建待办,其余视图仍新建日程。 新建时把全部可写 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(`_`) 反解出账号与索引后从 `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`,本次改动无新增警告。 --- entry/src/main/ets/common/CardDataService.ets | 121 ++++++ entry/src/main/ets/common/EventDb.ets | 17 +- entry/src/main/ets/common/IcsUtil.ets | 37 ++ entry/src/main/ets/common/PlaceParser.ets | 363 ++++++++++++++++++ entry/src/main/ets/common/TodoService.ets | 75 ++++ .../ets/entryformability/EntryFormAbility.ets | 98 ++++- entry/src/main/ets/pages/EventEditPage.ets | 132 ++++++- entry/src/main/ets/pages/Index.ets | 183 ++++++++- .../main/ets/pages/widget/TodoWidget2x2.ets | 104 +++++ .../main/ets/pages/widget/TodoWidget2x4.ets | 229 +++++++++++ .../main/ets/pages/widget/TodoWidget4x4.ets | 209 ++++++++++ .../main/ets/pages/widget/TodoWidget6x4.ets | 198 ++++++++++ .../main/resources/base/element/string.json | 22 +- .../resources/base/profile/form_config.json | 96 ++++- 14 files changed, 1862 insertions(+), 22 deletions(-) create mode 100644 entry/src/main/ets/common/PlaceParser.ets create mode 100644 entry/src/main/ets/pages/widget/TodoWidget2x2.ets create mode 100644 entry/src/main/ets/pages/widget/TodoWidget2x4.ets create mode 100644 entry/src/main/ets/pages/widget/TodoWidget4x4.ets create mode 100644 entry/src/main/ets/pages/widget/TodoWidget6x4.ets diff --git a/entry/src/main/ets/common/CardDataService.ets b/entry/src/main/ets/common/CardDataService.ets index ecb8ca9..49d959c 100644 --- a/entry/src/main/ets/common/CardDataService.ets +++ b/entry/src/main/ets/common/CardDataService.ets @@ -36,6 +36,19 @@ export class CardAllDay { 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 { eventsJson: string = '[]'; @@ -62,6 +75,15 @@ export class CardData { isToday: boolean = true; // 当前显示的是否为今天(非今天不画红线) selectedDate: number = 0; // 当前卡片显示的那一天 0 点毫秒(点卡片进入日历时定位到这一天) dayCount: number = 0; // 当前显示日的日程条数(含全天) + // ===== 待办卡片专用字段(isTodoCard=true 时才有效)===== + /** 待办卡片列表:TodoCardItem[] JSON(按截止时间升序,未完成在前) */ + todoListJson: string = '[]'; + /** 未完成待办总数(2x2 / 2x4 卡片大数字) */ + todoCount: number = 0; + /** 今日到期待办数(卡片副标题用) */ + todoTodayCount: number = 0; + /** 是否待办卡片(卡片侧据此切换渲染分支) */ + isTodoCard: boolean = false; } export class CardDataService { @@ -381,4 +403,103 @@ export class CardDataService { } 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 { + 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; + } } diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index c676aae..db7dccf 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -644,13 +644,24 @@ export class EventDb { updated++; } else { unchanged++; - // etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中, - // 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释) + // ⭐ UNCHANGED_REMOTE_PATH etag 未变是**常态路径**(REPORT 只给 etag, + // 未变就直接复用本地行、不 GET)。实测 742 行里 99% 走这里。 + // 若只在"更新分支"回填 remotePath,对存量数据等于无效 —— + // 历史行里存的是旧的 uid 猜测值,而 DELETE/PUT 正是用它拼 URL, + // 拼错就 404(服务器上删不掉)。 + // 所以这里必须一并校正 href 与 remotePath。 + const hb: relationalStore.ValuesBucket = {}; if (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'); hp.equalTo('id', found.id); - const hb: relationalStore.ValuesBucket = { 'href': href }; await store.update(hb, hp); } } diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 90481b3..7dc271d 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -815,6 +815,43 @@ export class IcsUtil { '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 里。 * diff --git a/entry/src/main/ets/common/PlaceParser.ets b/entry/src/main/ets/common/PlaceParser.ets new file mode 100644 index 0000000..b50855b --- /dev/null +++ b/entry/src/main/ets/common/PlaceParser.ets @@ -0,0 +1,363 @@ +// entry/src/main/ets/common/PlaceParser.ets +// 从剪贴板文本/链接中解析出地点:支持高德分享链接(含坐标)与微信复制的地址文本(无坐标)。 +// +// 支持的四种链接形态(均实测过): +// ① poi/detail ?__p=,,,,
—— 坐标在 query +// ② share/index /share/index/__q=,, —— ⚠️ 坐标在 PATH 不在 query +// ③ place /place/?lng=&lat= —— 显式命名 +// ④ surl 短链 surl.amap.com/ —— 需跟随 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 { + 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 { + // 从可能夹带其他文字的段落里抽出第一个 http(s) 链接 + const urlMatch: RegExpExecArray | null = /https?:\/\/[^\s"'<>))]+/i.exec(text); + if (urlMatch === null) { + return null; + } + const url: string = urlMatch[0]; + // ① poi/detail: __p=,,,, + const p: ParsedPlace | null = PlaceParser.parseAmapP(url); + if (p !== null) { + return p; + } + // ② share/index: __q=,,(在 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=,,,,
` + * + * ⚠️ 地址段可能自身含逗号(如「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=,,` + * + * ⚠️ 这是唯一坐标藏在 **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/?lng=&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 { + 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=,, + 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 { + 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; + 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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/common/TodoService.ets b/entry/src/main/ets/common/TodoService.ets index bc35d95..7e79f0f 100644 --- a/entry/src/main/ets/common/TodoService.ets +++ b/entry/src/main/ets/common/TodoService.ets @@ -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 { + 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 服务器资源 + 本地软删) */ static async deleteTodo(context: common.Context, localId: number): Promise { const row: LocalEvent | null = await EventDb.queryById(context, localId); diff --git a/entry/src/main/ets/entryformability/EntryFormAbility.ets b/entry/src/main/ets/entryformability/EntryFormAbility.ets index 8f4a5be..d37a956 100644 --- a/entry/src/main/ets/entryformability/EntryFormAbility.ets +++ b/entry/src/main/ets/entryformability/EntryFormAbility.ets @@ -6,6 +6,7 @@ import { BusinessError } from '@kit.BasicServicesKit'; import process from '@ohos.process'; import { CardDataService, CardData } from '../common/CardDataService'; import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner'; +import { TodoService } from '../common/TodoService'; import { LogUtil } from '../common/LogUtil'; 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 { - 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 = - formBindingData.createFormBindingData(data); + formBindingData.createFormBindingData(ev); formProvider.updateForm(formId, binding).catch((err: BusinessError): void => { LogUtil.write(`卡片 ${formId} 更新失败: ${err.message}`); }); - LogUtil.write(`卡片 ${formId} 数据已推送`); + LogUtil.write(`卡片 ${formId} 数据已推送(日程+待办)`); }).catch((err: BusinessError): void => { LogUtil.write(`卡片数据构建失败: ${err.message}`); }); @@ -84,12 +101,31 @@ export default class EntryFormAbility extends FormExtensionAbility { CardDataService.clearDayOffset(formId); } - /** 卡片上「‹ / › / 回到今天」按钮触发:message 里带 pageAction=prev|next|today */ + /** + * 卡片事件:既处理日程翻页,也处理待办打勾。 + * + * ⚠️ 不用 JSON.parse 解析 message —— 不同系统版本下格式可能是 + * `{params:{...}}` 也可能是直接 `{...}`,格式差异会让 JSON 解析拿到 undefined。 + * 统一用**子串匹配**,稳且不依赖具体结构。 + */ onFormEvent(formId: string, message: string): void { LogUtil.init(this.context); + + // ===== 待办打勾:message 里带 todoCheck= ===== + 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); let next: number = cur; - // 不用 JSON 解析:不同版本 message 的格式可能是 {params:{...}} 或直接 {...},直接子串匹配更稳 if (message.indexOf('prev') >= 0) { next = cur - 1; } else if (message.indexOf('next') >= 0) { @@ -101,4 +137,54 @@ export default class EntryFormAbility extends FormExtensionAbility { LogUtil.write(`卡片翻页: formId=${formId} ${cur} → ${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}`); + }); + } } diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 7f689d4..492503f 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -2,7 +2,7 @@ // 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程) import { curves, router, KeyboardAvoidMode } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; -import { BusinessError } from '@kit.BasicServicesKit'; +import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { notificationManager } from '@kit.NotificationKit'; import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore'; import { EventDb, LocalEvent } from '../common/EventDb'; @@ -14,6 +14,7 @@ import { LogUtil } from '../common/LogUtil'; import { IcsUtil } from '../common/IcsUtil'; import { RruleUtil } from '../common/RruleUtil'; import { PlaceSuggest, PlaceItem } from '../common/PlaceSuggest'; +import { PlaceParser, ParsedPlace } from '../common/PlaceParser'; /** 重复系列某次发生的前后边界(用于 EXDATE / UNTIL / 拆分) */ class SeriesBounds { @@ -443,6 +444,123 @@ struct EventEditPage { .padding({ top: 4, bottom: 4 }) } + /** + * 从剪贴板粘贴并解析地点。 + * + * ⚠️ 必须由 PasteButton 的 onClick 触发,权限仅在回调内有效。 + * 在回调外调用 pasteboard.getData() 会因缺少权限而失败。 + */ + private async pastePlace(): Promise { + 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 { + 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 { // ⭐ 顺序很关键:先收起浮层,再写文本。 @@ -1624,6 +1742,18 @@ struct EventEditPage { } }) .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('地图') .fontSize(13) diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index ff7c4e9..71ff51f 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -66,6 +66,15 @@ struct Index { @State events: DisplayEvent[] = []; @State todos: DisplayEvent[] = []; // 待办(VTODO,单独展示) @State todoBusy: boolean = false; // 正在写回待办状态(防连点) + // ⭐ 待办新建模式:true=新建(弹层显示日历本选择),false=编辑既有待办 + @State todoCreating: boolean = false; + // 新建待办时可选的日历本(铺开供用户选,与新建日程一致) + @State todoBooks: CalSource[] = []; + // 新建待办时选中的日历本 calKey + @State todoCreateBookKey: string = ''; + // calKey → 日历本集合 URL 的映射(CalSource 本身不带 href,需从账号解析) + private todoBookHrefs: Map = new Map(); + // ⭐ TODO_EDITOR 待办编辑弹框状态 @State todoEditShow: boolean = false; @State todoEditId: number = 0; @@ -1183,7 +1192,13 @@ struct Index { .shadow({ radius: 8, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 2 }) .margin(24) .onClick(() => { - this.addEvent(); + // ⭐ 职责感知:待办视图下新建待办,其余视图新建日程。 + // 原来固定 addEvent(),导致在待办页点 + 却跳到日程编辑页,与当前语境不符。 + if (this.mode === 'todo') { + this.openTodoCreator(); + } else { + this.addEvent(); + } }) // 未同意《隐私政策》与《用户协议》:整页门禁盖在最上层(不申请权限、不加载数据) @@ -2747,14 +2762,15 @@ struct Index { }) Column({ space: 12 }) { - // 标题行 + // 标题行(新建/编辑两态) Row({ space: 8 }) { - Text('编辑待办') + Text(this.todoCreating ? '新建待办' : '编辑待办') .fontSize(17) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.text_primary')) Blank() - if (this.todoEditWritable) { + // 新建时没有可删的东西,隐藏删除入口 + if (!this.todoCreating && this.todoEditWritable) { Text('删除') .fontSize(14) .fontColor($r('app.color.error')) @@ -2765,7 +2781,42 @@ struct Index { } .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 }) { Text('日历本') .fontSize(12) @@ -2888,7 +2939,7 @@ struct Index { .onClick(() => { this.todoEditShow = false; }) - Button(this.todoBusy ? '保存中…' : '保存') + Button(this.todoBusy ? '保存中…' : (this.todoCreating ? '创建' : '保存')) .layoutWeight(1) .height(44) .borderRadius(12) @@ -3100,8 +3151,81 @@ struct Index { return out; } + /** + * 打开「新建待办」弹层。 + * + * 与 openTodoEditor 的区别:不预填任何既有数据,todoEditId=0, + * 并把**所有可写日历本铺开**供用户选择。 + * + * 默认选中策略:优先 Inbox(多数 CalDAV 服务器把待办收件箱叫 inbox/Inbox/收件箱), + * 找不到就选第一个可写的本 —— 让用户少点一步,但仍可改。 + */ + private async openTodoCreator(): Promise { + 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 形如 "_" + 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 { + this.todoCreating = false; this.todoEditId = t.id; this.todoEditTitle = t.title; this.todoEditDesc = t.description; @@ -3141,7 +3265,15 @@ struct Index { /** 保存待办编辑(标题 / 备注 / 截止时间) */ private async saveTodoEdit(): Promise { 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; } if (!this.todoEditWritable) { @@ -3177,6 +3309,43 @@ struct Index { } } + /** 保存新建的待办 */ + private async saveNewTodo(ctx: common.Context): Promise { + 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 { this.getUIContext().showAlertDialog({ diff --git a/entry/src/main/ets/pages/widget/TodoWidget2x2.ets b/entry/src/main/ets/pages/widget/TodoWidget2x2.ets new file mode 100644 index 0000000..a301b9a --- /dev/null +++ b/entry/src/main/ets/pages/widget/TodoWidget2x2.ets @@ -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()) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/widget/TodoWidget2x4.ets b/entry/src/main/ets/pages/widget/TodoWidget2x4.ets new file mode 100644 index 0000000..1dab05a --- /dev/null +++ b/entry/src/main/ets/pages/widget/TodoWidget2x4.ets @@ -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()) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/widget/TodoWidget4x4.ets b/entry/src/main/ets/pages/widget/TodoWidget4x4.ets new file mode 100644 index 0000000..99690dd --- /dev/null +++ b/entry/src/main/ets/pages/widget/TodoWidget4x4.ets @@ -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) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/widget/TodoWidget6x4.ets b/entry/src/main/ets/pages/widget/TodoWidget6x4.ets new file mode 100644 index 0000000..688d4b4 --- /dev/null +++ b/entry/src/main/ets/pages/widget/TodoWidget6x4.ets @@ -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) + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 65d474c..1458784 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -51,6 +51,26 @@ { "name": "sync_work_desc", "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": "待办服务卡片:查看未完成待办,可直接点击完成" } ] -} \ No newline at end of file +} diff --git a/entry/src/main/resources/base/profile/form_config.json b/entry/src/main/resources/base/profile/form_config.json index 3bf7963..63b942e 100644 --- a/entry/src/main/resources/base/profile/form_config.json +++ b/entry/src/main/resources/base/profile/form_config.json @@ -16,7 +16,9 @@ "scheduledUpdateTime": "07:30", "updateDuration": 1, "defaultDimension": "2*2", - "supportDimensions": ["2*2"] + "supportDimensions": [ + "2*2" + ] }, { "name": "WidgetCard2x4", @@ -34,7 +36,9 @@ "scheduledUpdateTime": "07:30", "updateDuration": 1, "defaultDimension": "2*4", - "supportDimensions": ["2*4"] + "supportDimensions": [ + "2*4" + ] }, { "name": "WidgetCard4x4", @@ -52,7 +56,9 @@ "scheduledUpdateTime": "07:30", "updateDuration": 1, "defaultDimension": "4*4", - "supportDimensions": ["4*4"] + "supportDimensions": [ + "4*4" + ] }, { "name": "WidgetCard6x4", @@ -70,7 +76,89 @@ "scheduledUpdateTime": "07:30", "updateDuration": 1, "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" + ] } ] }