v79b 端上:修 ForEach 键——改完状态卡片还显示旧状态

自查发现的隐患,**编译通过、静态断言全绿、节点也都在**,只是内容陈旧:

任务行是 `@Builder taskRow(t: FbTaskItem, …)` / `taskCard(t)` —— 入参按值传递、
不参与状态观察(本仓库文件头第 A 条反模式)。而 `ForEach` 的生成键只有
`'htk_' + t.id` ⇒ 改了状态 → 重拉列表 → `this.tasks` 换成新对象,但**键没变**
⇒ ArkUI 按 key 复用旧节点、不重跑生成器 ⇒ 卡片仍显示旧状态。

用户会看到:「面板里改成已完成了,关掉面板那张卡还是待办。」

修法:新增 `model/Types.taskKey(t)`,键里带**所有会被画到行上的字段**
(status / boardId / dueAt / overdue / childDone+childTotal / title),
首页与任务页两处 ForEach 都改用它;面板里子任务行的键也补上 `k.status`。

⚠️ 代价是状态一变这一行会重建(丢的是这一行的局部动画,不是滚动位置)。
   这是刻意的取舍:**宁可重画,也不要不刷新**。

新增守卫 ⑯ 组 10 条(计数/关系型:
「键生成器必须调 taskKey」「taskKey 必须含这 6 个字段」「子任务键要带 status」),
并补 5 条反向验证注入(退回只拼 id / 漏 status / 漏 boardId / 子任务键缺 status)。

守卫 1064/0 · 反向 220/0 · 编译 BUILD SUCCESSFUL。
This commit is contained in:
2026-10-22 17:33:32 +08:00
parent 25a0797a74
commit 559d79b2e2
4 changed files with 40 additions and 5 deletions
@@ -783,7 +783,7 @@ export struct FbTaskPanel {
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (k: FbTaskKid): string => 'pk_' + k.id.toString())
}, (k: FbTaskKid): string => 'pk_' + k.id.toString() + '_' + k.status)
/* 加一条:输入框 + 「加」 */
Row({ space: 8 }) {
+29
View File
@@ -688,6 +688,35 @@ export interface FbTaskItem {
dueAt: string;
}
/**
* 任务列表 `ForEach` 的**生成键**(首页与任务页共用)。
*
* 🔴🔴 为什么不能只用 id(v79 自查出来的真隐患,编译通过、断言全绿):
* ArkUI 的 `ForEach` 用生成键做 diff —— **键没变就复用旧节点、不重跑生成器**。
* 而任务行是 `@Builder taskRow(t: FbTaskItem, …)`:入参是**按值传递、不参与状态观察**的
* (本仓库文件头第 A 条反模式)。两件事撞在一起就是:
* 改了状态 / 挪了看板 → 重拉列表 → `this.tasks` 换成新对象
* → 但键还是 `htk_7` ⇒ ArkUI 认为"这条没变" ⇒ **卡片仍显示旧状态**。
* 用户看到的是"面板里改成已完成了,关掉面板那张卡还是待办"。
*
* ⇒ 键里必须带**所有会被画到行上的字段**(状态/看板/截止/逾期/子任务进度/标题)。
* 只要有一个变了,键就变 ⇒ ForEach 重建这一行。
* ⚠️ 代价是状态一变这一行会重建(丢的是这一行的局部动画,不是滚动位置),
* 这是对的取舍:**宁可重画,也不要不刷新**。
*
* ⚠️ 日程页 `cell_' + iso + '_' + this.pickedIso` 是同一个道理的另一个写法
* (那是"选区变了、格子要重画"),别以为是随手的字符串拼接。
*/
export function taskKey(t: FbTaskItem): string {
return 'tk_' + t.id.toString()
+ '_' + t.status
+ '_' + t.boardId.toString()
+ '_' + t.dueAt
+ '_' + (t.overdue ? '1' : '0')
+ '_' + t.childDone.toString() + 'of' + t.childTotal.toString()
+ '_' + t.title;
}
/**
* 任务看板选项(新建/编辑任务时挑"归到哪个看板")。
*
+5 -2
View File
@@ -40,7 +40,7 @@
import { FbColors, FbMetric, FB_MASK } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbCalEvent, FbCatOpt, FbHomeData, FbMark, FbMemoryItem, FbRecentDay, FbTaskItem,
FbBoardOpt, FbTaskDetail, FbTaskMeta,
FbBoardOpt, FbTaskDetail, FbTaskMeta, taskKey,
FbWeather, isEditableEvent } from '../../model/Types';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
@@ -723,7 +723,10 @@ export struct FbHomePage {
} else {
ForEach(this.tasks, (t: FbTaskItem, idx: number) => {
this.taskRow(t, idx === this.tasks.length - 1)
}, (t: FbTaskItem, idx: number): string => 'htk_' + t.id.toString())
/* 🔴 v79:键必须带状态等**会被画到行上的字段**(`taskKey` 的注释有完整推导)。
只用 id 的后果:面板里改成已完成 → 重拉 → 对象换了但键没变
⇒ ArkUI 复用旧节点 ⇒ 首页这张卡还是"待办"。 */
}, (t: FbTaskItem, idx: number): string => taskKey(t))
}
}
.width('100%')
@@ -11,7 +11,7 @@
import { FbColors, FbMetric, fbTint, FbTint, FB_MASK, fbAlpha } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbBoardOpt, FbCatOpt, FbGroup, FbTaskItem, FbTaskDetail, FbTaskMeta } from '../../model/Types';
import { FbBoardOpt, FbCatOpt, FbGroup, FbTaskItem, FbTaskDetail, FbTaskMeta, taskKey } from '../../model/Types';
import { fbGroupTask } from '../../model/DemoData';
import { parseTasks, parseTaskList, parseTaskMeta } from '../../data/FbParse';
import { loadModule, loadPath } from '../../data/FbModuleLoad';
@@ -446,7 +446,10 @@ export struct FbTasksPage {
ListItem() {
this.taskCard(t)
}
}, (t: FbTaskItem): string => 'tk_' + t.id.toString())
/* 🔴 v79:键必须带状态/看板/截止/子任务进度 —— 见 `taskKey()` 的注释。
只用 id 时"勾完成→卡片仍显示待办"(任务行是按值参数的 @Builder,
键不变 ArkUI 就复用旧节点、不重跑生成器)。 */
}, (t: FbTaskItem): string => taskKey(t))
}
}, (g: FbGroup<FbTaskItem>): string => 'grp_' + g.key)
}