v69:日程桌面卡片完全照 SyncCalendar 重做(四种尺寸 + 翻页 + 时间轴)

用户 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日,**不**弹新建浮层。
This commit is contained in:
2026-10-20 21:59:23 +08:00
parent 030a9c7fbb
commit 719c68202b
12 changed files with 2070 additions and 605 deletions
@@ -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);
});
}
/** 详情是不是开着 */
+103 -126
View File
@@ -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())
}
}
+193 -128
View File
@@ -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())
}
}
+481 -191
View File
@@ -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())
}
}
+522
View File
@@ -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())
}
}