任务清单最容易停在"能显示几行文字"的阶段。真正开始操作以后,新增任务要进入列表,筛选条件要立即生效,勾选状态要同步顶部计数,宽屏详情还要始终指向当前任务。只要其中一个状态单独维护,页面很快就会出现列表和详情不一致的问题。
这一版从单一数据源开始。每个 TaskItem 同时保存标题、详情、完成状态和标签,页面只记录当前选择、筛选条件与输入内容,不再额外维护一份"已完成列表"。
typescript
interface TaskItem {
id: number
title: string
detail: string
done: boolean
tag: string
}
@State selectedId: number = 1
@State filter: string = '全部'
@State newTask: string = ''
@State tasks: TaskItem[] = [/* 初始任务 */]

筛选结果通过 visibleTasks() 实时派生。选择"待处理"时过滤 done === false,选择"已完成"时过滤完成项,其他情况返回完整数组。这样勾选任务以后不需要手动维护第二份数组,当前筛选下的列表会随 tasks 更新。
typescript
private visibleTasks(): TaskItem[] {
if (this.filter === '待处理') {
return this.tasks.filter((item: TaskItem) => !item.done)
}
if (this.filter === '已完成') {
return this.tasks.filter((item: TaskItem) => item.done)
}
return this.tasks
}

状态切换没有直接写 item.done = !item.done。任务数组属于 @State,通过 map() 返回新对象和新数组,ArkUI 能够明确观察到引用变化;未命中的元素保持原对象,只有目标任务被替换。
typescript
private toggleTask(id: number): void {
this.tasks = this.tasks.map((item: TaskItem) => {
if (item.id === id) {
return {
id: item.id,
title: item.title,
detail: item.detail,
done: !item.done, // 只替换被操作的任务
tag: item.tag
}
}
return item
})
}
直接修改数组元素在简单页面里有时也能看到变化,但随着列表、计数和详情同时依赖这份数据,刷新边界会越来越难判断。现在顶部完成数量直接对 tasks 过滤,详情按钮也调用同一个 toggleTask(),两处行为保持一致。
新增流程多处理了几个容易漏掉的状态。输入先 trim(),空字符串停在错误提示;成功后把新任务放到列表顶部,选中它,清空输入框,并把筛选恢复成"全部"。如果新增时仍停留在"已完成",新建的未完成任务会被过滤掉,用户会误以为按钮没有生效,因此筛选复位不是装饰动作。
typescript
private addTask(): void {
const title: string = this.newTask.trim()
if (title.length === 0) {
this.tip = '任务内容不能为空'
return
}
const task: TaskItem = {
id: Date.now(),
title: title,
detail: '刚刚添加,稍后补充详情',
done: false,
tag: '临时'
}
this.tasks = [task, ...this.tasks]
this.selectedId = task.id
this.newTask = ''
this.filter = '全部'
this.tip = `已添加:${title}`
}

手机宽度下只展示任务列表,减少详情区域对纵向空间的占用。页面宽度达到700以后,左侧列表占46%,右侧详情使用剩余空间。这里没有创建两套页面,TaskList() 和 DetailPanel() 都读取同一组状态。
typescript
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.wideMode = Number(newValue.width) >= 700
})
if (this.wideMode) {
Row({ space: 18 }) {
Column() { this.TaskList() }.width('46%').height('100%')
Column() { this.DetailPanel() }.layoutWeight(1).height('100%')
}
} else {
this.TaskList()
}
初始运行时,列表里同时存在已完成和待处理任务,顶部计数从同一数组计算。勾选框、标签和选中背景给出了三种不同状态,后续操作不需要跳转页面。

切到"待处理"以后,已经完成的条目从当前视图移除,但数据没有删除;选中某条任务时,背景变化由 selectedId 控制。

当前版本的数据只存在组件内存中,应用重启后会回到初始任务;详情文本也还没有编辑入口。继续完善时,应先接入持久化,再为新增任务增加详情和标签输入。分栏本身已经与数据层解耦,这些字段扩展不需要重写手机和平板两套状态逻辑。
选中任务与筛选结果不是同一份状态
selectedId 保存的是全量任务中的选择,visibleTasks() 返回的是当前筛选结果。二者职责不同:切到"已完成"时,被选中的待处理任务可能暂时不在列表里,但原数据没有消失。宽屏详情仍可按ID从完整数组查找。
typescript
private selectedTask(): TaskItem {
const result: TaskItem[] = this.tasks.filter(
(item: TaskItem) => item.id === this.selectedId
)
return result.length > 0 ? result[0] : this.tasks[0]
}
当前回退到 tasks[0],前提是数组至少有一条记录。加入删除功能后,最后一条任务被删除会让这里返回 undefined,详情区域继续访问标题就会出错。删除能力进入工程前,需要先定义空任务状态,并在删除当前项后选择相邻任务。
详情面板本身没有复制任务内容,所有文字和按钮都通过 selectedTask() 读取:
typescript
@Builder
DetailPanel() {
Column({ space: 18 }) {
Text('任务详情')
Text(this.selectedTask().title)
.fontSize(26)
.fontWeight(FontWeight.Bold)
Text(this.selectedTask().detail)
.fontSize(16)
.lineHeight(25)
Text(`标签 · ${this.selectedTask().tag}`)
Button(this.selectedTask().done ? '重新打开' : '标记完成')
.onClick(() => {
this.toggleTask(this.selectedId)
})
}
}
列表勾选和详情按钮最终调用同一个 toggleTask(),避免两种入口产生不同结果。顶部的完成数也直接过滤任务数组,不需要在两个按钮里各自加减计数。
宽屏断点为什么放在页面容器上
断点判断绑定最外层容器的 onAreaChange,读取的是页面实际布局宽度,而不是假设设备型号。窗口缩放、分屏或横竖屏变化都能重新计算 wideMode。
typescript
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.wideMode = Number(newValue.width) >= 700
})
700是当前页面根据列表最小宽度和详情阅读宽度选择的经验断点,不是系统规定值。左栏设为46%,右栏用 layoutWeight(1) 吸收剩余空间,18像素间距只出现于宽屏分栏。
窄屏时详情面板不渲染,因此点击任务只改变选中背景,没有单独详情页。若手机也需要编辑详情,应增加页面跳转或覆盖层,而不是强行把分栏压缩进小屏。宽屏与窄屏可以共享数据状态,但交互路径不必完全相同。
数据落盘前先处理未完成的输入
当前任务只存在内存中。接入本地存储时,保存对象应包含 tasks,筛选条件和 wideMode 属于临时UI状态,不需要落盘。selectedId 是否恢复可以根据体验决定,但恢复前必须确认对应任务仍存在。
新增任务使用 Date.now() 生成ID,手动点击场景足够;批量导入或并发创建时需要更可靠的唯一标识。标签和详情目前使用默认值,完整表单应在构造 TaskItem 前一次完成校验,避免先插入记录、再逐字段补写造成中间状态。