v90 新增观影卡与任务卡(2x2/2x4):卡片按类型分流推数据,key 带 version 前缀

This commit is contained in:
2026-10-24 23:44:46 +08:00
parent 7ebe18b2d1
commit 6711197a94
9 changed files with 1196 additions and 42 deletions
@@ -0,0 +1,125 @@
/**
* 家书 FamilyBook · 2×2 服务卡片(观影 · 今年概览)
*
* 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」
* ⇒ 排版照日程 2×2(FbCard2x2)的骨架:抬头 + 分隔线 + 大数字。
* 大数字 = **今年**看过的场数(比总数更有"今年看了多少"的当下感);
* 底部一行给最近一部电影,让卡上有具体内容而不只是一个数。
*
* ⚠️ 动态卡片不能自己发网络请求:只画 `fbMovieCardBuild` 推过来的字段。
* ⚠️ 整卡可点 → 打开应用并落到「观影」模块(fbModule: 'movies')。
*/
let storageMv2 = new LocalStorage();
/** 最近观影一行(`recentJson` 的元素,只用到这几个字段) */
class FbMovieRow2 {
title: string = '';
dateText: string = '';
}
@Entry(storageMv2)
@Component
struct FbMovieCard2x2 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('yearCount') yearCount: number = 0;
@LocalStorageProp('recentJson') recentJson: string = '[]';
/* 🔴 v89 教训:ForEach key 要带 version 前缀(本卡列表最多一条,也照规矩来) */
@LocalStorageProp('version') version: string = '';
/** 最近一部(取不到给空) */
private latest(): FbMovieRow2 {
try {
const all: FbMovieRow2[] = JSON.parse(this.recentJson) as FbMovieRow2[];
if (all.length > 0) {
return all[0];
}
} catch (err) {
// 解析失败按"没有最近一部"处理
}
const none: FbMovieRow2 = new FbMovieRow2();
return none;
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'movies' }
});
}
build() {
Column({ space: 6 }) {
// ---- 抬头:模块名 + 更新时间 ----
Row({ space: 6 }) {
Text('观影')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ---- 今年场数(占满剩余空间,垂直居中)----
Row({ space: 8 }) {
Text(`${this.yearCount}`)
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor('#7A4E8C')
Column({ space: 2 }) {
Text('今年观影')
.fontSize(12)
.fontColor('#2C231D')
.maxLines(1)
Text(this.hint !== '' ? this.hint : '点击查看')
.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)
// ---- 最近一部(有数据才画)----
if (this.latest().title !== '') {
Row({ space: 6 }) {
Column()
.width(3)
.height(26)
.borderRadius(2)
.backgroundColor('#7A4E8C')
Text('《' + this.latest().title + '》')
.fontSize(11)
.fontColor('#5A4A3D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
}
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
}
}
@@ -0,0 +1,195 @@
/**
* 家书 FamilyBook · 2×4 服务卡片(观影 · 左概览 / 右最近)
*
* 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」
* ⇒ 骨架照日程 2×4(FbCard2x4):左半 = 2×2 那套;右半 = 最近观影(最多三条)。
*
* ⚠️ 数据与 2×2 同一份(fbMovieCardBuild 算一次),条数口径一致 —— 卡片里不自己算。
*/
let storageMv4 = new LocalStorage();
/** 右半「最近观影」一行 */
class FbMovieRow4 {
key: string = '';
title: string = '';
dateText: string = '';
cinema: string = '';
rating: string = '';
}
@Entry(storageMv4)
@Component
struct FbMovieCard2x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('yearCount') yearCount: number = 0;
@LocalStorageProp('totalCount') totalCount: number = 0;
@LocalStorageProp('recentJson') recentJson: string = '[]';
/* 🔴 v89 教训:ForEach key 必须带 version 前缀,否则下次推送不重绘 */
@LocalStorageProp('version') version: string = '';
/** 右半最多三条(数据侧已裁到 4,这里再兜底截断) */
private recent(): FbMovieRow4[] {
try {
const all: FbMovieRow4[] = JSON.parse(this.recentJson) as FbMovieRow4[];
return all.slice(0, 3);
} catch (err) {
return [];
}
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'movies' }
});
}
/** 右半单条:左色条 + 片名 + 日期/影院(+评分) */
@Builder
recentRow(item: FbMovieRow4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(38)
.borderRadius(2)
.backgroundColor('#7A4E8C')
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
if (item.dateText !== '') {
Text(item.dateText)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
if (item.cinema !== '') {
Text(item.cinema)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
if (item.rating !== '') {
Text(item.rating)
.fontSize(10)
.fontColor('#C08A3E')
.maxLines(1)
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F6F2F7')
}
/** 右半空态 */
@Builder
emptyHint() {
Column() {
Text(this.hint !== '' ? this.hint : '还没有观影记录')
.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 }) {
Text('观影')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
Row({ space: 6 }) {
Text(`${this.yearCount}`)
.fontSize(38)
.fontWeight(FontWeight.Bold)
.fontColor('#7A4E8C')
Column({ space: 2 }) {
Text('今年观影')
.fontSize(11)
.fontColor('#2C231D')
.maxLines(1)
Text(this.hint !== '' ? this.hint
: ('共 ' + this.totalCount.toString() + ' 条 · 点击查看'))
.fontSize(9)
.fontColor(this.hint !== '' ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.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.recent().length === 0) {
this.emptyHint()
} else {
Column({ space: 6 }) {
ForEach(this.recent(), (item: FbMovieRow4) => {
this.recentRow(item)
}, (item: FbMovieRow4) => `mv_${this.version}_${item.key}`)
}
.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())
}
}
@@ -0,0 +1,85 @@
/**
* 家书 FamilyBook · 2×2 服务卡片(任务 · 待办概览)
*
* 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」
* ⇒ 骨架照日程 2×2;大数字 = 未完成任务数(todo + doing);
* 有逾期时副行变红给「逾期 N 条」,让"该处理了"一眼可见。
*
* ⚠️ 动态卡片不能自己发网络请求:只画 `fbTaskCardBuild` 推过来的字段。
* ⚠️ 整卡可点 → 打开应用并落到「任务」模块(fbModule: 'tasks')。
*/
let storageTk2 = new LocalStorage();
@Entry(storageTk2)
@Component
struct FbTaskCard2x2 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks' }
});
}
build() {
Column({ space: 6 }) {
// ---- 抬头:模块名 + 更新时间 ----
Row({ space: 6 }) {
Text('任务')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ---- 待办数 ----
Row({ space: 8 }) {
Text(`${this.openCount}`)
.fontSize(40)
.fontWeight(FontWeight.Bold)
.fontColor('#A8574F')
Column({ space: 2 }) {
Text('待办任务')
.fontSize(12)
.fontColor('#2C231D')
.maxLines(1)
Text(this.hint !== '' ? this.hint
: (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条' : '点击查看'))
.fontSize(10)
.fontColor(this.hint !== '' || this.overdueCount > 0 ? '#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())
}
}
@@ -0,0 +1,198 @@
/**
* 家书 FamilyBook · 2×4 服务卡片(任务 · 左概览 / 右待办)
*
* 用户 2026-10-24:「在做几个服务卡片,包括观影、任务,你看着做吧。」
* ⇒ 骨架照日程 2×4:左半 = 2×2 那套;右半 = 待办任务(最多三条)。
* 排序由数据侧定好(逾期 > 进行中 > 普通,同组按截止时间升序),
* 逾期行整条用红色 deadline 文案提示。
*
* ⚠️ 数据与 2×2 同一份(fbTaskCardBuild 算一次)。
*/
let storageTk4 = new LocalStorage();
/** 右半「待办」一行 */
class FbTaskRow4 {
key: string = '';
title: string = '';
dueText: string = '';
status: string = '';
boardColor: string = '#A8574F';
overdue: boolean = false;
}
@Entry(storageTk4)
@Component
struct FbTaskCard2x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
@LocalStorageProp('rowsJson') rowsJson: string = '[]';
/* 🔴 v89 教训:ForEach key 必须带 version 前缀,否则下次推送不重绘 */
@LocalStorageProp('version') version: string = '';
/** 右半最多三条(数据侧已裁到 4,这里再兜底截断) */
private rows(): FbTaskRow4[] {
try {
const all: FbTaskRow4[] = JSON.parse(this.rowsJson) as FbTaskRow4[];
return all.slice(0, 3);
} catch (err) {
return [];
}
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks' }
});
}
/** 右半单条:左色条 + 标题 + 截止(逾期红) */
@Builder
taskRow(item: FbTaskRow4) {
Row({ space: 8 }) {
Column()
.width(3)
.height(38)
.borderRadius(2)
.backgroundColor(item.overdue ? '#C0533F' : item.boardColor)
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
if (item.overdue) {
Text('逾期')
.fontSize(9)
.fontColor('#C0533F')
.maxLines(1)
}
if (item.dueText !== '') {
Text(item.dueText)
.fontSize(9)
.fontColor(item.overdue ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
} else {
Text(item.status === 'doing' ? '进行中' : '待办')
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor(item.overdue ? '#FBEDEA' : '#F7F2EC')
}
/** 右半空态:没有待办 */
@Builder
emptyHint() {
Column() {
Text(this.hint !== '' ? this.hint : '没有待办任务')
.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 }) {
Text('任务')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#2C231D')
.maxLines(1)
.layoutWeight(1)
Text(this.updatedAt)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
Row({ space: 6 }) {
Text(`${this.openCount}`)
.fontSize(38)
.fontWeight(FontWeight.Bold)
.fontColor('#A8574F')
Column({ space: 2 }) {
Text('待办任务')
.fontSize(11)
.fontColor('#2C231D')
.maxLines(1)
Text(this.hint !== '' ? this.hint
: (this.overdueCount > 0 ? '逾期 ' + this.overdueCount.toString() + ' 条 · 点击查看'
: '点击查看'))
.fontSize(9)
.fontColor(this.hint !== '' || this.overdueCount > 0 ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.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.rows().length === 0) {
this.emptyHint()
} else {
Column({ space: 6 }) {
ForEach(this.rows(), (item: FbTaskRow4) => {
this.taskRow(item)
}, (item: FbTaskRow4) => `tk_${this.version}_${item.key}`)
}
.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())
}
}