From 719c68202b610b1906ab6b655b708176a607a505 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 20 Oct 2026 21:59:23 +0800 Subject: [PATCH] =?UTF-8?q?v69=EF=BC=9A=E6=97=A5=E7=A8=8B=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=8D=A1=E7=89=87=E5=AE=8C=E5=85=A8=E7=85=A7=20SyncCa?= =?UTF-8?q?lendar=20=E9=87=8D=E5=81=9A=EF=BC=88=E5=9B=9B=E7=A7=8D=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=20+=20=E7=BF=BB=E9=A1=B5=20+=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=BD=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 上一版卡片是"首页概览"(今日日程 + 在做/待办/逾期 + 纪念日), 是照着首页数据自己设计的版式。这一批按用户要求换成 **SyncCalendar 那套日程卡**: ## 四块尺寸(与 SyncCalendar 一一对应) - 2×2 日期 / 星期+农历 / 当天条数大数字 + + - 2×4 左半同 2×2,右半「接下来两条」 - 4×4 `‹ 10月20日 / 九月初一 › [今|+]` + 当天日程(时间轴 / 列表) - 6×4 同 4×4,更宽(小时 40vp) ## 翻页(这是"照 SyncCalendar"的核心) - 卡片按钮走 `postCardAction({action:'message', params:{pageAction}})`, `FbFormAbility.onFormEvent` 改偏移 → 重新取数 → 推回卡片; - **每张卡一份偏移量**,落 `preferences`(`data/FbCardForms.ets`): SyncCalendar 只放内存 Map,进程被回收就丢 ⇒ 系统每 30 分钟刷新一次, 会把用户翻到的"下周三"悄悄拽回今天。家书这边连 `updateDuration` 的定时刷新 也按各自偏移重建(`onUpdateForm` 先 `getOffset` 再 `push`)。 - 偏移夹取 ±366 天:message 是外部输入,算到离谱年份会 Invalid Date(白卡)。 ## 点卡片要落到"卡片显示的那一天" - 卡片 router 带 `openDate`(**毫秒字符串**,数字参数是 int32,13 位会溢出) 与 `widgetAction=add`(点的是+); - `EntryAbility.applyWantModule` 解析后写 `FbAppState.calJumpIso/Create/Seq`; - 日程页两条入口都接上:冷启动 `aboutToAppear`、热启动 `@Monitor('app.calJumpSeq')` (应用开着时点卡片不会重建页面,只靠 aboutToAppear 会"点了没反应")。 ## 数据 - 换接口:`/api/v1/calendar?from=<那天>&to=<那天>`(原来打 `/home`)。 `authProbe: true` + 显式带 token 的硬要求不变(后台 401 不能把用户踢下线)。 - 农历行(`kind=lunar`)抽成抬头那一行小字,其余衍生条目(节气/节假日)照常进列表。 - 时间轴复用 `common/FbTimeline`(与日程页同一套算法), 卡片侧只做"比例 → vp"的换算(`fbCardTlLines`,纯函数、可断言)。 - 显示方式(时间轴/列表)跟「日程设置」联动;今天才画当前时间红线。 - 应用内改完日程 → `FB_CARD_FORMS.pushAll()` 立刻刷新桌面卡片 (不必等 30 分钟;推失败自动注销该 formId)。 ## 验证 - `assembleHap` BUILD SUCCESSFUL;已装机(5NC0225B27004718)。 - `_fb_arkts_guard.js` **856 / 0**(v66 组按新契约重写:四尺寸登记、 字段名与数据类配对、数组必须 JSON 字符串、翻页三动作、 `onUpdateForm` 按偏移重建、冷/热启动两条入口)。 - `_fb_v59d_falsify.py` **146 / 0**(新增 6 条注入:定时刷新丢偏移、 偏移不落盘、4×4 少「下一天」、「热启动不响应」、不读 openDate、 字段名拼错、去 authProbe)。 ⚠️ 其中"热启动"那条第一次注入**打偏了**:它先命中了文档注释里那半句 `@Monitor('app.calJumpSeq')` ⇒ 真正的装饰器没被改,守卫照样绿。 已把字面量从注释里去掉(本项目"注释里别抄字面标签"的老坑)。 - 真机跳转 `_fb_v69_card_jump.py`: · `openDate=今天+3天` + `widgetAction=add` ⇒ 日程页落在 **10月23日**, 且「新建日程」浮层打开、日期就是 10月23日(dump 里 26 个浮层节点); · 只带 `openDate` ⇒ 同样落在 10月23日,**不**弹新建浮层。 --- entry/src/main/ets/common/Store.ets | 19 + entry/src/main/ets/data/FbCardData.ets | 503 +++++++++---- entry/src/main/ets/data/FbCardForms.ets | 197 +++++ .../main/ets/entryability/EntryAbility.ets | 44 +- .../ets/entryformability/FbFormAbility.ets | 86 ++- .../ets/pages/calendar/FbCalendarPage.ets | 57 ++ entry/src/main/ets/pages/card/FbCard2x2.ets | 229 +++--- entry/src/main/ets/pages/card/FbCard2x4.ets | 321 +++++---- entry/src/main/ets/pages/card/FbCard4x4.ets | 672 +++++++++++++----- entry/src/main/ets/pages/card/FbCard6x4.ets | 522 ++++++++++++++ .../main/resources/base/element/string.json | 8 +- .../resources/base/profile/form_config.json | 17 + 12 files changed, 2070 insertions(+), 605 deletions(-) create mode 100644 entry/src/main/ets/data/FbCardForms.ets create mode 100644 entry/src/main/ets/pages/card/FbCard6x4.ets diff --git a/entry/src/main/ets/common/Store.ets b/entry/src/main/ets/common/Store.ets index 394c8ea..3036df9 100644 --- a/entry/src/main/ets/common/Store.ets +++ b/entry/src/main/ets/common/Store.ets @@ -36,6 +36,25 @@ export class FbAppState { /** 当前所在模块 key(home / people / movies …) */ @Trace activeModule: string = 'home'; + /** + * 桌面日程卡片点进来时要落到哪一天(`YYYY-MM-DD`,空 = 没有卡片跳转请求)。 + * + * 🔴 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 + * SyncCalendar 的做法是卡片 `router` 时带 `openDate`(毫秒时间戳字符串), + * 应用启动后把日程页定位到那一天。 + * + * ⚠️ 为什么要做成**三个 @Trace 字段**而不是一个"待处理参数"对象: + * ① 卡片可以在应用**已经开着**时被点(热启动走 `onNewWant`,页面不会重建) + * ⇒ 必须有一个会触发重渲染的信号,所以用 `calJumpSeq` 自增; + * ② 页面是 `@ComponentV2`,`@Monitor` 只能盯**可观察的字段路径** + * ⇒ 放在 `FbAppState`(`@ObservedV2`)里最省事,且 Index/日程页都能看到。 + */ + @Trace calJumpIso: string = ''; + /** 卡片上是「+」进来的 ⇒ 进日程页后直接开"新建日程"浮层 */ + @Trace calJumpCreate: boolean = false; + /** 跳转请求序号(页面 `@Monitor` 它;同一个日期连点两次也能再触发一次) */ + @Trace calJumpSeq: number = 0; + /** 是否宽屏(窗口短边 ≥ 600vp)—— 决定抽屉是「常驻侧栏」还是「滑出层」 */ @Trace wideLayout: boolean = false; diff --git a/entry/src/main/ets/data/FbCardData.ets b/entry/src/main/ets/data/FbCardData.ets index 99f4150..8600ba2 100644 --- a/entry/src/main/ets/data/FbCardData.ets +++ b/entry/src/main/ets/data/FbCardData.ets @@ -1,26 +1,23 @@ /** - * 家书 FamilyBook · 桌面服务卡片数据(鸿蒙端) + * 家书 FamilyBook · **日程**桌面卡片数据(鸿蒙端) * - * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 - * ⇒ 参照 `SyncCalendar` 的 `CardDataService`:卡片**不直连数据库**, - * 而是把"卡片要显示的那几十个字段"先算成一份**扁平的**数据对象 - * (每个字段都是 string / number),再由 `formBindingData` 推给卡片渲染。 + * 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 + * ⇒ 结构照 `D:\Yongquan\code\SyncCalendar` 的 `CardDataService` + 四块卡: + * · 2×2 日期 / 星期农历 / 当天条数; + * · 2×4 左边同 2×2,右边「今天接下来两条」; + * · 4×4 日期翻页条 + 当天日程(时间轴 / 列表,跟应用内设置联动); + * · 6×4 同 4×4,横向更宽。 * - * 为什么不让卡片自己去请求接口: - * ① 卡片的 ArkTS 是**受限子集**(不能随便 await、不能拿 Context 去读 preferences), - * 它只认 `LocalStorage` 里那几个标量; - * ② 逻辑放在这里 ⇒ 2×2 / 2×4 / 4×4 三种尺寸共用**同一份计算**, - * 不会出现"大卡说 5 条日程、小卡说 3 条"这种自相矛盾。 + * 与 SyncCalendar 的**两处有意差异**(都是家书的数据形状决定的,不照抄): + * ① 它读端上本地库,家书读服务端 `GET /api/v1/calendar?from=&to=`; + * ② 它的数据里没有"衍生条目",家书的 `/calendar` 会给农历 / 节气 / 节假日 + * ⇒ 农历行抽成 `lunarText`(抬头那一行),其余衍生条目照常进列表。 * - * 🔴 数据来源只用 **`GET /api/v1/home` 一个接口**: - * 它一次给齐 日期/星期/农历/日子标记/今日日程/统计/最近纪念日, - * 与网页版首页**同源**(服务端 `home()` 的注释写明了这一点)。 - * 多打两个接口(calendar + days)只会让卡片刷新变慢、且口径可能与首页不一致。 - * - * ⚠️ 令牌与服务器地址: - * 卡片进程(FormExtensionAbility)与主应用**共享同一个沙箱**, - * 所以直接复用 `FbTokenStore` / `FbServerStore` 的 preferences 即可, - * **不要在卡片里另存一份**(那样用户在应用里"退出登录",卡片还带着老令牌)。 + * 🔴 三条硬约束(沿用上一版卡片,别丢): + * ① 卡片进程生命周期回调**只活约 10 秒** ⇒ 回调里不同步做长任务; + * ② `FormBindingData` **只认标量** ⇒ 数组一律 `JSON.stringify` 成字符串; + * ③ 卡片是**后台**刷新,请求必须 `authProbe: true` 且显式带 token —— + * 否则一次 401 会触发全局"踢回登录页",用户在应用里用得好好的却被卡片踢下线。 */ import { common } from '@kit.AbilityKit'; @@ -29,68 +26,121 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FB_SERVER_STORE } from './FbServerStore'; import { FB_TOKEN_STORE } from './FbTokenStore'; +import { FB_CAL_SETTINGS } from './FbCalSettings'; import { fbApi, FbApiResult } from './FbHttp'; -import { parseHome } from './FbParse'; -import { FbCalEvent, FbHomeData, FbMark, FbRecentDay } from '../model/Types'; +import { parseCalendar } from './FbParse'; +import { FbCalEvent } from '../model/Types'; +import { FbTimelineUtil, FbTimelineData, FbTimeBlock, FbViewRange } from '../common/FbTimeline'; const DOMAIN: number = 0x0000; const TAG: string = 'FbCard'; -/** 卡片上的一条日程(扁平化:卡片只认标量) */ -export class FbCardEvent { - /** ForEach 的 key(**必须唯一**,否则同名的两条只渲染第一条) */ +/** 卡片上的一行日程(扁平标量,卡片侧 `JSON.parse` 后 ForEach) */ +export class FbCardRow { + /** ForEach 的 key(**必须唯一**:同名两条只渲染一条,踩过) */ key: string = ''; /** `09:00` / `全天` */ time: string = ''; + /** `10:30`(全天为空) */ + endTime: string = ''; title: string = ''; - /** 日历本色标(直接当左侧色条用) */ + /** 所属日历本色标(左侧色条) */ color: string = '#A84A28'; + /** 所属日历本名(4×4 列表右侧小字) */ + calName: string = ''; + /** 正在进行中(仅"今天"的有时间日程) */ + isNow: boolean = false; } -/** 卡片上的一条纪念日倒计时 */ -export class FbCardDay { +/** 时间轴上的一块(比例形式;视图侧按小时高度换算成 vp) */ +export class FbCardBlock { key: string = ''; title: string = ''; - /** 还有几天(0 = 就是今天) */ - days: number = 0; - /** 7 天内 ⇒ 卡片上用品牌色强调 */ - soon: boolean = false; + timeText: string = ''; + color: string = '#A84A28'; + /** 距 0 点的比例 0~1 */ + topRatio: number = 0; + heightRatio: number = 0; + /** 冲突组序号(同一簇重叠日程) */ + groupIndex: number = 0; + /** 组内列号(同列互不重叠) */ + laneIndex: number = 0; + isNow: boolean = false; +} + +/** 时间轴渲染的一"行":要么是一段空白,要么是一个冲突组(组内按列并排) */ +export class FbCardTlBlock { + key: string = ''; + title: string = ''; + timeText: string = ''; + color: string = '#A84A28'; + isNow: boolean = false; + /** 组内上方的空白高度(vp) */ + padTopVp: number = 0; + /** 色块高度(vp) */ + heightVp: number = 0; +} + +export class FbCardTlGroup { + key: string = ''; + heightVp: number = 0; + lanes: FbCardTlBlock[][] = []; +} + +export class FbCardTlLine { + key: string = ''; + /** 空白行高度(kind=0 时有效) */ + blankVp: number = 0; + /** kind=1 时有效 */ + group: FbCardTlGroup | null = null; } /** * 卡片整体数据。 * * ⚠️ **字段名就是 `LocalStorage` 的键名**(卡片页 `@LocalStorageProp('xxx')`), - * 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",不会报错。 + * 两侧改名必须同步 —— 改一边的表现是"卡片永远显示默认值",编译不报错。 */ export class FbCardData { - /** - * 状态:`ok` 正常 / `login` 未登录 / `noserver` 没填服务器 / - * `offline` 连不上(此时若有缓存则显示缓存) / `loading` 刚添加还没拉到。 - */ + /** `ok` / `login` / `noserver` / `offline` / `loading` */ state: string = 'loading'; - /** `10 月 20 日` */ - todayLabel: string = ''; - /** `星期二` */ - weekday: string = ''; - /** `九月初一`(空 = 算不出,卡片不画) */ - lunarShort: string = ''; - /** 日子标记拼成一行:`寒露 · 重阳节` */ - marks: string = ''; - /** 今日日程条数 */ - eventCount: number = 0; - /** 在做 / 待办 / 逾期(三个数字与首页概述卡同源) */ - doing: number = 0; - todo: number = 0; - overdue: number = 0; - /** `FbCardEvent[]` 的 JSON(卡片侧 `JSON.parse` 成数组再 ForEach) */ - eventsJson: string = '[]'; - /** `FbCardDay[]` 的 JSON */ - daysJson: string = '[]'; /** 一行小字提示(未登录 / 离线 / 出错时给用户一个说法) */ hint: string = ''; /** `15:40 更新` */ updatedAt: string = ''; + /** `10月20日` */ + dateMd: string = ''; + /** `今天 10月20日 周二` */ + dateText: string = ''; + /** `星期二` */ + weekday: string = ''; + /** `九月初一`(空 = 算不出 / 那天没有农历行,卡片不画) */ + lunarText: string = ''; + /** 相对今天的天数偏移(0 = 今天) */ + dayOffset: number = 0; + /** 当前显示的是不是今天(只有今天画"当前时间"红线) */ + isToday: boolean = true; + /** 当前显示那天 0 点的毫秒(点卡片进应用时定位到这一天) */ + selectedDate: number = 0; + /** 当天条目数(含全天) */ + dayCount: number = 0; + /** 当天日程(行式,全天在前、其余按时间升序) */ + listJson: string = '[]'; + /** 全天/跨天条目(单独一组,画在时间轴上方) */ + allDayJson: string = '[]'; + /** 时间轴色块(`FbCardBlock[]`) */ + timelineJson: string = '[]'; + /** 「接下来两条」(2×4 右半用) */ + upcomingJson: string = '[]'; + /** 日程画法:`timeline` / `list`(与应用内「日程设置」联动) */ + displayStyle: string = 'timeline'; + /** 时间轴视窗(小时,含起点不含终点) */ + viewStartHour: number = 8; + viewEndHour: number = 18; + /** 当前时刻在当天时间轴上的比例;**-1 = 不画红线**(非今天) */ + nowRatio: number = -1; + /** `15:40` */ + nowLabel: string = ''; } /** 两位补零 */ @@ -98,74 +148,31 @@ function pad2(v: number): string { return v < 10 ? '0' + v.toString() : v.toString(); } -/** 当前时刻 `HH:mm`(卡片右上角"更新时间") */ +/** 当前时刻 `HH:mm` */ function clockText(): string { const d: Date = new Date(); return pad2(d.getHours()) + ':' + pad2(d.getMinutes()); } -/** 把接口载荷铺进卡片数据 */ -function applyHome(home: FbHomeData, out: FbCardData): void { - out.todayLabel = home.todayLabel; - out.weekday = home.weekday; - out.lunarShort = home.lunarShort; - out.eventCount = home.todayEvents.length; - out.doing = home.stat.doing; - out.todo = home.stat.todo; - out.overdue = home.stat.overdue; - // 日子标记:最多两个(卡片上那一行放三个就会折行,反而看不清) - let marks: string = ''; - let n: number = 0; - for (const mk of home.marks as FbMark[]) { - if (n >= 2) { - break; - } - marks += (marks === '' ? '' : ' · ') + mk.word; - n++; - } - out.marks = marks; - // 今日日程:最多 6 条(4×4 也只放得下 5~6 行) - const events: FbCardEvent[] = []; - let idx: number = 0; - for (const e of home.todayEvents as FbCalEvent[]) { - if (idx >= 6) { - break; - } - const it: FbCardEvent = new FbCardEvent(); - it.key = 'e' + idx.toString(); - it.time = e.allDay || e.time === '' ? '全天' : e.time; - it.title = e.title === '' ? '(无标题)' : e.title; - it.color = e.color !== '' ? e.color : '#A84A28'; - events.push(it); - idx++; - } - out.eventsJson = JSON.stringify(events); - // 最近纪念日:最多 3 条(服务端已按"最近的在前"排好,**不要再排**) - const days: FbCardDay[] = []; - let di: number = 0; - for (const rd of home.recentDays as FbRecentDay[]) { - if (di >= 3) { - break; - } - const it: FbCardDay = new FbCardDay(); - it.key = 'd' + di.toString(); - it.title = rd.title; - it.days = rd.days; - it.soon = rd.days <= 7; - days.push(it); - di++; - } - out.daysJson = JSON.stringify(days); +const FULL_WEEK: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; +const SHORT_WEEK: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + +/** `Date` → `YYYY-MM-DD`(**本地时区**:服务端也按本地自然日给条目) */ +function isoOf(d: Date): string { + return d.getFullYear().toString() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } -/** 缓存用的 preferences(卡片断网时显示上次成功的数据) */ -const CACHE_STORE: string = 'fb_card'; -const CACHE_KEY: string = 'home_json'; +/* ══════════════════════════════════════════════════════════════ + 缓存(断网时显示上次成功取到的数据) + ══════════════════════════════════════════════════════════════ */ -async function saveCache(ctx: common.Context, payload: string): Promise { +const CACHE_STORE: string = 'fb_card'; +const CACHE_PREFIX: string = 'd_'; + +async function saveCache(ctx: common.Context, iso: string, payload: string): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE); - await store.put(CACHE_KEY, payload); + await store.put(CACHE_PREFIX + iso, payload); // ⚠️ 必须 flush:卡片进程随时可能被回收,不 flush 等于没写 await store.flush(); } catch (err) { @@ -174,28 +181,168 @@ async function saveCache(ctx: common.Context, payload: string): Promise { } } -async function loadCache(ctx: common.Context): Promise { +async function loadCache(ctx: common.Context, iso: string): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(ctx, CACHE_STORE); - const v: preferences.ValueType = await store.get(CACHE_KEY, ''); + const v: preferences.ValueType = await store.get(CACHE_PREFIX + iso, ''); return typeof v === 'string' ? v : ''; } catch (err) { return ''; } } +/* ══════════════════════════════════════════════════════════════ + 组装 + ══════════════════════════════════════════════════════════════ */ + +/** 这一天第几分钟(`HH:mm` → 分钟;认不出返回 -1) */ +function minutesOf(hhmm: string): number { + return FbTimelineUtil.minutesOf(hhmm); +} + +/** 一条日程 → 卡片行 */ +function rowOf(e: FbCalEvent, idx: number, nowMin: number, isToday: boolean): FbCardRow { + const r: FbCardRow = new FbCardRow(); + r.key = 'r' + idx.toString() + '_' + e.kind + '_' + e.id.toString() + '_' + e.time; + const allDay: boolean = e.allDay || minutesOf(e.time) < 0; + r.time = allDay ? '全天' : e.time; + r.endTime = allDay ? '' : e.endTime; + // 标题为空时用日历本名兜底(农历/节气这类衍生条目),别显示"(无标题)" + r.title = e.title !== '' ? e.title : (e.calName !== '' ? e.calName : '未命名日程'); + r.color = e.color; + r.calName = e.calName; + if (!allDay && isToday) { + const s: number = minutesOf(e.time); + let en: number = minutesOf(e.endTime); + if (en <= s) { + en = s + 60; + } + r.isNow = nowMin >= s && nowMin < en; + } + return r; +} + +/** 全天在前、其余按开始时间升序(时间相同按标题,保证顺序稳定) */ +function byDayOrder(a: FbCalEvent, b: FbCalEvent): number { + const sa: number = a.allDay || minutesOf(a.time) < 0 ? -1 : minutesOf(a.time); + const sb: number = b.allDay || minutesOf(b.time) < 0 ? -1 : minutesOf(b.time); + if (sa !== sb) { + return sa - sb; + } + return a.title.localeCompare(b.title); +} + +/** 把接口行铺进卡片数据 */ +function applyRows(rows: FbCalEvent[], out: FbCardData, now: Date): void { + const nowMin: number = now.getHours() * 60 + now.getMinutes(); + + // 1) 农历行 → 抬头那行小字(它的信息量就在"九月初一"这几个字,不必再占一行列表) + const events: FbCalEvent[] = []; + for (const e of rows) { + if (e.kind === 'lunar') { + if (out.lunarText === '') { + out.lunarText = e.title; + } + } else { + events.push(e); + } + } + events.sort(byDayOrder); + + // 2) 行式列表(4×4 列表模式 / 6×4) + const list: FbCardRow[] = []; + const allDay: FbCardRow[] = []; + let idx: number = 0; + for (const e of events) { + const r: FbCardRow = rowOf(e, idx, nowMin, out.isToday); + list.push(r); + if (r.time === '全天') { + allDay.push(r); + } + idx++; + } + out.listJson = JSON.stringify(list); + out.allDayJson = JSON.stringify(allDay); + out.dayCount = list.length; + + // 3) 时间轴(只放"带时间"的条目;全天另画在顶部 —— 与 `FbTimelineUtil` 的约定一致) + const tl: FbTimelineData = FbTimelineUtil.build(events, out.isToday, nowMin); + const blocks: FbCardBlock[] = []; + for (const b of tl.blocks as FbTimeBlock[]) { + const cb: FbCardBlock = new FbCardBlock(); + cb.key = b.key; + cb.title = b.title; + cb.timeText = b.timeText; + cb.color = b.color; + cb.topRatio = b.topRatio; + cb.heightRatio = b.heightRatio; + cb.groupIndex = b.groupIndex; + cb.laneIndex = b.laneIndex; + cb.isNow = b.isNow; + blocks.push(cb); + } + out.timelineJson = JSON.stringify(blocks); + const range: FbViewRange = FbTimelineUtil.viewRange(tl.blocks); + out.viewStartHour = range.startHour; + out.viewEndHour = range.endHour; + + // 4) 「接下来两条」(2×4 右半):今天取"还没开始的",别的日期取当天头两条 + const upcoming: FbCardRow[] = []; + for (const r of list) { + if (upcoming.length >= 2) { + break; + } + if (r.time === '全天') { + continue; + } + if (out.isToday && minutesOf(r.time) < nowMin) { + continue; + } + upcoming.push(r); + } + // 今天已经没有"还没开始的"了 ⇒ 退回头两条有时间的(别让右半空着说"今日日程已结束") + if (upcoming.length === 0) { + for (const r of list) { + if (upcoming.length >= 2) { + break; + } + if (r.time !== '全天') { + upcoming.push(r); + } + } + } + out.upcomingJson = JSON.stringify(upcoming); +} + /** * 组装一份卡片数据(异步)。 * + * @param dayOffset 相对今天的偏移(卡片翻页:负=过去,0=今天,正=未来) + * * ⚠️ **永远不抛错**:卡片进程抛出去只会让桌面显示一张空白卡。 - * 所有失败都翻译成 `state` + `hint`,由卡片自己画一句人话。 */ -export async function fbCardBuild(ctx: common.Context): Promise { +export async function fbCardBuild(ctx: common.Context, dayOffset: number): Promise { const out: FbCardData = new FbCardData(); + const now: Date = new Date(); out.updatedAt = clockText(); + out.nowLabel = out.updatedAt; + out.dayOffset = dayOffset; + out.isToday = dayOffset === 0; + out.nowRatio = out.isToday ? (now.getHours() * 60 + now.getMinutes()) / 1440 : -1; + + const base: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset); + out.selectedDate = base.getTime(); + out.dateMd = (base.getMonth() + 1).toString() + '月' + base.getDate().toString() + '日'; + out.weekday = FULL_WEEK[base.getDay()]; + const prefix: string = dayOffset === 0 ? '今天 ' : (dayOffset === 1 ? '明天 ' : (dayOffset === -1 ? '昨天 ' : '')); + out.dateText = prefix + out.dateMd + ' ' + SHORT_WEEK[base.getDay()]; + try { await FB_SERVER_STORE.init(ctx); await FB_TOKEN_STORE.init(ctx); + // 显示方式(时间轴 / 列表)与应用内设置联动 —— 与 SyncCalendar 的 displayStyle 同一个思路 + await FB_CAL_SETTINGS.init(ctx); + out.displayStyle = FB_CAL_SETTINGS.getStyle(); } catch (err) { hilog.warn(DOMAIN, TAG, '卡片环境预加载失败:%{public}s', JSON.stringify(err)); } @@ -210,21 +357,31 @@ export async function fbCardBuild(ctx: common.Context): Promise { out.hint = '先在「家书」里登录'; return out; } - /* 🔴 `authProbe: true`:卡片是**后台**刷新,401 时**绝不能**触发全局"踢回登录页" - (那个回调会把令牌清掉 —— 用户在应用里用得好好的,桌面卡片一刷新把他踢下线, - 这是最说不清的 bug)。这里自己处理"登录失效"的提示即可。 - ⚠️ 代价:`authProbe` 会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */ - const r: FbApiResult = await fbApi('/api/v1/home', { token: token, authProbe: true }); + + const iso: string = isoOf(base); + /* 只查**这一天**(服务端的 `from`/`to` 都是闭区间)。 + ⚠️ 查询参数要显式声明成 `Record` 再赋值: + ArkTS 不允许"字面量直接塞进 `Record`",会报 arkts-no-untyped-obj-literals。 */ + const query: Record = {}; + query['from'] = iso; + query['to'] = iso; + /* `authProbe: true` ⇒ 401 时**不触发全局"踢回登录页"**(见文件头约束 ③)。 + ⚠️ 代价:它会跳过 `fbApi` 的"自动补令牌",所以**必须显式传 token**。 */ + const r: FbApiResult = await fbApi('/api/v1/calendar', { + token: token, + authProbe: true, + query: query + }); if (r.ok) { - applyHome(parseHome(r.data), out); + applyRows(parseCalendar(r.data), out, now); out.state = 'ok'; out.hint = ''; - await saveCache(ctx, r.data); + await saveCache(ctx, iso, r.data); return out; } - const cached: string = await loadCache(ctx); + const cached: string = await loadCache(ctx, iso); if (cached !== '') { - applyHome(parseHome(cached), out); + applyRows(parseCalendar(cached), out, now); out.state = 'offline'; out.hint = r.status === 401 ? '登录已失效,请重新登录' : '离线 · 显示上次的数据'; return out; @@ -236,10 +393,96 @@ export async function fbCardBuild(ctx: common.Context): Promise { } /** - * 供卡片页判断"这条日程是不是第一行"之类的**纯函数**已经不需要了 —— - * 卡片直接按数组顺序画。这里只留一个给测试/守卫用的口径函数: - * 卡片上**最多**显示几条日程(大小卡共用同一个上限,避免"两卡数字不一致")。 + * 时间轴「行」列表:把比例形式的色块换算成 vp 高度,供视图直接 `ForEach`。 + * + * 为什么不在卡片里算:卡片页的 ArkTS 是受限子集、也不好写测试; + * 这里纯函数、只吃标量,守卫与反向验证都能直接盯。 + * + * @param blocks `FbCardBlock[]`(已按 topRatio 升序) + * @param startHour 视窗起点小时 + * @param hourVp 1 小时 = 多少 vp */ -export function fbCardMaxEvents(): number { - return 6; +export function fbCardTlLines(blocks: FbCardBlock[], startHour: number, hourVp: number): FbCardTlLine[] { + const lines: FbCardTlLine[] = []; + // 1) 按冲突组归并(组号在 `FbTimelineUtil.build` 里已算好) + const order: number[] = []; + const groups: Map = new Map(); + for (const b of blocks) { + const arr: FbCardBlock[] | undefined = groups.get(b.groupIndex); + if (arr === undefined) { + groups.set(b.groupIndex, [b]); + order.push(b.groupIndex); + } else { + arr.push(b); + } + } + order.sort((a: number, b: number): number => a - b); + + // 2) 逐组产出:组前补空白 + 组内按列并排 + // ⚠️ 游标从**视窗起点**开始(不是 0 点):8 点视窗下第一组前面的 0~8 点空白 + // 不该画进卡片,否则时间轴一开始就有一大段空格子,看着像"数据算错了"。 + let cursor: number = startHour; // 已画到"当天第几小时"(浮点) + for (const gi of order) { + const bs: FbCardBlock[] = groups.get(gi) ?? []; + if (bs.length === 0) { + continue; + } + let top: number = 1; + let bot: number = 0; + for (const b of bs) { + const t: number = b.topRatio * 24; + const e: number = (b.topRatio + b.heightRatio) * 24; + if (t < top) { + top = t; + } + if (e > bot) { + bot = e; + } + } + if (top < cursor) { + top = cursor; + } + const gap: number = (top - cursor) * hourVp; + if (gap > 0.5) { + const blank: FbCardTlLine = new FbCardTlLine(); + blank.key = 'gap' + gi.toString(); + blank.blankVp = gap; + lines.push(blank); + } + const g: FbCardTlGroup = new FbCardTlGroup(); + g.key = 'g' + gi.toString(); + g.heightVp = Math.max((bot - top) * hourVp, 14); + // 列号可能不连续 ⇒ 用"出现的列号升序"重排,保证 Row 内 layoutWeight 均匀 + const laneIdx: number[] = []; + for (const b of bs) { + if (laneIdx.indexOf(b.laneIndex) < 0) { + laneIdx.push(b.laneIndex); + } + } + laneIdx.sort((a: number, b: number): number => a - b); + for (const li of laneIdx) { + const lane: FbCardTlBlock[] = []; + for (const b of bs) { + if (b.laneIndex !== li) { + continue; + } + const cb: FbCardTlBlock = new FbCardTlBlock(); + cb.key = b.key; + cb.title = b.title; + cb.timeText = b.timeText; + cb.color = b.color; + cb.isNow = b.isNow; + cb.padTopVp = Math.max((b.topRatio * 24 - top) * hourVp, 0); + cb.heightVp = Math.max(b.heightRatio * 24 * hourVp, 14); + lane.push(cb); + } + g.lanes.push(lane); + } + const line: FbCardTlLine = new FbCardTlLine(); + line.key = g.key; + line.group = g; + lines.push(line); + cursor = bot; + } + return lines; } diff --git a/entry/src/main/ets/data/FbCardForms.ets b/entry/src/main/ets/data/FbCardForms.ets new file mode 100644 index 0000000..a714a18 --- /dev/null +++ b/entry/src/main/ets/data/FbCardForms.ets @@ -0,0 +1,197 @@ +/** + * 家书 FamilyBook · 桌面卡片的「每张卡」状态(鸿蒙端) + * + * 照 `SyncCalendar/entry/src/main/ets/common/CardDataService.ets` 的那一套: + * · `registerForm / unregisterForm` —— 记住桌面上有哪些卡(推送时要知道推给谁); + * · `getDayOffset / setDayOffset` —— **每张卡各自翻到哪一天**(卡片不能滚到别的日子, + * 只能靠翻页;两张卡翻到不同日期是正常的); + * · `pushAll` —— 应用内改完日程后,让桌面上的卡立刻跟上。 + * + * 🔴 与 SyncCalendar 的**一处有意加强**: + * 它的翻页偏移只放在**内存 Map** 里(进程一被回收就丢),于是系统每 30 分钟的 + * 定时刷新会把用户刚翻到的"下周三"悄悄拽回"今天"。 + * 这里落进 `preferences`:FormExtensionAbility 的进程本来就可能随时被回收, + * "翻页"这种用户意图必须能跨进程活下来,否则表现就是"翻着翻着自己跳回去了"。 + * + * ⚠️ 偏移量做**夹取**(±366 天):`onFormEvent` 的 message 是外部输入, + * 连续点 ‹ 几百次也不该把它算成一个荒唐的年份(`new Date(y, m, d + 99999)` + * 在部分设备上会算出 Invalid Date,卡片直接白掉)。 + */ + +import { common } from '@kit.AbilityKit'; +import { preferences } from '@kit.ArkData'; +import { formBindingData, formProvider } from '@kit.FormKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { FbCardData, fbCardBuild } from './FbCardData'; + +const DOMAIN: number = 0x0000; +const TAG: string = 'FbCardForms'; + +const STORE_NAME: string = 'fb_card_forms'; +const K_IDS: string = 'form_ids'; +const K_OFF: string = 'day_offsets'; + +/** 翻页最多到前后一年(防外部输入把日期算成 Invalid Date) */ +const MAX_OFFSET: number = 366; + +/** 条目分隔符用 `;`、键值用 `=`:formId 是数字串,不会撞这两个字符 */ +const SEP_ITEM: string = ';'; +const SEP_KV: string = '='; + +class FbCardFormsImpl { + private async store(ctx: common.Context): Promise { + return preferences.getPreferences(ctx, STORE_NAME); + } + + private async readStr(ctx: common.Context, key: string): Promise { + try { + const p: preferences.Preferences = await this.store(ctx); + const v: preferences.ValueType = await p.get(key, ''); + return typeof v === 'string' ? v : ''; + } catch (err) { + return ''; + } + } + + private async writeStr(ctx: common.Context, key: string, val: string): Promise { + try { + const p: preferences.Preferences = await this.store(ctx); + await p.put(key, val); + // 卡片进程随时可能被回收:不 flush 等于没写 + await p.flush(); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '写卡片状态失败(%{public}s):%{public}s', key, e.message); + } + } + + /* ---- 偏移量(formId → 相对今天的天数)---- */ + + private parseOffsets(raw: string): Map { + const map: Map = new Map(); + for (const item of raw.split(SEP_ITEM)) { + if (item === '') { + continue; + } + const at: number = item.indexOf(SEP_KV); + if (at <= 0) { + continue; + } + const id: string = item.substring(0, at); + const off: number = parseInt(item.substring(at + 1), 10); + if (!isNaN(off)) { + map.set(id, off); + } + } + return map; + } + + private joinOffsets(map: Map): string { + const parts: string[] = []; + map.forEach((off: number, id: string): void => { + parts.push(id + SEP_KV + off.toString()); + }); + return parts.join(SEP_ITEM); + } + + /** 读某张卡翻到第几天(读不到按今天 = 0) */ + async getOffset(ctx: common.Context, formId: string): Promise { + const map: Map = this.parseOffsets(await this.readStr(ctx, K_OFF)); + const v: number | undefined = map.get(formId); + return v === undefined ? 0 : v; + } + + /** 写某张卡的偏移(已夹取到 ±366) */ + async setOffset(ctx: common.Context, formId: string, off: number): Promise { + let v: number = Math.round(off); + if (v > MAX_OFFSET) { + v = MAX_OFFSET; + } + if (v < -MAX_OFFSET) { + v = -MAX_OFFSET; + } + const map: Map = this.parseOffsets(await this.readStr(ctx, K_OFF)); + map.set(formId, v); + await this.writeStr(ctx, K_OFF, this.joinOffsets(map)); + return v; + } + + async clearOffset(ctx: common.Context, formId: string): Promise { + const map: Map = this.parseOffsets(await this.readStr(ctx, K_OFF)); + if (map.delete(formId)) { + await this.writeStr(ctx, K_OFF, this.joinOffsets(map)); + } + } + + /* ---- 卡片注册表 ---- */ + + private async ids(ctx: common.Context): Promise { + const raw: string = await this.readStr(ctx, K_IDS); + const out: string[] = []; + for (const id of raw.split(SEP_ITEM)) { + if (id !== '' && out.indexOf(id) < 0) { + out.push(id); + } + } + return out; + } + + private async saveIds(ctx: common.Context, list: string[]): Promise { + await this.writeStr(ctx, K_IDS, list.join(SEP_ITEM)); + } + + async register(ctx: common.Context, formId: string): Promise { + if (formId === '') { + return; + } + const list: string[] = await this.ids(ctx); + if (list.indexOf(formId) < 0) { + list.push(formId); + await this.saveIds(ctx, list); + } + } + + async unregister(ctx: common.Context, formId: string): Promise { + const list: string[] = (await this.ids(ctx)).filter((id: string): boolean => id !== formId); + await this.saveIds(ctx, list); + } + + /** + * 应用内日程变化后刷新**所有**已添加的卡。 + * + * ⚠️ 按偏移量分组构建:两张卡可能翻在不同日期,逐张构建会重复打同一个接口。 + * ⚠️ 推失败的 formId 直接**注销**(卡片多半已被用户删掉),否则每刷一次都白推一遍。 + */ + async pushAll(ctx: common.Context): Promise { + const list: string[] = await this.ids(ctx); + if (list.length === 0) { + return; + } + const cache: Map = + new Map(); + const stale: string[] = []; + for (const formId of list) { + const off: number = await this.getOffset(ctx, formId); + let binding: formBindingData.FormBindingData | undefined = cache.get(off); + if (binding === undefined) { + const data: FbCardData = await fbCardBuild(ctx, off); + binding = formBindingData.createFormBindingData(data); + cache.set(off, binding); + } + try { + await formProvider.updateForm(formId, binding); + } catch (err) { + const e: BusinessError = err as BusinessError; + hilog.warn(DOMAIN, TAG, '卡片 %{public}s 推送失败(已注销):%{public}s', formId, e.message); + stale.push(formId); + } + } + if (stale.length > 0) { + const keep: string[] = list.filter((id: string): boolean => stale.indexOf(id) < 0); + await this.saveIds(ctx, keep); + } + } +} + +export const FB_CARD_FORMS: FbCardFormsImpl = new FbCardFormsImpl(); diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 3df25d0..104dbf0 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,7 +13,7 @@ import { FB_MODULE_TOKEN } from '../data/FbModuleRepo'; import { FB_KEY_STORE } from '../data/FbKeyStore'; import { FB_ATTACH } from '../data/FbAttachQueue'; import { FB_MODULES } from '../model/Modules'; -import { fbAppState } from '../common/Store'; +import { fbAppState, FbAppState } from '../common/Store'; const DOMAIN: number = 0x0000; @@ -126,8 +126,30 @@ export default class EntryAbility extends UIAbility { if (p === undefined) { return; } + /* ---- ① 桌面日程卡片带来的"看哪一天 / 要不要直接新建" ---- + 卡片按 SyncCalendar 那套传 `openDate`(**毫秒字符串**,数字会溢出 int32) + 与 `widgetAction`(`add` = 点的是卡片上的 +)。 + ⚠️ 日期解析失败就当没传:宁可落到"今天",也不能拿一个 0 去算 —— + `new Date(0)` 是 1970 年,用户会看到一屏空白。 */ + const dateRaw: Object | undefined = p['openDate']; + const dateStr: string = typeof dateRaw === 'string' ? dateRaw : ''; + const actRaw: Object | undefined = p['widgetAction']; + const act: string = typeof actRaw === 'string' ? actRaw : ''; + let jumpIso: string = ''; + let wantCreate: boolean = act === 'add'; + if (dateStr !== '') { + const ms: number = Number(dateStr); + if (!isNaN(ms) && ms > 0) { + jumpIso = this.isoOf(new Date(ms)); + } + } + const raw: Object | undefined = p['fbModule']; - const key: string = typeof raw === 'string' ? raw : ''; + let key: string = typeof raw === 'string' ? raw : ''; + // 卡片只给了日期/动作(没写模块名)⇒ 落日程,与 SyncCalendar 的语义一致 + if (key === '' && (jumpIso !== '' || wantCreate)) { + key = 'calendar'; + } if (key === '') { return; } @@ -142,10 +164,28 @@ export default class EntryAbility extends UIAbility { hilog.warn(DOMAIN, 'FamilyBook', '卡片带来的模块名不认识(已忽略):%{public}s', key); return; } + /* ---- ② 把"看哪一天"交给日程页 ---- + 冷启动时日程页还没建,先写进全局状态(页面 `aboutToAppear` 会读); + 热启动(应用已开着)走 `onNewWant`,页面不会重建 ⇒ 靠 `calJumpSeq` 自增 + 触发页面上的 `@Monitor`。两种入口共用同一份状态,不会"有时跳有时不跳"。 */ + if (jumpIso !== '' || wantCreate) { + const st: FbAppState = fbAppState(); + st.calJumpIso = jumpIso; + st.calJumpCreate = wantCreate; + st.calJumpSeq = st.calJumpSeq + 1; + hilog.info(DOMAIN, 'FamilyBook', '卡片请求日程:%{public}s(新建=%{public}s)', + jumpIso === '' ? '今天' : jumpIso, wantCreate ? '是' : '否'); + } fbAppState().switchModule(key); hilog.info(DOMAIN, 'FamilyBook', '从卡片进入模块:%{public}s', key); } + /** `Date` → `YYYY-MM-DD`(本地时区;与卡片数据层同一个口径) */ + private isoOf(d: Date): string { + const p2 = (v: number): string => v < 10 ? '0' + v.toString() : v.toString(); + return d.getFullYear().toString() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()); + } + /** * 应用**已经在运行**时再从卡片点进来(热启动)走这里。 * diff --git a/entry/src/main/ets/entryformability/FbFormAbility.ets b/entry/src/main/ets/entryformability/FbFormAbility.ets index 9ee5f7a..10d7a3e 100644 --- a/entry/src/main/ets/entryformability/FbFormAbility.ets +++ b/entry/src/main/ets/entryformability/FbFormAbility.ets @@ -1,22 +1,23 @@ /** * 家书 FamilyBook · 服务卡片提供方(鸿蒙端 FormExtensionAbility) * - * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 - * 参照 `SyncCalendar` 的 `EntryFormAbility`: - * · `onAddForm` —— 添加到桌面时先塞一份"空壳",再异步推真实数据; - * · `onUpdateForm` —— 系统定时刷新(`form_config.json` 里 `updateDuration: 1` ⇒ 30 分钟); - * · `onFormEvent` —— 卡片上的「刷新」按钮(`action: 'message'`); - * · `onRemoveForm` —— 移出桌面。 + * 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 + * ⇒ 生命周期与翻页照 `SyncCalendar/entry/src/main/ets/entryformability/EntryFormAbility.ets`: + * · `onAddForm` —— 添加到桌面时先塞"空壳",再异步推真实数据,并**登记**这张卡; + * · `onUpdateForm` —— 系统定时刷新(`updateDuration: 1` ⇒ 30 分钟); + * · `onFormEvent` —— 卡片上的 ‹ / › / 今 三个按钮(`action: 'message'`); + * · `onRemoveForm` —— 移出桌面(注销 + 清掉它的翻页偏移)。 * - * 🔴 三个必须记住的约束: - * ① **生命周期回调返回后进程只活约 10 秒**(官方文档),所以回调里**不能做长任务**。 - * 这里的做法与 SyncCalendar 一致:立刻返回/用本地缓存,真实的网络请求 - * 放在 `.then()` 里异步推 —— 就算被回收,也只是"这次没刷成", - * 下一次系统刷新(30 分钟)还会重试。**卡片数据不是关键数据,允许慢一拍。** - * ② `onAddForm` **必须同步返回**一个 `FormBindingData`(不能是 Promise), - * 否则桌面直接显示"卡片加载失败"。 - * ③ 数据构建失败**绝不能抛**:抛出去的表现是桌面上一张白卡, - * 用户完全不知道发生了什么。⇒ 失败也返回"带一句人话"的壳(见 `FbCardData.hint`)。 + * 🔴 四个必须记住的约束: + * ① **生命周期回调返回后进程只活约 10 秒**(官方文档)⇒ 回调里不能做长任务。 + * 这里的做法与 SyncCalendar 一致:立刻返回/用本地缓存,真实网络请求放在 + * `.then()` 里异步推 —— 就算被回收,也只是"这次没刷成",30 分钟后还会重试。 + * ② `onAddForm` **必须同步返回** `FormBindingData`(不能是 Promise),否则桌面 + * 直接显示"卡片加载失败"。 + * ③ 数据构建失败**绝不能抛**:抛出去的表现是桌面一张白卡,用户完全不知道发生了什么。 + * ⇒ 失败也返回"带一句人话"的壳(`FbCardData.hint`)。 + * ④ **翻页 = 每张卡一份偏移量**(`FB_CARD_FORMS`),推数据时按各自偏移构建 —— + * 系统定时刷新**不能**把用户翻走的日期拽回今天。 */ import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit'; @@ -24,6 +25,7 @@ import { Want } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbCardData, fbCardBuild } from '../data/FbCardData'; +import { FB_CARD_FORMS } from '../data/FbCardForms'; const DOMAIN: number = 0x0000; const TAG: string = 'FbForm'; @@ -32,14 +34,14 @@ export default class FbFormAbility extends FormExtensionAbility { /** * 重新算一遍并推给指定卡片。 * - * ⚠️ 三种尺寸(2×2 / 2×4 / 4×4)共用这一份数据:多算一次就多打一次接口, - * 没有必要 —— 它们只是**排版不同**,数字必须是同一个。 + * ⚠️ 四块卡(2×2 / 2×4 / 4×4 / 6×4)共用这份数据:它们只是**排版不同**, + * 数字与条目必须是同一个 —— 多算一遍既慢又可能口径不一致。 */ - private push(formId: string): void { - fbCardBuild(this.context).then((data: FbCardData): void => { + private push(formId: string, dayOffset: number): void { + fbCardBuild(this.context, dayOffset).then((data: FbCardData): void => { const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data); formProvider.updateForm(formId, binding).then((): void => { - hilog.info(DOMAIN, TAG, '卡片 %{public}s 已更新(%{public}s)', formId, data.state); + hilog.info(DOMAIN, TAG, '卡片 %{public}s 已更新(偏移 %{public}d)', formId, data.dayOffset); }).catch((err: BusinessError): void => { // 卡片可能已经被用户删了 —— 打一条日志就够,不要重试 hilog.warn(DOMAIN, TAG, '卡片 %{public}s 更新失败:%{public}s', formId, err.message); @@ -62,10 +64,14 @@ export default class FbFormAbility extends FormExtensionAbility { const formId: string = this.formIdOf(want); hilog.info(DOMAIN, TAG, '添加卡片:%{public}s', formId); if (formId !== '') { - // 异步补真实数据(不 await:见文件头约束 ①) - this.push(formId); + // 异步:登记 + 补真实数据(不 await:见文件头约束 ①) + FB_CARD_FORMS.register(this.context, formId).then((): void => { + this.push(formId, 0); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '卡片登记失败:%{public}s', err.message); + }); } - // 先给一份"正在取"的壳 —— 卡片上会显示这天/这周的空状态,而不是白屏 + // 先给一份"正在取"的壳 —— 卡片上会显示空态,而不是白屏 const shell: FbCardData = new FbCardData(); shell.state = 'loading'; shell.hint = '正在取数据…'; @@ -74,7 +80,13 @@ export default class FbFormAbility extends FormExtensionAbility { onUpdateForm(formId: string): void { hilog.info(DOMAIN, TAG, '系统定时刷新:%{public}s', formId); - this.push(formId); + // ⚠️ 必须按**这张卡自己的偏移**重建,否则定时刷新会把用户翻走的日期拽回今天 + FB_CARD_FORMS.getOffset(this.context, formId).then((off: number): void => { + this.push(formId, off); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '读卡片偏移失败:%{public}s', err.message); + this.push(formId, 0); + }); } onFormEvent(formId: string, message: string): void { @@ -82,11 +94,33 @@ export default class FbFormAbility extends FormExtensionAbility { ⚠️ 不解析 JSON:不同版本系统给的形态可能是 `{params:{...}}` 或直接 `{...}`, 子串匹配更稳(与 SyncCalendar 同一个取舍)。 */ hilog.info(DOMAIN, TAG, '卡片事件:%{public}s %{public}s', formId, message); - this.push(formId); + FB_CARD_FORMS.getOffset(this.context, formId).then((cur: number): void => { + let next: number = cur; + if (message.indexOf('prev') >= 0) { + next = cur - 1; + } else if (message.indexOf('next') >= 0) { + next = cur + 1; + } else if (message.indexOf('today') >= 0) { + next = 0; + } + FB_CARD_FORMS.setOffset(this.context, formId, next).then((off: number): void => { + this.push(formId, off); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '卡片翻页失败:%{public}s', err.message); + this.push(formId, cur); + }); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '读卡片偏移失败(按今天处理):%{public}s', err.message); + this.push(formId, 0); + }); } onRemoveForm(formId: string): void { - // 本卡片不落任何"每张卡"的状态(没有翻页偏移之类),所以只需记一条日志 hilog.info(DOMAIN, TAG, '移除卡片:%{public}s', formId); + FB_CARD_FORMS.unregister(this.context, formId).then((): Promise => { + return FB_CARD_FORMS.clearOffset(this.context, formId); + }).catch((err: BusinessError): void => { + hilog.warn(DOMAIN, TAG, '注销卡片失败:%{public}s', err.message); + }); } } diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets index cc30603..e0ec69f 100644 --- a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -24,12 +24,16 @@ */ import { FbColors, FbMetric, fbPalette, FbTint, FB_MASK } from '../../common/Theme'; +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; import { FbAppState, fbAppState } from '../../common/Store'; import { FbCalBook, FbCalEvent, isEditableEvent } from '../../model/Types'; import { parseCalendar } from '../../data/FbParse'; import { loadModule } from '../../data/FbModuleLoad'; import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo'; import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../../data/FbCalSettings'; +import { FB_CARD_FORMS } from '../../data/FbCardForms'; import { FB_TOKEN_STORE } from '../../data/FbTokenStore'; import { FbEmptyBox, FbIconButton } from '../../components/Widgets'; import { FbEventSheet } from '../../components/FbEventSheet'; @@ -288,10 +292,45 @@ export struct FbCalendarPage { this.mode = FB_CAL_SETTINGS.getView(); this.style = FB_CAL_SETTINGS.getStyle(); this.pickedIso = this.todayIso(); + // 桌面日程卡片可能指定了"看哪一天 / 直接新建"(冷启动入口) + this.applyCardJump(); this.load(); this.loadBooks(); } + /** + * 桌面日程卡片点进来:定位到卡片显示的那一天;若是点「+」进来的,直接开新建浮层。 + * + * 🔴 两种入口都要照顾(用户 2026-10-20 要求"完全照 SyncCalendar"): + * · 冷启动 —— 页面 `aboutToAppear` 时读一次; + * · 热启动 —— 应用已经开着,`onNewWant` 只改了全局状态、页面不会重建 + * ⇒ 靠下面那个 `@Monitor` 盯住请求序号,再触发一次。 + * ⚠️ `calJumpIso` 为空表示"卡片只点了 +,没指定日期" ⇒ 落到今天(不要清空 pickedIso)。 + */ + private applyCardJump(): void { + const iso: string = this.app.calJumpIso; + if (iso.length === 10) { + this.pickedIso = iso; + const y: number = parseInt(iso.substring(0, 4), 10); + const m: number = parseInt(iso.substring(5, 7), 10); + if (!isNaN(y) && !isNaN(m) && m >= 1 && m <= 12) { + this.showYear = y; + this.showMonth = m; + } + } + if (this.app.calJumpCreate) { + // 用掉就清掉:否则用户关掉新建浮层后,每次回到本页又会自己弹出来 + this.app.calJumpCreate = false; + this.creating = true; + } + } + + /** 卡片在应用已开着时被点(`onNewWant` 改了全局状态)→ 这里响应 */ + @Monitor('app.calJumpSeq') + onCardJump(): void { + this.applyCardJump(); + } + /** * 四个浮层(详情 / 编辑 / 新建 / 设置)任一开着 → 让底部悬浮快捷栏让位。 * @@ -714,6 +753,24 @@ export struct FbCalendarPage { this.creating = false; this.load(); this.loadBooks(); + // 桌面上的日程卡片也要立刻跟上(否则要等系统 30 分钟后的定时刷新) + this.pushCards(); + } + + /** + * 把最新数据推给桌面上所有日程卡片。 + * + * ⚠️ 不 await、失败只记日志:这里是"顺手做的一件好事", + * 推送失败(卡片已被用户删掉等)绝不能影响应用内的保存动作。 + */ + private pushCards(): void { + const ctx: common.Context | undefined = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + FB_CARD_FORMS.pushAll(ctx).catch((err: BusinessError): void => { + hilog.warn(0x0000, 'FbCalendar', '刷新桌面卡片失败:%{public}s', err.message); + }); } /** 详情是不是开着 */ diff --git a/entry/src/main/ets/pages/card/FbCard2x2.ets b/entry/src/main/ets/pages/card/FbCard2x2.ets index fb535be..781ed9d 100644 --- a/entry/src/main/ets/pages/card/FbCard2x2.ets +++ b/entry/src/main/ets/pages/card/FbCard2x2.ets @@ -1,157 +1,134 @@ /** - * 家书 FamilyBook · 2×2 服务卡片(今日概览) + * 家书 FamilyBook · 2×2 服务卡片(日程 · 当天概览) * - * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 + * ⇒ 照 `SyncCalendar/entry/src/main/ets/pages/widget/Widget2x2.ets`: + * 日期(月日)+ 星期/农历 + 当天日程条数大数字;右上角 + 直接新建日程; + * **整卡可点**(空白处也能进应用,并定位到卡片显示的那一天)。 * - * 这块卡只回答一句话:**今天有几件事、最要紧的那件是什么**。 - * 所以版面是「日期 → 大数字 → 一行提示」,不做列表(2×2 放列表只会挤成蚂蚁)。 - * - * ⚠️ 卡片里**不做任何计算**(不排序、不筛选、不拼日期): - * 全部由 `data/FbCardData.ets` 算好塞进 `LocalStorage`(原因见那边的文件头)。 - * 这里只负责画 —— 三种尺寸共用同一份数据,数字必须一致。 + * ⚠️ 动态卡片**不能自己发网络请求**:这里只画 `FbCardData` 推过来的字段, + * 要重新取数只能 `postCardAction({action:'message'})` 交给 `FbFormAbility`。 */ -import { FbCardDay, FbCardEvent } from '../../data/FbCardData'; - let storage2x2 = new LocalStorage(); @Entry(storage2x2) @Component struct FbCard2x2 { @LocalStorageProp('state') state: string = 'loading'; - @LocalStorageProp('todayLabel') todayLabel: string = ''; - @LocalStorageProp('weekday') weekday: string = ''; - @LocalStorageProp('lunarShort') lunarShort: string = ''; - @LocalStorageProp('marks') marks: string = ''; - @LocalStorageProp('eventCount') eventCount: number = 0; - @LocalStorageProp('doing') doing: number = 0; - @LocalStorageProp('todo') todo: number = 0; - @LocalStorageProp('overdue') overdue: number = 0; - @LocalStorageProp('eventsJson') eventsJson: string = '[]'; - @LocalStorageProp('daysJson') daysJson: string = '[]'; @LocalStorageProp('hint') hint: string = ''; - @LocalStorageProp('updatedAt') updatedAt: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('dateText') dateText: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; + @LocalStorageProp('lunarText') lunarText: string = ''; + @LocalStorageProp('dayCount') dayCount: number = 0; + /** 卡片当前显示那天的 0 点毫秒 —— 点卡片进应用时定位到这一天 */ + @LocalStorageProp('selectedDate') selectedDate: number = 0; - /** 副标题:`星期二 九月初一`(缺哪个就不占位) */ + /** 副标题:`星期二 九月初一`(缺哪段就少哪段) */ private subLine(): string { const parts: string[] = []; if (this.weekday !== '') { parts.push(this.weekday); } - if (this.lunarShort !== '') { - parts.push(this.lunarShort); + if (this.lunarText !== '') { + parts.push(this.lunarText); } return parts.join(' '); } - /** - * 最下面那一行:优先"最近的纪念日倒计时",其次"今天第一条日程"。 - * - * ⚠️ 不显示"共 N 条"这种废话 —— 大数字已经说了。 - */ - private footLine(): string { - if (this.hint !== '') { - return this.hint; + /** 右上角 +:拉起应用直接进「新建日程」 */ + @Builder + addButton() { + Button() { + Text('+') + .fontSize(20) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') } - const days = JSON.parse(this.daysJson) as FbCardDay[]; - if (days.length > 0) { - const d: FbCardDay = days[0]; - return d.days === 0 ? `${d.title} 就是今天` : `${d.title} 还有 ${d.days} 天`; - } - const evs = JSON.parse(this.eventsJson) as FbCardEvent[]; - if (evs.length > 0) { - return `${evs[0].time} ${evs[0].title}`; - } - return ''; - } - - /** 右下角小字:逾期优先(那是要人马上处理的) */ - private statLine(): string { - if (this.overdue > 0) { - return `逾期 ${this.overdue}`; - } - if (this.todo > 0) { - return `待办 ${this.todo}`; - } - return this.updatedAt; - } - - build() { - Column() { - // ---- 抬头:日期 + 日子标记 ---- - Row() { - Column({ space: 1 }) { - Text(this.todayLabel === '' ? '家书' : this.todayLabel) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor('#2C231D') - .maxLines(1) - Text(this.subLine()) - .fontSize(10) - .fontColor('#A8988A') - .maxLines(1) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - - if (this.marks !== '') { - Text(this.marks) - .fontSize(9) - .fontColor('#A84A28') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .padding({ left: 4, right: 4, top: 1, bottom: 1 }) - .backgroundColor('#F7EFE6') - .borderRadius(4) - } - } - .width('100%') - - Blank() - - // ---- 大数字 ---- - Row({ space: 6 }) { - Text(`${this.eventCount}`) - .fontSize(34) - .fontWeight(FontWeight.Bold) - .fontColor('#A84A28') - Column({ space: 1 }) { - Text('件日程') - .fontSize(11) - .fontColor('#2C231D') - Text(this.statLine()) - .fontSize(10) - .fontColor(this.overdue > 0 ? '#C0533F' : '#A8988A') - } - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .alignItems(VerticalAlign.Bottom) - - Blank() - - // ---- 一行提示 ---- - Text(this.footLine()) - .fontSize(10) - .fontColor(this.hint !== '' ? '#C0533F' : '#7E6B5A') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .width('100%') - } - .width('100%') - .height('100%') - .padding(12) - .backgroundColor('#FFFFFF') - .borderRadius(16) - .alignItems(HorizontalAlign.Start) - // 整卡点击 → 打开日程(这张卡的主角就是"今天有几件事") + .width(30) + .height(30) + .borderRadius(15) + .padding(0) + .backgroundColor('#A84A28') + /* ⚠️ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**, + 故点 + 不会连带触发根容器的"打开日程"。 */ .onClick((): void => { postCardAction(this, { action: 'router', abilityName: 'EntryAbility', - params: { fbModule: 'calendar' } + params: { fbModule: 'calendar', widgetAction: 'add', openDate: `${this.selectedDate}` } }); }) } -} + /** 打开应用并**定位到卡片当前显示的那一天** */ + private openApp(): void { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + // 必须转成字符串:param 的数值按 int32 传递,13 位毫秒时间戳会溢出(SyncCalendar 踩过) + params: { fbModule: 'calendar', openDate: `${this.selectedDate}` } + }); + } + + build() { + Column({ space: 6 }) { + // ---- 抬头:日期 + + ---- + Row({ space: 6 }) { + Column({ space: 2 }) { + Text(this.dateMd === '' ? this.dateText : this.dateMd) + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor('#2C231D') + .maxLines(1) + Text(this.subLine()) + .fontSize(11) + .fontColor('#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + this.addButton() + } + .width('100%') + + Divider() + .strokeWidth(0.5) + .color('#EFE6D8') + + // ---- 当天日程条数(占满剩余空间,垂直居中)---- + Row({ space: 8 }) { + Text(`${this.dayCount}`) + .fontSize(40) + .fontWeight(FontWeight.Bold) + .fontColor('#A84A28') + Column({ space: 2 }) { + Text('今日日程') + .fontSize(12) + .fontColor('#2C231D') + .maxLines(1) + Text(this.hint !== '' ? this.hint : (this.dayCount === 0 ? '点击添加' : '点击查看')) + .fontSize(10) + .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + .layoutWeight(1) + .alignItems(VerticalAlign.Center) + } + .width('100%') + .height('100%') + .padding(14) + .backgroundColor('#FFFFFF') + .borderRadius(16) + // ⭐ 整卡可点:空白处也能进应用(原来只有日期区/条数区可点) + .onClick((): void => this.openApp()) + } +} diff --git a/entry/src/main/ets/pages/card/FbCard2x4.ets b/entry/src/main/ets/pages/card/FbCard2x4.ets index eaff229..3dc1335 100644 --- a/entry/src/main/ets/pages/card/FbCard2x4.ets +++ b/entry/src/main/ets/pages/card/FbCard2x4.ets @@ -1,162 +1,227 @@ /** - * 家书 FamilyBook · 2×4 服务卡片(今日日程 · 左右分栏) + * 家书 FamilyBook · 2×4 服务卡片(日程 · 左概览 / 右待办) * - * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 + * ⇒ 照 `SyncCalendar/entry/src/main/ets/pages/widget/Widget4x2.ets`: + * 左半 = 2×2 那套(日期 / 星期农历 / 当天条数);右半 = **接下来两条**日程; + * 整卡可点进应用并定位到当天,右上角 + 直接新建。 * - * 左边一列是"今天怎么样"(日期 / 农历 / 件数 / 待办),右边是**今天的前三条日程**。 - * 2×2 只能给个数字,说不出"是什么事";这块卡就是补那一步。 - * - * ⚠️ 与 2×2 / 4×4 **同一份数据**(`data/FbCardData.ets` 算一次,三卡共用), - * 所以件数一定一致 —— 不要在卡片里自己算。 + * ⚠️ 与 2×2 / 4×4 / 6×4 **同一份数据**(`data/FbCardData.ets` 算一次,四卡共用), + * 所以条数一定一致 —— 不要在卡片里自己算。 */ -import { FbCardDay, FbCardEvent } from '../../data/FbCardData'; - let storage2x4 = new LocalStorage(); +/** 右半「接下来两条」(与 `listJson` 同结构,这里只用到这几个字段) */ +class FbCardUp { + title: string = ''; + time: string = ''; + endTime: string = ''; + color: string = '#A84A28'; + isNow: boolean = false; +} + @Entry(storage2x4) @Component struct FbCard2x4 { @LocalStorageProp('state') state: string = 'loading'; - @LocalStorageProp('todayLabel') todayLabel: string = ''; - @LocalStorageProp('weekday') weekday: string = ''; - @LocalStorageProp('lunarShort') lunarShort: string = ''; - @LocalStorageProp('eventCount') eventCount: number = 0; - @LocalStorageProp('doing') doing: number = 0; - @LocalStorageProp('todo') todo: number = 0; - @LocalStorageProp('overdue') overdue: number = 0; - @LocalStorageProp('eventsJson') eventsJson: string = '[]'; @LocalStorageProp('hint') hint: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('dateText') dateText: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; + @LocalStorageProp('lunarText') lunarText: string = ''; + @LocalStorageProp('dayCount') dayCount: number = 0; + @LocalStorageProp('upcomingJson') upcomingJson: string = '[]'; + @LocalStorageProp('selectedDate') selectedDate: number = 0; + /** 副标题:`星期二 九月初一` */ private subLine(): string { const parts: string[] = []; if (this.weekday !== '') { parts.push(this.weekday); } - if (this.lunarShort !== '') { - parts.push(this.lunarShort); + if (this.lunarText !== '') { + parts.push(this.lunarText); } return parts.join(' '); } - /** 右侧最多三条(再多 2×4 的高度放不下,会被裁掉半行) */ - private top3(): FbCardEvent[] { - const all = JSON.parse(this.eventsJson) as FbCardEvent[]; - const out: FbCardEvent[] = []; - for (let i = 0; i < all.length && i < 3; i++) { - out.push(all[i]); + /** 右半最多两行(数据侧已裁剪,这里再兜底截断) */ + private upcoming(): FbCardUp[] { + try { + const all: FbCardUp[] = JSON.parse(this.upcomingJson) as FbCardUp[]; + return all.slice(0, 2); + } catch (err) { + return []; } - return out; } - build() { - Row({ space: 10 }) { - // ---- 左栏:今天的"账" ---- - Column({ space: 2 }) { - Text(this.todayLabel === '' ? '家书' : this.todayLabel) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor('#2C231D') - .maxLines(1) - Text(this.subLine()) - .fontSize(10) - .fontColor('#A8988A') - .maxLines(1) - - Blank() - - Row({ space: 5 }) { - Text(`${this.eventCount}`) - .fontSize(30) - .fontWeight(FontWeight.Bold) - .fontColor('#A84A28') - Text('件日程') - .fontSize(11) - .fontColor('#2C231D') - .margin({ bottom: 4 }) - } - .alignItems(VerticalAlign.Bottom) - - Text(this.overdue > 0 ? `逾期 ${this.overdue} 件` : `待办 ${this.todo} 件`) - .fontSize(10) - .fontColor(this.overdue > 0 ? '#C0533F' : '#A8988A') - .maxLines(1) - } - .width('38%') - .height('100%') - .alignItems(HorizontalAlign.Start) - - // 分隔线(视觉上把"账"和"事"分开) - Divider() - .vertical(true) - .strokeWidth(0.5) - .color('#EFE6D8') - .height('86%') - - // ---- 右栏:今天的事 ---- - Column({ space: 0 }) { - Text('今天') - .fontSize(10) - .fontColor('#A8988A') - .width('100%') - .margin({ bottom: 5 }) - - if (this.hint !== '') { - Text(this.hint) - .fontSize(11) - .fontColor('#C0533F') - .maxLines(2) - .width('100%') - } else if (this.top3().length === 0) { - Text('今天没有安排') - .fontSize(11) - .fontColor('#A8988A') - .width('100%') - } else { - ForEach(this.top3(), (e: FbCardEvent) => { - Row({ space: 6 }) { - Column() - .width(3) - .height(13) - .borderRadius(2) - .backgroundColor(e.color) - .flexShrink(0) - Text(e.time) - .fontSize(10) - .fontColor('#7E6B5A') - .width(34) - .flexShrink(0) - Text(e.title) - .fontSize(11) - .fontColor('#2C231D') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } - .width('100%') - .height(20) - .alignItems(VerticalAlign.Center) - }, (e: FbCardEvent) => e.key) - } - Blank() - } - .layoutWeight(1) - .height('100%') - .alignItems(HorizontalAlign.Start) + @Builder + addButton() { + Button() { + Text('+') + .fontSize(18) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') } - .width('100%') - .height('100%') - .padding({ left: 14, right: 14, top: 12, bottom: 12 }) - .backgroundColor('#FFFFFF') - .borderRadius(16) - .alignItems(VerticalAlign.Top) + .width(26) + .height(26) + .borderRadius(13) + .padding(0) + .backgroundColor('#A84A28') .onClick((): void => { postCardAction(this, { action: 'router', abilityName: 'EntryAbility', - params: { fbModule: 'calendar' } + params: { fbModule: 'calendar', widgetAction: 'add', openDate: `${this.selectedDate}` } }); }) } -} + private openApp(): void { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar', openDate: `${this.selectedDate}` } + }); + } + + /** 右半单条:左色条 + 起止时间 + 标题 */ + @Builder + upcomingRow(item: FbCardUp) { + Row({ space: 8 }) { + Column() + .width(3) + .height(38) + .borderRadius(2) + .backgroundColor(item.isNow ? '#C0533F' : item.color) + Column({ space: 2 }) { + Text(item.time) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor(item.isNow ? '#C0533F' : '#5A4A3D') + .maxLines(1) + if (item.endTime !== '' && item.endTime !== '全天') { + Text(item.endTime) + .fontSize(9) + .fontColor('#A8988A') + .maxLines(1) + } + } + .width(36) + .alignItems(HorizontalAlign.Start) + Text(item.title) + .fontSize(13) + .fontColor('#2C231D') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .alignItems(VerticalAlign.Center) + .width('100%') + .padding({ left: 8, right: 8, top: 5, bottom: 5 }) + .borderRadius(8) + .backgroundColor(item.isNow ? '#FBEEE9' : '#F7F2EA') + } + + /** 右半空态:当天没有日程 / 只剩全天 */ + @Builder + emptyHint() { + Column() { + Text(this.hint !== '' ? this.hint : (this.dayCount === 0 ? '今日暂无日程' : '只有全天日程')) + .fontSize(12) + .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') + .maxLines(3) + .textAlign(TextAlign.Center) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } + + build() { + Row({ space: 10 }) { + // ===== 左半:与 2×2 一致 ===== + Column({ space: 6 }) { + Row({ space: 6 }) { + Column({ space: 2 }) { + Text(this.dateMd === '' ? this.dateText : this.dateMd) + .fontSize(15) + .fontWeight(FontWeight.Bold) + .fontColor('#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.subLine()) + .fontSize(10) + .fontColor('#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + this.addButton() + } + .width('100%') + + Divider() + .strokeWidth(0.5) + .color('#EFE6D8') + + Row({ space: 6 }) { + Text(`${this.dayCount}`) + .fontSize(38) + .fontWeight(FontWeight.Bold) + .fontColor('#A84A28') + Column({ space: 2 }) { + Text('今日日程') + .fontSize(11) + .fontColor('#2C231D') + .maxLines(1) + Text(this.dayCount === 0 ? '点击添加' : '点击查看') + .fontSize(9) + .fontColor('#A8988A') + .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('#EFE6D8') + + // ===== 右半:接下来两条(不滚动)===== + Column() { + if (this.upcoming().length === 0) { + this.emptyHint() + } else { + Column({ space: 6 }) { + ForEach(this.upcoming(), (item: FbCardUp, idx: number) => { + this.upcomingRow(item) + }, (item: FbCardUp, idx: number) => `${idx}_${item.time}_${item.title}`) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } + } + .layoutWeight(1) + .height('100%') + } + .width('100%') + .height('100%') + .padding(12) + .backgroundColor('#FFFFFF') + .borderRadius(16) + // ⭐ 整卡可点:左右空白处也能进应用并定位到当天 + .onClick((): void => this.openApp()) + } +} diff --git a/entry/src/main/ets/pages/card/FbCard4x4.ets b/entry/src/main/ets/pages/card/FbCard4x4.ets index 17a097f..48f3fc6 100644 --- a/entry/src/main/ets/pages/card/FbCard4x4.ets +++ b/entry/src/main/ets/pages/card/FbCard4x4.ets @@ -1,235 +1,525 @@ /** - * 家书 FamilyBook · 4×4 服务卡片(今日全景) + * 家书 FamilyBook · 4×4 服务卡片(日程 · 当天时间轴 / 列表) * - * 用户 2026-10-20:「做几个和 syncCalendar 差不多的卡片。」 + * 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 + * ⇒ 照 `SyncCalendar/entry/src/main/ets/pages/widget/Widget4x4.ets`: + * · 顶栏:`‹ 10月20日 / 九月初一 › [今|+]` —— **翻页按钮**走 + * `postCardAction({action:'message', params:{pageAction:'prev'}})`, + * 由 `FbFormAbility.onFormEvent` 改偏移、重新取数再推回来; + * · 主体:**时间轴**(8:00~18:00,越界的日程自动扩)或**列表**, + * 跟应用内「日程设置 → 日程显示方式」联动; + * · 今天才画当前时间红线;翻到别的日期不画(否则"昨天的 15:00 正在发生"很荒谬)。 * - * 这块是"不用打开应用就能看完"的那一块: - * 抬头(日期/农历)→ 四个数字(日程/在做/待办/逾期)→ 今日日程 → 最近纪念日。 - * 四块内容的信息密度正好对上 4×4;再塞(任务详情、往年的今天)就会被裁。 - * - * ⚠️ 右上角那个 ↻ 是"手动刷新": - * 动态卡片**不能自己发起网络请求**,只能 `postCardAction({action:'message'})` - * 把消息发给 `FbFormAbility.onFormEvent`,由那边重新取数再推回来。 + * ⚠️ 卡片不支持 `Scroll`,但支持 `List`:把"整日时间轴"当成**一个很高的 ListItem**, + * 超出卡片窗口的部分靠上下滑动看(与 SyncCalendar 同一个做法)。 */ -import { FbCardDay, FbCardEvent } from '../../data/FbCardData'; +import { FbCardBlock, FbCardTlBlock, FbCardTlLine, fbCardTlLines } from '../../data/FbCardData'; + +/** 1 小时 = 多少 vp(时间轴的纵向刻度) */ +const HOUR_VP: number = 36; +/** 小时刻度列宽 */ +const GUTTER_VP: number = 22; +/** 色块最短高度(太矮就点不到、字也画不下) */ +const MIN_BLOCK_VP: number = 14; let storage4x4 = new LocalStorage(); +/** 列表模式的一行(与 `listJson` 同结构) */ +class FbCardListRow { + key: string = ''; + time: string = ''; + endTime: string = ''; + title: string = ''; + color: string = '#A84A28'; + calName: string = ''; + isNow: boolean = false; +} + @Entry(storage4x4) @Component struct FbCard4x4 { @LocalStorageProp('state') state: string = 'loading'; - @LocalStorageProp('todayLabel') todayLabel: string = ''; - @LocalStorageProp('weekday') weekday: string = ''; - @LocalStorageProp('lunarShort') lunarShort: string = ''; - @LocalStorageProp('marks') marks: string = ''; - @LocalStorageProp('eventCount') eventCount: number = 0; - @LocalStorageProp('doing') doing: number = 0; - @LocalStorageProp('todo') todo: number = 0; - @LocalStorageProp('overdue') overdue: number = 0; - @LocalStorageProp('eventsJson') eventsJson: string = '[]'; - @LocalStorageProp('daysJson') daysJson: string = '[]'; @LocalStorageProp('hint') hint: string = ''; - @LocalStorageProp('updatedAt') updatedAt: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; + @LocalStorageProp('lunarText') lunarText: string = ''; + @LocalStorageProp('isToday') isToday: boolean = true; + @LocalStorageProp('selectedDate') selectedDate: number = 0; + @LocalStorageProp('dayCount') dayCount: number = 0; + @LocalStorageProp('listJson') listJson: string = '[]'; + @LocalStorageProp('allDayJson') allDayJson: string = '[]'; + @LocalStorageProp('timelineJson') timelineJson: string = '[]'; + @LocalStorageProp('displayStyle') displayStyle: string = 'timeline'; + @LocalStorageProp('viewStartHour') viewStartHour: number = 8; + @LocalStorageProp('viewEndHour') viewEndHour: number = 18; + @LocalStorageProp('nowRatio') nowRatio: number = -1; - private subLine(): string { - const parts: string[] = []; - if (this.weekday !== '') { - parts.push(this.weekday); - } - if (this.lunarShort !== '') { - parts.push(this.lunarShort); - } - return parts.join(' '); - } - - /** 今日日程最多画 5 条(第 6 条开始一定被裁) */ - private events(): FbCardEvent[] { - const all = JSON.parse(this.eventsJson) as FbCardEvent[]; - const out: FbCardEvent[] = []; - for (let i = 0; i < all.length && i < 5; i++) { - out.push(all[i]); + /** 小时刻度(含起点不含终点) */ + private hours(): number[] { + const out: number[] = []; + for (let h = this.viewStartHour; h < this.viewEndHour; h++) { + out.push(h); } return out; } - /** 最近纪念日最多 2 条(第三行会与"今日日程"抢高度) */ - private days(): FbCardDay[] { - const all = JSON.parse(this.daysJson) as FbCardDay[]; - const out: FbCardDay[] = []; - for (let i = 0; i < all.length && i < 2; i++) { - out.push(all[i]); - } - return out; + private hourText(h: number): string { + return h < 10 ? `0${h}` : `${h}`; } - /** 手动刷新(走 message,见文件头) */ - private refresh(): void { + /** 时间轴总高 */ + private contentH(): number { + const n: number = this.viewEndHour - this.viewStartHour; + return (n > 0 ? n : 1) * HOUR_VP; + } + + /** + * 冲突组里的列(`ln.group` 是可空类型,ForEach 的闭包里 TS 收窄会失效 + * ⇒ 统一用这个取值函数,避免到处写非空断言)。 + */ + private lanesOf(ln: FbCardTlLine): FbCardTlBlock[][] { + const g = ln.group; + return g === null ? [] : g.lanes; + } + + private groupH(ln: FbCardTlLine): number { + const g = ln.group; + return g === null ? 0 : g.heightVp; + } + + private blocks(): FbCardBlock[] { + try { + return JSON.parse(this.timelineJson) as FbCardBlock[]; + } catch (err) { + return []; + } + } + + /** 把比例换算成"行"(空白 / 冲突组),视图直接 ForEach */ + private lines(): FbCardTlLine[] { + return fbCardTlLines(this.blocks(), this.viewStartHour, HOUR_VP); + } + + private rows(): FbCardListRow[] { + try { + return JSON.parse(this.listJson) as FbCardListRow[]; + } catch (err) { + return []; + } + } + + private allDay(): FbCardListRow[] { + try { + return JSON.parse(this.allDayJson) as FbCardListRow[]; + } catch (err) { + return []; + } + } + + /** 当前时间红线在时间轴上的偏移(vp);不画时返回 -1 */ + private nowOffsetVp(): number { + if (!this.isToday || this.nowRatio < 0) { + return -1; + } + const off: number = (this.nowRatio * 24 - this.viewStartHour) * HOUR_VP; + if (off < 0 || off > this.contentH()) { + return -1; + } + return off; + } + + /** 打开应用并定位到卡片显示的那天 */ + private openApp(): void { postCardAction(this, { - action: 'message', - params: { msg: 'refresh' } + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar', openDate: `${this.selectedDate}` } }); } - /** 一个数字格(日程 / 在做 / 待办 / 逾期) */ - @Builder - statCell(label: string, value: number, color: string) { - Column({ space: 1 }) { - Text(`${value}`) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor(color) - .maxLines(1) - Text(label) - .fontSize(9) - .fontColor('#A8988A') - .maxLines(1) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) + /** 翻页:交给 FbFormAbility.onFormEvent 重新取数并推送 */ + private page(act: string): void { + postCardAction(this, { + action: 'message', + params: { pageAction: act } + }); } - build() { - Column() { - // ---- 抬头 ---- - Row({ space: 6 }) { - Column({ space: 1 }) { - Text(this.todayLabel === '' ? '家书' : this.todayLabel) - .fontSize(17) - .fontWeight(FontWeight.Medium) - .fontColor('#2C231D') - .maxLines(1) - Text(this.subLine() + (this.marks !== '' ? ' ' + this.marks : '')) + @Builder + private pageBtn(label: string, act: string) { + Button() { + Text(label) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#5A4A3D') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#F3ECE2') + /* ⚠️ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开应用 */ + .onClick((): void => this.page(act)) + } + + /** 今天时是「+」(新建日程);翻到别的日期时是「今」(回到今天) */ + @Builder + private sideBtn() { + if (this.isToday) { + Button() { + Text('+') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#A84A28') + .onClick((): void => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar', widgetAction: 'add', openDate: `${this.selectedDate}` } + }); + }) + } else { + Button() { + Text('今') + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#A84A28') + .onClick((): void => this.page('today')) + } + } + + /** 全天日程色块(画在时间轴上方,随内容一起滚动) */ + @Builder + private allDayChips() { + Column({ space: 2 }) { + ForEach(this.allDay(), (a: FbCardListRow, idx: number) => { + Row({ space: 4 }) { + Text('全天') + .fontSize(8) + .fontColor('#FFFFFF') + .backgroundColor('#26000000') + .borderRadius(5) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + Text(a.title) .fontSize(10) - .fontColor('#A8988A') + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - - Text('↻') - .fontSize(14) - .fontColor('#7E6B5A') - .width(26) - .height(26) - .textAlign(TextAlign.Center) - .backgroundColor('#F5EFE4') - .borderRadius(13) - .onClick((): void => { - this.refresh(); - }) - } - .width('100%') - - // ---- 四个数字 ---- - Row() { - this.statCell('日程', this.eventCount, '#A84A28') - this.statCell('在做', this.doing, '#2C231D') - this.statCell('待办', this.todo, '#2C231D') - this.statCell('逾期', this.overdue, this.overdue > 0 ? '#C0533F' : '#A8988A') - } - .width('100%') - .margin({ top: 8 }) - - Divider() - .strokeWidth(0.5) - .color('#EFE6D8') - .margin({ top: 8, bottom: 6 }) - - // ---- 今日日程 ---- - Text('今日日程') - .fontSize(10) - .fontColor('#A8988A') + .alignItems(VerticalAlign.Center) .width('100%') + .height(18) + .padding({ left: 5, right: 5 }) + .borderRadius(5) + .backgroundColor(a.color) + }, (a: FbCardListRow, idx: number) => `ad_${idx}_${a.key}`) + } + .width('100%') + .padding({ bottom: 3 }) + } - Column({ space: 0 }) { - if (this.hint !== '') { - Text(this.hint) - .fontSize(11) - .fontColor('#C0533F') - .maxLines(2) - .width('100%') - } else if (this.events().length === 0) { - Text('今天没有安排') - .fontSize(11) - .fontColor('#A8988A') - .width('100%') + /** 第 1 层:整点网格 */ + @Builder + private gridLayer() { + Column() { + ForEach(this.hours(), (h: number) => { + Column() + .width('100%') + .height(HOUR_VP) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + .hitTestBehavior(HitTestMode.None) + }, (h: number) => `gd${h}`) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) + } + + /** 第 2 层:日程色块(空白行 + 冲突组按列并排) */ + @Builder + private eventLayer() { + Column() { + ForEach(this.lines(), (ln: FbCardTlLine) => { + if (ln.group === null) { + Blank().height(ln.blankVp) } else { - ForEach(this.events(), (e: FbCardEvent) => { - Row({ space: 6 }) { - Column() - .width(3) - .height(13) - .borderRadius(2) - .backgroundColor(e.color) - .flexShrink(0) - Text(e.time) - .fontSize(10) - .fontColor('#7E6B5A') - .width(34) - .flexShrink(0) - Text(e.title) - .fontSize(11) - .fontColor('#2C231D') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - } - .width('100%') - .height(21) - .alignItems(VerticalAlign.Center) - }, (e: FbCardEvent) => e.key) - } - } - .width('100%') - .margin({ top: 3 }) - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - - // ---- 最近纪念日 ---- - if (this.days().length > 0) { - Divider() - .strokeWidth(0.5) - .color('#EFE6D8') - .margin({ top: 4, bottom: 5 }) - - ForEach(this.days(), (d: FbCardDay) => { - Row({ space: 6 }) { - Text(d.days === 0 ? '今天' : `${d.days} 天`) - .fontSize(10) - .fontColor(d.soon ? '#A84A28' : '#A8988A') - .width(38) - .flexShrink(0) - Text(d.title) - .fontSize(11) - .fontColor('#2C231D') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) + Row() { + ForEach(this.lanesOf(ln), (lane: FbCardTlBlock[], li: number) => { + Column() { + ForEach(lane, (b: FbCardTlBlock, bi: number) => { + if (b.padTopVp > 0) { + Blank().height(b.padTopVp) + } + Column({ space: 0 }) { + Text(b.title) + .fontSize(9) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + if (b.heightVp >= 30) { + Text(b.timeText) + .fontSize(8) + .fontColor('#E6FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .padding({ left: 4, right: 2, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(b.color) + .opacity(b.isNow ? 1 : 0.92) + .clip(true) + .width('100%') + .height(b.heightVp < MIN_BLOCK_VP ? MIN_BLOCK_VP : b.heightVp) + }, (b: FbCardTlBlock, bi: number) => `b_${b.key}_${bi}`) + } .layoutWeight(1) + .height('100%') + .padding({ right: 2 }) + .clip(true) + }, (lane: FbCardTlBlock[], li: number) => `ln_${ln.key}_${li}`) } .width('100%') - .height(18) - .alignItems(VerticalAlign.Center) - }, (d: FbCardDay) => d.key) + .height(this.groupH(ln)) + .alignItems(VerticalAlign.Top) + } + }, (ln: FbCardTlLine) => ln.key) + } + .width('100%') + .height('100%') + } + + /** 第 3 层:当前时间红线(只有今天画) */ + @Builder + private nowLayer() { + Column() { + if (this.nowOffsetVp() >= 0) { + Blank().height(this.nowOffsetVp()).hitTestBehavior(HitTestMode.None) + Row() { + Column() + .width(4) + .height(4) + .borderRadius(2) + .backgroundColor('#C0533F') + Column() + .height(2) + .layoutWeight(1) + .borderRadius(1) + .backgroundColor('#C0533F') + } + .width('100%') + .hitTestBehavior(HitTestMode.None) } } .width('100%') .height('100%') - .padding({ left: 14, right: 14, top: 12, bottom: 10 }) + .hitTestBehavior(HitTestMode.None) + } + + /** 时间轴主体 */ + @Builder + private timelineBody() { + List() { + ListItem() { + Column() { + if (this.allDay().length > 0) { + this.allDayChips() + } + Row() { + Column() { + ForEach(this.hours(), (h: number) => { + Text(this.hourText(h)) + .fontSize(9) + .fontColor('#A8988A') + .width(GUTTER_VP) + .height(HOUR_VP) + .textAlign(TextAlign.End) + .padding({ right: 3 }) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + }, (h: number) => `h${h}`) + } + .width(GUTTER_VP) + .height(this.contentH()) + + Stack() { + this.gridLayer() + this.eventLayer() + this.nowLayer() + } + .layoutWeight(1) + .height(this.contentH()) + .alignContent(Alignment.TopStart) + .border({ width: { left: 0.5 }, color: { left: '#14000000' } }) + } + .width('100%') + .height(this.contentH()) + .alignItems(VerticalAlign.Top) + } + .width('100%') + } + .width('100%') + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Auto) + .cachedCount(4) + } + + /** 列表模式的一行:左色条 + 起止时间 + 标题 [+ 进行中] + 日历本名 */ + @Builder + private listRow(item: FbCardListRow) { + Row({ space: 8 }) { + Column() + .width(3) + .height(item.time === '全天' ? 16 : 36) + .borderRadius(2) + .backgroundColor(item.isNow ? '#C0533F' : item.color) + if (item.time === '全天') { + Text(item.title) + .fontSize(12) + .fontColor('#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text('全天') + .fontSize(9) + .fontColor('#FFFFFF') + .backgroundColor(item.color) + .borderRadius(6) + .padding({ left: 5, right: 5, top: 1, bottom: 1 }) + } else { + Column({ space: 1 }) { + Text(item.time) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor(item.isNow ? '#C0533F' : '#5A4A3D') + Text(item.endTime) + .fontSize(9) + .fontColor('#A8988A') + } + .width(34) + .alignItems(HorizontalAlign.Center) + Text(item.title) + .fontSize(12) + .fontColor(item.isNow ? '#C0533F' : '#2C231D') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + if (item.isNow) { + Text('进行中') + .fontSize(9) + .fontColor('#C0533F') + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor('#FBEEE9') + } + } + if (item.calName !== '') { + Text(item.calName) + .fontSize(9) + .fontColor(item.color) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '25%' }) + } + } + .alignItems(VerticalAlign.Center) + .width('100%') + .padding({ left: 8, right: 8, top: 5, bottom: 5 }) + .borderRadius(8) + .backgroundColor(item.isNow ? '#FBEEE9' : '#F7F2EA') + } + + /** 列表模式主体:空态 / 可滚动的行列表 */ + @Builder + private listBody() { + if (this.rows().length === 0) { + Column({ space: 6 }) { + Text(this.hint !== '' ? this.hint : '暂无日程') + .fontSize(13) + .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') + .maxLines(3) + .textAlign(TextAlign.Center) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } else { + List({ space: 4 }) { + ForEach(this.rows(), (item: FbCardListRow) => { + ListItem() { + this.listRow(item) + } + .width('100%') + }, (item: FbCardListRow) => item.key) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Auto) + .cachedCount(8) + } + } + + build() { + Column({ space: 6 }) { + // ---- 顶栏:翻页 + 日期 + 今/+ ---- + Row({ space: 4 }) { + this.pageBtn('‹', 'prev') + Column({ space: 1 }) { + Text(this.dateMd) + .fontSize(12) + .fontWeight(FontWeight.Bold) + .fontColor(this.isToday ? '#A84A28' : '#2C231D') + .maxLines(1) + Text(this.lunarText === '' ? this.weekday : this.lunarText) + .fontSize(9) + .fontColor('#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + this.pageBtn('›', 'next') + this.sideBtn() + } + .width('100%') + + Divider() + .strokeWidth(0.5) + .color('#EFE6D8') + + if (this.displayStyle === 'list') { + this.listBody() + } else { + this.timelineBody() + } + } + .width('100%') + .height('100%') + .padding(12) .backgroundColor('#FFFFFF') .borderRadius(16) - .alignItems(HorizontalAlign.Start) - .onClick((): void => { - // 整卡点击 → 打开日程(卡片上"今日日程"是主角) - postCardAction(this, { - action: 'router', - abilityName: 'EntryAbility', - params: { fbModule: 'calendar' } - }); - }) + /* ⭐ 整卡可点:空白处也能进应用并定位到当前显示日。 + ⚠️ onClick 是**非冒泡**且**子组件优先** → 内部的 ‹ › 今 + 不会被这里抢走 */ + .onClick((): void => this.openApp()) } } - - diff --git a/entry/src/main/ets/pages/card/FbCard6x4.ets b/entry/src/main/ets/pages/card/FbCard6x4.ets new file mode 100644 index 0000000..ea41982 --- /dev/null +++ b/entry/src/main/ets/pages/card/FbCard6x4.ets @@ -0,0 +1,522 @@ +/** + * 家书 FamilyBook · 6×4 服务卡片(日程 · 当天时间轴 / 列表,横向更宽) + * + * 用户 2026-10-20:「跟日程有关的桌面服务卡片,你就完全按照 synccalendar 来做就行。」 + * ⇒ 与 `FbCard4x4` 同一套结构(`SyncCalendar` 的 `Widget6x4` 与 `Widget4x4` + * 也是同一个版面、只是更宽),差别只有两处: + * · 1 小时 = 40vp(宽卡纵向能多给一点); + * · 小时刻度列更宽(26vp),时间数字不挤。 + * + * ⚠️ 与 4×4 **必须**用同一份数据(`FbCardData`):两张卡同时放桌面上时, + * 条数与时间轴位置必须一致 —— 谁都不许自己再算一遍。 + */ + +import { FbCardBlock, FbCardTlBlock, FbCardTlLine, fbCardTlLines } from '../../data/FbCardData'; + +/** 1 小时 = 多少 vp(时间轴的纵向刻度) */ +const HOUR_VP: number = 40; +/** 小时刻度列宽 */ +const GUTTER_VP: number = 26; +/** 色块最短高度(太矮就点不到、字也画不下) */ +const MIN_BLOCK_VP: number = 14; + +let storage6x4 = new LocalStorage(); + +/** 列表模式的一行(与 `listJson` 同结构) */ +class FbCardListRow { + key: string = ''; + time: string = ''; + endTime: string = ''; + title: string = ''; + color: string = '#A84A28'; + calName: string = ''; + isNow: boolean = false; +} + +@Entry(storage6x4) +@Component +struct FbCard6x4 { + @LocalStorageProp('state') state: string = 'loading'; + @LocalStorageProp('hint') hint: string = ''; + @LocalStorageProp('dateMd') dateMd: string = ''; + @LocalStorageProp('weekday') weekday: string = ''; + @LocalStorageProp('lunarText') lunarText: string = ''; + @LocalStorageProp('isToday') isToday: boolean = true; + @LocalStorageProp('selectedDate') selectedDate: number = 0; + @LocalStorageProp('dayCount') dayCount: number = 0; + @LocalStorageProp('listJson') listJson: string = '[]'; + @LocalStorageProp('allDayJson') allDayJson: string = '[]'; + @LocalStorageProp('timelineJson') timelineJson: string = '[]'; + @LocalStorageProp('displayStyle') displayStyle: string = 'timeline'; + @LocalStorageProp('viewStartHour') viewStartHour: number = 8; + @LocalStorageProp('viewEndHour') viewEndHour: number = 18; + @LocalStorageProp('nowRatio') nowRatio: number = -1; + + /** 小时刻度(含起点不含终点) */ + private hours(): number[] { + const out: number[] = []; + for (let h = this.viewStartHour; h < this.viewEndHour; h++) { + out.push(h); + } + return out; + } + + private hourText(h: number): string { + return h < 10 ? `0${h}` : `${h}`; + } + + /** 时间轴总高 */ + private contentH(): number { + const n: number = this.viewEndHour - this.viewStartHour; + return (n > 0 ? n : 1) * HOUR_VP; + } + + /** + * 冲突组里的列(`ln.group` 是可空类型,ForEach 的闭包里 TS 收窄会失效 + * ⇒ 统一用这个取值函数,避免到处写非空断言)。 + */ + private lanesOf(ln: FbCardTlLine): FbCardTlBlock[][] { + const g = ln.group; + return g === null ? [] : g.lanes; + } + + private groupH(ln: FbCardTlLine): number { + const g = ln.group; + return g === null ? 0 : g.heightVp; + } + + private blocks(): FbCardBlock[] { + try { + return JSON.parse(this.timelineJson) as FbCardBlock[]; + } catch (err) { + return []; + } + } + + /** 把比例换算成"行"(空白 / 冲突组),视图直接 ForEach */ + private lines(): FbCardTlLine[] { + return fbCardTlLines(this.blocks(), this.viewStartHour, HOUR_VP); + } + + private rows(): FbCardListRow[] { + try { + return JSON.parse(this.listJson) as FbCardListRow[]; + } catch (err) { + return []; + } + } + + private allDay(): FbCardListRow[] { + try { + return JSON.parse(this.allDayJson) as FbCardListRow[]; + } catch (err) { + return []; + } + } + + /** 当前时间红线在时间轴上的偏移(vp);不画时返回 -1 */ + private nowOffsetVp(): number { + if (!this.isToday || this.nowRatio < 0) { + return -1; + } + const off: number = (this.nowRatio * 24 - this.viewStartHour) * HOUR_VP; + if (off < 0 || off > this.contentH()) { + return -1; + } + return off; + } + + /** 打开应用并定位到卡片显示的那天 */ + private openApp(): void { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar', openDate: `${this.selectedDate}` } + }); + } + + /** 翻页:交给 FbFormAbility.onFormEvent 重新取数并推送 */ + private page(act: string): void { + postCardAction(this, { + action: 'message', + params: { pageAction: act } + }); + } + + @Builder + private pageBtn(label: string, act: string) { + Button() { + Text(label) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#5A4A3D') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#F3ECE2') + /* ⚠️ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开应用 */ + .onClick((): void => this.page(act)) + } + + /** 今天时是「+」(新建日程);翻到别的日期时是「今」(回到今天) */ + @Builder + private sideBtn() { + if (this.isToday) { + Button() { + Text('+') + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#A84A28') + .onClick((): void => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { fbModule: 'calendar', widgetAction: 'add', openDate: `${this.selectedDate}` } + }); + }) + } else { + Button() { + Text('今') + .fontSize(11) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + } + .width(22) + .height(22) + .borderRadius(11) + .padding(0) + .backgroundColor('#A84A28') + .onClick((): void => this.page('today')) + } + } + + /** 全天日程色块(画在时间轴上方,随内容一起滚动) */ + @Builder + private allDayChips() { + Column({ space: 2 }) { + ForEach(this.allDay(), (a: FbCardListRow, idx: number) => { + Row({ space: 4 }) { + Text('全天') + .fontSize(8) + .fontColor('#FFFFFF') + .backgroundColor('#26000000') + .borderRadius(5) + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + Text(a.title) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .alignItems(VerticalAlign.Center) + .width('100%') + .height(18) + .padding({ left: 5, right: 5 }) + .borderRadius(5) + .backgroundColor(a.color) + }, (a: FbCardListRow, idx: number) => `ad_${idx}_${a.key}`) + } + .width('100%') + .padding({ bottom: 3 }) + } + + /** 第 1 层:整点网格 */ + @Builder + private gridLayer() { + Column() { + ForEach(this.hours(), (h: number) => { + Column() + .width('100%') + .height(HOUR_VP) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + .hitTestBehavior(HitTestMode.None) + }, (h: number) => `gd${h}`) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) + } + + /** 第 2 层:日程色块(空白行 + 冲突组按列并排) */ + @Builder + private eventLayer() { + Column() { + ForEach(this.lines(), (ln: FbCardTlLine) => { + if (ln.group === null) { + Blank().height(ln.blankVp) + } else { + Row() { + ForEach(this.lanesOf(ln), (lane: FbCardTlBlock[], li: number) => { + Column() { + ForEach(lane, (b: FbCardTlBlock, bi: number) => { + if (b.padTopVp > 0) { + Blank().height(b.padTopVp) + } + Column({ space: 0 }) { + Text(b.title) + .fontSize(9) + .fontWeight(FontWeight.Medium) + .fontColor('#FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + if (b.heightVp >= 30) { + Text(b.timeText) + .fontSize(8) + .fontColor('#E6FFFFFF') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .padding({ left: 4, right: 2, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(b.color) + .opacity(b.isNow ? 1 : 0.92) + .clip(true) + .width('100%') + .height(b.heightVp < MIN_BLOCK_VP ? MIN_BLOCK_VP : b.heightVp) + }, (b: FbCardTlBlock, bi: number) => `b_${b.key}_${bi}`) + } + .layoutWeight(1) + .height('100%') + .padding({ right: 2 }) + .clip(true) + }, (lane: FbCardTlBlock[], li: number) => `ln_${ln.key}_${li}`) + } + .width('100%') + .height(this.groupH(ln)) + .alignItems(VerticalAlign.Top) + } + }, (ln: FbCardTlLine) => ln.key) + } + .width('100%') + .height('100%') + } + + /** 第 3 层:当前时间红线(只有今天画) */ + @Builder + private nowLayer() { + Column() { + if (this.nowOffsetVp() >= 0) { + Blank().height(this.nowOffsetVp()).hitTestBehavior(HitTestMode.None) + Row() { + Column() + .width(4) + .height(4) + .borderRadius(2) + .backgroundColor('#C0533F') + Column() + .height(2) + .layoutWeight(1) + .borderRadius(1) + .backgroundColor('#C0533F') + } + .width('100%') + .hitTestBehavior(HitTestMode.None) + } + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.None) + } + + /** 时间轴主体 */ + @Builder + private timelineBody() { + List() { + ListItem() { + Column() { + if (this.allDay().length > 0) { + this.allDayChips() + } + Row() { + Column() { + ForEach(this.hours(), (h: number) => { + Text(this.hourText(h)) + .fontSize(9) + .fontColor('#A8988A') + .width(GUTTER_VP) + .height(HOUR_VP) + .textAlign(TextAlign.End) + .padding({ right: 3 }) + .border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } }) + }, (h: number) => `h${h}`) + } + .width(GUTTER_VP) + .height(this.contentH()) + + Stack() { + this.gridLayer() + this.eventLayer() + this.nowLayer() + } + .layoutWeight(1) + .height(this.contentH()) + .alignContent(Alignment.TopStart) + .border({ width: { left: 0.5 }, color: { left: '#14000000' } }) + } + .width('100%') + .height(this.contentH()) + .alignItems(VerticalAlign.Top) + } + .width('100%') + } + .width('100%') + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Auto) + .cachedCount(4) + } + + /** 列表模式的一行:左色条 + 起止时间 + 标题 [+ 进行中] + 日历本名 */ + @Builder + private listRow(item: FbCardListRow) { + Row({ space: 8 }) { + Column() + .width(3) + .height(item.time === '全天' ? 16 : 36) + .borderRadius(2) + .backgroundColor(item.isNow ? '#C0533F' : item.color) + if (item.time === '全天') { + Text(item.title) + .fontSize(12) + .fontColor('#2C231D') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text('全天') + .fontSize(9) + .fontColor('#FFFFFF') + .backgroundColor(item.color) + .borderRadius(6) + .padding({ left: 5, right: 5, top: 1, bottom: 1 }) + } else { + Column({ space: 1 }) { + Text(item.time) + .fontSize(10) + .fontWeight(FontWeight.Medium) + .fontColor(item.isNow ? '#C0533F' : '#5A4A3D') + Text(item.endTime) + .fontSize(9) + .fontColor('#A8988A') + } + .width(34) + .alignItems(HorizontalAlign.Center) + Text(item.title) + .fontSize(12) + .fontColor(item.isNow ? '#C0533F' : '#2C231D') + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + if (item.isNow) { + Text('进行中') + .fontSize(9) + .fontColor('#C0533F') + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor('#FBEEE9') + } + } + if (item.calName !== '') { + Text(item.calName) + .fontSize(9) + .fontColor(item.color) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '25%' }) + } + } + .alignItems(VerticalAlign.Center) + .width('100%') + .padding({ left: 8, right: 8, top: 5, bottom: 5 }) + .borderRadius(8) + .backgroundColor(item.isNow ? '#FBEEE9' : '#F7F2EA') + } + + /** 列表模式主体:空态 / 可滚动的行列表 */ + @Builder + private listBody() { + if (this.rows().length === 0) { + Column({ space: 6 }) { + Text(this.hint !== '' ? this.hint : '暂无日程') + .fontSize(13) + .fontColor(this.hint !== '' ? '#C0533F' : '#A8988A') + .maxLines(3) + .textAlign(TextAlign.Center) + } + .width('100%') + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + } else { + List({ space: 4 }) { + ForEach(this.rows(), (item: FbCardListRow) => { + ListItem() { + this.listRow(item) + } + .width('100%') + }, (item: FbCardListRow) => item.key) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Auto) + .cachedCount(8) + } + } + + build() { + Column({ space: 6 }) { + // ---- 顶栏:翻页 + 日期 + 今/+ ---- + Row({ space: 4 }) { + this.pageBtn('‹', 'prev') + Column({ space: 1 }) { + Text(this.dateMd) + .fontSize(12) + .fontWeight(FontWeight.Bold) + .fontColor(this.isToday ? '#A84A28' : '#2C231D') + .maxLines(1) + Text(this.lunarText === '' ? this.weekday : this.lunarText) + .fontSize(9) + .fontColor('#A8988A') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + this.pageBtn('›', 'next') + this.sideBtn() + } + .width('100%') + + Divider() + .strokeWidth(0.5) + .color('#EFE6D8') + + if (this.displayStyle === 'list') { + this.listBody() + } else { + this.timelineBody() + } + } + .width('100%') + .height('100%') + .padding(12) + .backgroundColor('#FFFFFF') + .borderRadius(16) + /* ⭐ 整卡可点:空白处也能进应用并定位到当前显示日。 + ⚠️ onClick 是**非冒泡**且**子组件优先** → 内部的 ‹ › 今 + 不会被这里抢走 */ + .onClick((): void => this.openApp()) + } +} diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index ce35db2..d5bd9e4 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -18,7 +18,7 @@ }, { "name": "card_desc", - "value": "今天的日程与最近的纪念日" + "value": "家庭日程:今天有几件事、都是什么" }, { "name": "card_2x2_name", @@ -30,7 +30,11 @@ }, { "name": "card_4x4_name", - "value": "家书 · 今日全景" + "value": "家书 · 日程" + }, + { + "name": "card_6x4_name", + "value": "家书 · 日程(宽)" } ] } diff --git a/entry/src/main/resources/base/profile/form_config.json b/entry/src/main/resources/base/profile/form_config.json index 2446f9b..4fe1873 100644 --- a/entry/src/main/resources/base/profile/form_config.json +++ b/entry/src/main/resources/base/profile/form_config.json @@ -50,6 +50,23 @@ "updateDuration": 1, "defaultDimension": "4*4", "supportDimensions": ["4*4"] + }, + { + "name": "FbCard6x4", + "displayName": "$string:card_6x4_name", + "description": "$string:card_desc", + "src": "./ets/pages/card/FbCard6x4.ets", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "light", + "uiSyntax": "arkts", + "isDefault": false, + "updateEnabled": true, + "updateDuration": 1, + "defaultDimension": "6*4", + "supportDimensions": ["6*4"] } ] }