v91 观影/任务卡加 4x4/6x4(滚动列表+新建+卡上完成任务);修任务卡无数据(解析器用错:看板嵌套需 parseTasks)

This commit is contained in:
2026-10-25 09:29:41 +08:00
parent 6711197a94
commit 7ae34e6e23
12 changed files with 1042 additions and 9 deletions
+11
View File
@@ -55,6 +55,17 @@ export class FbAppState {
/** 跳转请求序号(页面 `@Monitor` 它;同一个日期连点两次也能再触发一次) */
@Trace calJumpSeq: number = 0;
/**
* 🔴 v91:非日程卡(观影 / 任务)上的「+ 新建」按钮走这条通道。
*
* 与 `calJump*` 同一套三件套思路:key 记"哪个模块要开新建浮层",
* seq 自增触发热启动下页面的 `@Monitor`;页面用掉就把 key 清空
* (否则用户关掉浮层后每次回该页又会自己弹出来,日程页踩过)。
*/
@Trace modCreateKey: string = '';
/** 新建请求序号(页面 `@Monitor` 它;连点两次也能再触发一次) */
@Trace modCreateSeq: number = 0;
/** 是否宽屏(窗口短边 ≥ 600vp)—— 决定抽屉是「常驻侧栏」还是「滑出层」 */
@Trace wideLayout: boolean = false;
+54 -5
View File
@@ -19,7 +19,8 @@ import { FB_SERVER_STORE } from './FbServerStore';
import { FB_TOKEN_STORE } from './FbTokenStore';
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
import { fbCardBuild, FbCardData } from './FbCardData';
import { parseTaskList } from './FbParse';
import { parseTasks } from './FbParse';
import { setTaskStatus } from './FbTaskRepo';
import { FbTaskItem } from '../model/Types';
const DOMAIN: number = 0x0000;
@@ -85,6 +86,8 @@ export class FbMovieCardData {
totalCount: number = 0;
/** 最近观影(按日期倒序,最多 4 条) */
recentJson: string = '[]';
/** 🔴 v91:4×4/6×4 的滚动列表(按日期倒序,最多 30 条)—— 4×2 的卡不吃这个字段 */
listJson: string = '[]';
/** 🔴 v89 教训:ForEach key 前缀源 */
version: string = '';
}
@@ -200,6 +203,15 @@ export async function fbMovieCardBuild(ctx: common.Context): Promise<FbMovieCard
rows.push(movieRowOf(arr[i], i));
}
out.recentJson = JSON.stringify(rows);
/* v91:4×4/6×4 的滚动列表 —— 全量倒序(最多 30 条,够滑一阵子;
200 条全塞会让 FormBindingData 序列化白白变大,卡片也展示不了那么多)。 */
const list: FbMovieCardRow[] = [];
for (let i = 0; i < arr.length && list.length < 30; i++) {
list.push(movieRowOf(arr[i], i));
}
out.listJson = JSON.stringify(list);
out.state = 'ok';
out.hint = '';
return out;
@@ -212,6 +224,8 @@ export async function fbMovieCardBuild(ctx: common.Context): Promise<FbMovieCard
/** 任务卡上的一行 */
export class FbTaskCardRow {
key: string = '';
/** 🔴 v91:任务 id —— 卡片上点「完成」要靠它告诉 FbFormAbility 改哪条 */
id: number = 0;
title: string = '';
/** 截止时间文案(服务端给的显示文本,空 = 没有期限) */
dueText: string = '';
@@ -231,8 +245,10 @@ export class FbTaskCardData {
openCount: number = 0;
/** 其中逾期数(标题行给红色提示) */
overdueCount: number = 0;
/** 优先展示的几条(逾期 > 进行中 > 普通,同组按截止时间升序),最多 4 条 */
/** 逾期优先展示的几条(逾期 > 进行中 > 普通,同组按截止时间升序),最多 4 条 */
rowsJson: string = '[]';
/** 🔴 v91:4×4/6×4 的滚动列表(同一排序,最多 30 条)—— 2×2/2×4 的卡不吃这个字段 */
allJson: string = '[]';
version: string = '';
}
@@ -247,6 +263,7 @@ function taskRank(t: FbTaskItem): number {
/** 一条任务 → 卡片行 */
function taskRowOf(t: FbTaskItem): FbTaskCardRow {
const r: FbTaskCardRow = new FbTaskCardRow();
r.id = t.id;
r.key = 't' + t.id.toString() + '_' + t.status;
r.title = t.title !== '' ? t.title : '未命名任务';
r.dueText = t.dueText;
@@ -259,8 +276,12 @@ function taskRowOf(t: FbTaskItem): FbTaskCardRow {
/**
* 组装任务卡数据(异步,**永远不抛错**)。
*
* ⚠️ `/api/v1/tasks` 默认视图就是"未完成任务"(已完成走 `?view=done`);
* 这里仍按 status 过滤一道,服务端口径将来变了也不会把 done 显示成待办。
* 🔴🔴 v91 修「任务卡片没有数据」:`/api/v1/tasks` 默认视图回的是
* **看板嵌套**结构 `{ok, data:{boards:[{name,color,tasks:[…]}]}}`,
* 原来用 `parseTaskList()`(只认扁平数组)⇒ 解析成 0 条,卡片永远空。
* 改用 `parseTasks()` —— 它就是任务页看板视图用的那个扁平化解析
* (顺带把**看板色**也填进每行,卡片色条不再全是兜底色)。
* 完成态仍按 status 过滤一道,服务端口径将来变了也不会把 done 显示成待办。
*/
export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardData> {
const out: FbTaskCardData = new FbTaskCardData();
@@ -290,7 +311,7 @@ export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardDa
let items: FbTaskItem[] = [];
try {
items = parseTaskList(r.data);
items = parseTasks(r.data);
} catch (err) {
hilog.warn(DOMAIN, TAG, '任务卡数据解析失败');
}
@@ -335,11 +356,39 @@ export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardDa
rows.push(taskRowOf(open[i]));
}
out.rowsJson = JSON.stringify(rows);
/* v91:4×4/6×4 的滚动列表 —— 同一排序全量(最多 30 条) */
const all: FbTaskCardRow[] = [];
for (let i = 0; i < open.length && all.length < 30; i++) {
all.push(taskRowOf(open[i]));
}
out.allJson = JSON.stringify(all);
out.state = 'ok';
out.hint = '';
return out;
}
/**
* 🔴 v91:卡片上点「完成」—— 把任务标成 done,然后重算一份数据。
*
* ⚠️ 由 `FbFormAbility.onFormEvent` 调用(卡片自己不能发网络请求)。
* ⚠️ 标记失败(断网/401)**不抛错**:照旧重算返回旧数据,
* 用户顶多看到"没变化",不能让 FormExtensionAbility 死在半路。
* ⚠️ 401 也不踢登录(`authProbe: true`,与卡片取数同口径)。
*/
export async function fbTaskCardComplete(ctx: common.Context, id: number): Promise<FbTaskCardData> {
const token: string = await cardToken(ctx);
if (token !== '' && FB_SERVER_STORE.get() !== '') {
try {
await setTaskStatus(token, id, 'done');
} catch (err) {
hilog.warn(DOMAIN, TAG, '卡片完成任务 %{public}d 失败', id);
}
}
return fbTaskCardBuild(ctx);
}
/* ══════════════════════════════════════════════════════════════
按卡片类型分流
══════════════════════════════════════════════════════════════ */
@@ -190,8 +190,10 @@ export default class EntryAbility extends UIAbility {
/* ---- ② 把"看哪一天"交给日程页 ----
冷启动时日程页还没建,先写进全局状态(页面 `aboutToAppear` 会读);
热启动(应用已开着)走 `onNewWant`,页面不会重建 ⇒ 靠 `calJumpSeq` 自增
触发页面上的 `@Monitor`。两种入口共用同一份状态,不会"有时跳有时不跳"。 */
if (jumpIso !== '' || wantCreate) {
触发页面上的 `@Monitor`。两种入口共用同一份状态,不会"有时跳有时不跳"。
🔴 v91:`add` 动作只对**日程卡**走 calJump;观影/任务卡的「+」
走 `modCreate*` 通道(它们没有"看哪一天"的语义)。 */
if (key === 'calendar' && (jumpIso !== '' || wantCreate)) {
const st: FbAppState = fbAppState();
st.calJumpIso = jumpIso;
st.calJumpCreate = wantCreate;
@@ -199,6 +201,15 @@ export default class EntryAbility extends UIAbility {
hilog.info(DOMAIN, 'FamilyBook', '卡片请求日程:%{public}s(新建=%{public}s)',
jumpIso === '' ? '今天' : jumpIso, wantCreate ? '是' : '否');
}
/* ---- ③ v91:观影 / 任务卡上的「+ 新建」----
与 calJump 同一套:写全局状态(冷启动页面 aboutToAppear 读)+
seq 自增(热启动触发 @Monitor)。页面用掉后自己清 key。 */
if (wantCreate && (key === 'movies' || key === 'tasks')) {
const st: FbAppState = fbAppState();
st.modCreateKey = key;
st.modCreateSeq = st.modCreateSeq + 1;
hilog.info(DOMAIN, 'FamilyBook', '卡片请求 %{public}s 新建', key);
}
fbAppState().switchModule(key);
hilog.info(DOMAIN, 'FamilyBook', '从卡片进入模块:%{public}s', key);
}
@@ -24,7 +24,7 @@ import { FormExtensionAbility, formBindingData, formProvider, formInfo } from '@
import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { fbCardDataByKind } from '../data/FbCardMediaData';
import { fbCardDataByKind, fbTaskCardComplete, FbTaskCardData } from '../data/FbCardMediaData';
import { FB_CARD_FORMS } from '../data/FbCardForms';
import { FB_BG_SYNC } from '../common/FbBackgroundSync';
@@ -147,13 +147,49 @@ export default class FbFormAbility extends FormExtensionAbility {
});
}
/**
* 🔴 v91:任务卡上点「完成」—— 把任务标成 done,重算后**推给所有任务卡**。
*
* ⚠️ 推所有任务卡而不是只推这一张:还有别的任务卡(2×2 的待办数、别的 4×4)
* 也显示这条任务,只推一张会出现"这张没了、那张还挂着"。
* ⚠️ message 里的 id 用正则抠:`{"taskDone":"12"}` / 嵌套 `{params:{…}}`
* 两种形态都能命中(与既有"子串匹配"取舍一致)。
*/
private markTaskDone(formId: string, message: string): void {
const m: RegExpMatchArray | null = message.match(/"taskDone"\s*:\s*"?(\d+)/);
const id: number = m !== null ? parseInt(m[1], 10) : 0;
if (id <= 0 || isNaN(id)) {
hilog.warn(DOMAIN, TAG, '任务卡「完成」消息里没有可用 id:%{public}s', message);
return;
}
fbTaskCardComplete(this.context, id).then((data: FbTaskCardData): void => {
const binding: formBindingData.FormBindingData = formBindingData.createFormBindingData(data);
// 先回这张卡(用户手指点的那张,必须立刻看到变化)
formProvider.updateForm(formId, binding).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '任务卡 %{public}s 回推失败:%{public}s', formId, err.message);
});
// 其余任务卡重算重推(数量不多,构建走同一份缓存口径)
FB_CARD_FORMS.pushAll(this.context).catch((err: BusinessError): void => {
hilog.warn(DOMAIN, TAG, '任务完成后全量重推失败:%{public}s', err.message);
});
}).catch((err: BusinessError): void => {
hilog.error(DOMAIN, TAG, '完成任务 %{public}d 流程失败:%{public}s', id, err.message);
});
}
onFormEvent(formId: string, message: string): void {
/* 卡片上的按钮都走 `action: 'message'`,`message` 是 JSON 字符串。
⚠️ 不解析 JSON:不同版本系统给的形态可能是 `{params:{...}}` 或直接 `{...}`,
子串匹配更稳(与 SyncCalendar 同一个取舍)。
⚠️ v90:只有日程卡有 ‹ / › / 今 三个按钮;观影/任务卡不发 message 事件,
收到也直接忽略(不把 message 撞进偏移量里)。 */
收到也直接忽略(不把 message 撞进偏移量里)。
🔴 v91:任务卡多了「完成」按钮(`taskDone`),**必须最先分流**——
它不认卡片类型也不认偏移量,认不出类型时也不能丢。 */
hilog.info(DOMAIN, TAG, '卡片事件:%{public}s %{public}s', formId, message);
if (message.indexOf('taskDone') >= 0) {
this.markTaskDone(formId, message);
return;
}
FB_CARD_FORMS.kindOf(this.context, formId).then((kind: string): void => {
if (kind !== 'calendar') {
return;
@@ -0,0 +1,186 @@
/**
* 家书 FamilyBook · 4×4 服务卡片(观影 · 上概述/新建,下滚动列表)
*
* 用户 2026-10-25:「观影卡片,加4x4,和4x6,上面概述,加新建按钮,
* 下面瀑布流,就跟观影模块类似,可以滑下去看全部电影。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「记一场」);
* 下半:`List`(卡片**不支持 Scroll、只支持 List**,见 FbCard4x4 的注释)
* 装全部观影(数据侧倒序裁到 30 条),整卡可点进模块。
*
* ⚠️ 卡片不能自己发网络请求:只画 `fbMovieCardBuild()` 推过来的字段。
* ⚠️ ForEach key 带 version 前缀(v89 教训,不重绘就是它的锅)。
*/
let storageMvB = new LocalStorage();
/** 列表一行 */
class FbMovieRow9a {
key: string = '';
title: string = '';
dateText: string = '';
cinema: string = '';
rating: string = '';
}
@Entry(storageMvB)
@Component
struct FbMovieCard4x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('yearCount') yearCount: number = 0;
@LocalStorageProp('totalCount') totalCount: number = 0;
@LocalStorageProp('listJson') listJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbMovieRow9a[] {
try {
const all: FbMovieRow9a[] = JSON.parse(this.listJson) as FbMovieRow9a[];
return all;
} catch (err) {
return [];
}
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'movies' }
});
}
/** 「+ 新建」→ 进 app 直开「记一场」浮层(EntryAbility → modCreate 通道) */
private addOne(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'movies', widgetAction: 'add' }
});
}
/** 列表单行:日期 + 片名/影院(+评分) */
@Builder
listRow(item: FbMovieRow9a) {
Row({ space: 8 }) {
Text(item.dateText !== '' ? item.dateText : '—')
.fontSize(9)
.fontColor('#A8988A')
.width(46)
.maxLines(1)
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.cinema !== '') {
Text(item.cinema)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.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() {
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)
/* ⭐ 新建按钮(子组件 onClick 优先于整卡,不会连带进 app) */
Button('+')
.fontSize(13)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.borderRadius(13)
.padding(0)
.backgroundColor('#7A4E8C')
.onClick((): void => this.addOne())
}
.width('100%')
// ===== 概述行 =====
Row({ space: 8 }) {
Text(`今年 ${this.yearCount} 场`)
.fontSize(11)
.fontColor('#7A4E8C')
.fontWeight(FontWeight.Medium)
Text(`共 ${this.totalCount} 条`)
.fontSize(10)
.fontColor(this.hint !== '' ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部观影(可滚动)=====
if (this.rows().length === 0) {
this.emptyHint()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbMovieRow9a) => {
ListItem() {
this.listRow(item)
}
}, (item: FbMovieRow9a): string => `ml_${this.version}_${item.key}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
}
}
@@ -0,0 +1,186 @@
/**
* 家书 FamilyBook · 6×4 服务卡片(观影 · 上概述/新建,下滚动列表)
*
* 用户 2026-10-25:「观影卡片,加4x4,和4x6,上面概述,加新建按钮,
* 下面瀑布流,就跟观影模块类似,可以滑下去看全部电影。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「记一场」);
* 下半:`List`(卡片**不支持 Scroll、只支持 List**,见 FbCard4x4 的注释)
* 装全部观影(数据侧倒序裁到 30 条),整卡可点进模块。
*
* ⚠️ 卡片不能自己发网络请求:只画 `fbMovieCardBuild()` 推过来的字段。
* ⚠️ ForEach key 带 version 前缀(v89 教训,不重绘就是它的锅)。
*/
let storageMvB2 = new LocalStorage();
/** 列表一行 */
class FbMovieRow9b {
key: string = '';
title: string = '';
dateText: string = '';
cinema: string = '';
rating: string = '';
}
@Entry(storageMvB2)
@Component
struct FbMovieCard6x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('yearCount') yearCount: number = 0;
@LocalStorageProp('totalCount') totalCount: number = 0;
@LocalStorageProp('listJson') listJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbMovieRow9b[] {
try {
const all: FbMovieRow9b[] = JSON.parse(this.listJson) as FbMovieRow9b[];
return all;
} catch (err) {
return [];
}
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'movies' }
});
}
/** 「+ 新建」→ 进 app 直开「记一场」浮层(EntryAbility → modCreate 通道) */
private addOne(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'movies', widgetAction: 'add' }
});
}
/** 列表单行:日期 + 片名/影院(+评分) */
@Builder
listRow(item: FbMovieRow9b) {
Row({ space: 8 }) {
Text(item.dateText !== '' ? item.dateText : '—')
.fontSize(9)
.fontColor('#A8988A')
.width(46)
.maxLines(1)
Column({ space: 2 }) {
Text(item.title)
.fontSize(13)
.fontColor('#2C231D')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.cinema !== '') {
Text(item.cinema)
.fontSize(9)
.fontColor('#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.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() {
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)
/* ⭐ 新建按钮(子组件 onClick 优先于整卡,不会连带进 app) */
Button('+')
.fontSize(13)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.borderRadius(13)
.padding(0)
.backgroundColor('#7A4E8C')
.onClick((): void => this.addOne())
}
.width('100%')
// ===== 概述行 =====
Row({ space: 8 }) {
Text(`今年 ${this.yearCount} 场`)
.fontSize(11)
.fontColor('#7A4E8C')
.fontWeight(FontWeight.Medium)
Text(`共 ${this.totalCount} 条`)
.fontSize(10)
.fontColor(this.hint !== '' ? '#C0533F' : '#A8988A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部观影(可滚动)=====
if (this.rows().length === 0) {
this.emptyHint()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbMovieRow9b) => {
ListItem() {
this.listRow(item)
}
}, (item: FbMovieRow9b): string => `ml_${this.version}_${item.key}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
}
}
@@ -0,0 +1,217 @@
/**
* 家书 FamilyBook · 4×4 服务卡片(任务 · 上概述/新建,下滚动列表 + 卡上完成)
*
* 用户 2026-10-25:「任务卡片,也需要4x4和4x6,跟任务模块类似,列出所有任务,
* 可以在卡片直接点击完成。有新建按钮。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「新建任务」);
* 下半:`List` 装全部待办(数据侧排序:逾期 > 进行中 > 普通),
* 每行右侧一颗「✓ 完成」→ `postCardAction({action:'message', taskDone:id})`
* → `FbFormAbility.markTaskDone()` 调接口 → 重算重推。
*
* ⚠️ 卡片不能自己发网络请求,完成动作必须借道 FormExtensionAbility。
* ⚠️ ForEach key 带 version 前缀(v89 教训)。
*/
let storageTkB = new LocalStorage();
/** 列表一行 */
class FbTaskRow9a {
id: number = 0;
key: string = '';
title: string = '';
dueText: string = '';
status: string = '';
boardColor: string = '#A8574F';
overdue: boolean = false;
}
@Entry(storageTkB)
@Component
struct FbTaskCard4x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
@LocalStorageProp('allJson') allJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbTaskRow9a[] {
try {
const all: FbTaskRow9a[] = JSON.parse(this.allJson) as FbTaskRow9a[];
return all;
} catch (err) {
return [];
}
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks' }
});
}
/** 「+ 新建」→ 进 app 直开「新建任务」浮层(EntryAbility → modCreate 通道) */
private addOne(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks', widgetAction: 'add' }
});
}
/** 卡上直接完成(id 必须转字符串:param 数值按 int32 传) */
private complete(item: FbTaskRow9a): void {
postCardAction(this, {
action: 'message',
params: { taskDone: `${item.id}` }
});
}
/** 列表单行:色条 + 标题/截止 + ✓ 完成 */
@Builder
listRow(item: FbTaskRow9a) {
Row({ space: 8 }) {
Column()
.width(3)
.height(34)
.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)
/* ⭐ 卡上直接点完成(子组件 onClick 优先于整卡,不会连带进 app) */
Text('✓')
.fontSize(14)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.backgroundColor(item.overdue ? '#C0533F' : '#A8574F')
.onClick((): void => this.complete(item))
}
.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() {
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)
Button('+')
.fontSize(13)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.borderRadius(13)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => this.addOne())
}
.width('100%')
// ===== 概述行 =====
Row({ space: 8 }) {
Text(`待办 ${this.openCount} 条`)
.fontSize(11)
.fontColor('#A8574F')
.fontWeight(FontWeight.Medium)
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 })
.layoutWeight(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部待办(可滚动,可直接完成)=====
if (this.rows().length === 0) {
this.emptyHint()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbTaskRow9a) => {
ListItem() {
this.listRow(item)
}
}, (item: FbTaskRow9a): string => `tl_${this.version}_${item.key}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
}
}
@@ -0,0 +1,217 @@
/**
* 家书 FamilyBook · 6×4 服务卡片(任务 · 上概述/新建,下滚动列表 + 卡上完成)
*
* 用户 2026-10-25:「任务卡片,也需要4x4和4x6,跟任务模块类似,列出所有任务,
* 可以在卡片直接点击完成。有新建按钮。」
* ⇒ 上半:标题 + 更新时间 + 「+ 新建」(路由进 app 直开「新建任务」);
* 下半:`List` 装全部待办(数据侧排序:逾期 > 进行中 > 普通),
* 每行右侧一颗「✓ 完成」→ `postCardAction({action:'message', taskDone:id})`
* → `FbFormAbility.markTaskDone()` 调接口 → 重算重推。
*
* ⚠️ 卡片不能自己发网络请求,完成动作必须借道 FormExtensionAbility。
* ⚠️ ForEach key 带 version 前缀(v89 教训)。
*/
let storageTkB2 = new LocalStorage();
/** 列表一行 */
class FbTaskRow9b {
id: number = 0;
key: string = '';
title: string = '';
dueText: string = '';
status: string = '';
boardColor: string = '#A8574F';
overdue: boolean = false;
}
@Entry(storageTkB2)
@Component
struct FbTaskCard6x4 {
@LocalStorageProp('state') state: string = 'loading';
@LocalStorageProp('hint') hint: string = '';
@LocalStorageProp('updatedAt') updatedAt: string = '';
@LocalStorageProp('openCount') openCount: number = 0;
@LocalStorageProp('overdueCount') overdueCount: number = 0;
@LocalStorageProp('allJson') allJson: string = '[]';
@LocalStorageProp('version') version: string = '';
private rows(): FbTaskRow9b[] {
try {
const all: FbTaskRow9b[] = JSON.parse(this.allJson) as FbTaskRow9b[];
return all;
} catch (err) {
return [];
}
}
private openApp(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks' }
});
}
/** 「+ 新建」→ 进 app 直开「新建任务」浮层(EntryAbility → modCreate 通道) */
private addOne(): void {
postCardAction(this, {
action: 'router',
abilityName: 'EntryAbility',
params: { fbModule: 'tasks', widgetAction: 'add' }
});
}
/** 卡上直接完成(id 必须转字符串:param 数值按 int32 传) */
private complete(item: FbTaskRow9b): void {
postCardAction(this, {
action: 'message',
params: { taskDone: `${item.id}` }
});
}
/** 列表单行:色条 + 标题/截止 + ✓ 完成 */
@Builder
listRow(item: FbTaskRow9b) {
Row({ space: 8 }) {
Column()
.width(3)
.height(34)
.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)
/* ⭐ 卡上直接点完成(子组件 onClick 优先于整卡,不会连带进 app) */
Text('✓')
.fontSize(14)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.backgroundColor(item.overdue ? '#C0533F' : '#A8574F')
.onClick((): void => this.complete(item))
}
.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() {
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)
Button('+')
.fontSize(13)
.fontColor('#FFFFFF')
.width(26)
.height(26)
.borderRadius(13)
.padding(0)
.backgroundColor('#A8574F')
.onClick((): void => this.addOne())
}
.width('100%')
// ===== 概述行 =====
Row({ space: 8 }) {
Text(`待办 ${this.openCount} 条`)
.fontSize(11)
.fontColor('#A8574F')
.fontWeight(FontWeight.Medium)
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 })
.layoutWeight(1)
}
.width('100%')
Divider()
.strokeWidth(0.5)
.color('#EFE6D8')
// ===== 全部待办(可滚动,可直接完成)=====
if (this.rows().length === 0) {
this.emptyHint()
} else {
List({ space: 5 }) {
ForEach(this.rows(), (item: FbTaskRow9b) => {
ListItem() {
this.listRow(item)
}
}, (item: FbTaskRow9b): string => `tl_${this.version}_${item.key}`)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(16)
// ⭐ 整卡可点
.onClick((): void => this.openApp())
}
}
@@ -211,6 +211,24 @@ export struct FbMoviesPage {
aboutToAppear(): void {
FB_BACK.register('movies', (): boolean => this.backOnce());
this.bootstrap();
/* 🔴 v91:桌面观影卡的「+ 新建」→ 冷启动直接开「记一场」浮层
(热启动走下面的 @Monitor)。用掉就清 key:否则用户关掉浮层后
每次回本页都会自己弹出来(日程页踩过)。 */
this.applyCardAdd();
}
/** 消费卡片带来的"要开新建浮层"意图(有则用掉并打开) */
private applyCardAdd(): void {
if (fbAppState().modCreateKey === 'movies') {
fbAppState().modCreateKey = '';
this.newOpen = true;
}
}
/** 卡片在应用已开着时被点(`onNewWant` 改了全局状态)→ 这里响应 */
@Monitor('app.modCreateSeq')
onCardAdd(): void {
this.applyCardAdd();
}
/** 该电影的海报是否已确认加载失败 */
@@ -148,6 +148,24 @@ export struct FbTasksPage {
FB_BACK.register('tasks', (): boolean => this.backOnce());
this.load();
this.loadMeta();
/* 🔴 v91:桌面任务卡的「+ 新建」→ 冷启动直接开「新建任务」浮层
(热启动走下面的 @Monitor)。用掉就清 key:否则用户关掉浮层后
每次回本页都会自己弹出来(日程页踩过)。 */
this.applyCardAdd();
}
/** 消费卡片带来的"要开新建浮层"意图(有则用掉并打开) */
private applyCardAdd(): void {
if (fbAppState().modCreateKey === 'tasks') {
fbAppState().modCreateKey = '';
this.openNew();
}
}
/** 卡片在应用已开着时被点(`onNewWant` 改了全局状态)→ 这里响应 */
@Monitor('app.modCreateSeq')
onCardAdd(): void {
this.applyCardAdd();
}
/** 拉全部看板 + 分类(筛选条、编辑表单、面板的"挪分组"共用一份) */