在这里插入图片描述

前言:任务卡片变复杂后,先别急着加字段

待办列表最初往往只有标题和完成状态。做着做着,需求开始增加:要显示分类、截止时间、优先级;要支持筛选和搜索;输入框里还要暂存一条尚未提交的新任务。很多页面就是从这里开始失控的——任务对象里混入搜索词,卡片里保存勾选状态,筛选后又维护一份“已完成列表”,最后一个点击动作要改三四个地方。

解决方法不是把类型写得更长,而是在写字段之前先问一句:这个值是任务本身的事实,还是当前页面的观看方式,还是尚未提交的用户输入? 三者放错位置,后面再漂亮的 UI 也会被同步问题拖住。

当前鸿蒙待办面板把这个边界控制在一个 TodoItem 接口和少量页面状态里。本文从实际的字段设计出发,说明哪些值应该进入任务模型,哪些值只应该存在于页面状态,为什么列表结果和计数不应该被重复保存,以及修改数组时为什么要让写入路径保持集中。

一、先把“任务是什么”定义清楚

当前工程为任务定义了以下类型:

type TodoPriority = '高' | '普通' | '低';

interface TodoItem {
  id: number;
  title: string;
  category: string;
  dueLabel: string;
  priority: TodoPriority;
  done: boolean;
}

这六个字段都描述同一条任务本身。

  • id:稳定身份,用于更新和列表 key;
  • title:任务要做什么;
  • category:任务归属的业务分类;
  • dueLabel:当前页面展示的时间标签;
  • priority:预先约束为高、普通、低,避免任意字符串扩散;
  • done:任务是否完成,是筛选和视觉样式的重要依据。

其中最容易被低估的是 id。页面一开始只有三条静态数据时,使用数组下标似乎也能找到任务;但新任务插到顶部、列表被筛选、以后再加入排序时,下标代表的对象就会变化。稳定 id 是任务的身份,不是它当前排在第几位。

TodoPriority 采用联合类型而不是任意 string,是另一个小但有用的约束。当前颜色方法只处理“高”“普通”“低”三种优先级:

private priorityColor(priority: TodoPriority): ResourceColor {
  if (priority === '高') {
    return $r('app.color.priority_high');
  }
  if (priority === '低') {
    return $r('app.color.priority_low');
  }
  return $r('app.color.priority_normal');
}

如果外部随意传入“紧急”“中等”之类的值,页面不会知道该怎样展示。先收紧模型的可选范围,比在每个渲染位置补一层兜底更可靠。

二、区分源数据、页面条件与临时输入

任务模型之外,当前页面还维护了几类状态:

type TodoFilter = '全部' | '进行中' | '已完成';

@State private todos: TodoItem[] = [];
@State private filter: TodoFilter = '全部';
@State private keyword: string = '';
@State private newTitle: string = '';
private nextId: number = 4;

可以按下面的方式理解它们:

类别 代表值 应该回答的问题
源数据 todos 当前有哪些任务,它们各自是什么状态?
页面条件 filterkeyword 用户此刻想看哪一部分任务?
临时输入 newTitle 用户正在编辑、但尚未提交什么内容?
内部辅助值 nextId 下一条内存任务该分配哪个编号?

这里有一个决定性的边界:搜索词不属于任务,筛选条件也不属于任务。用户把搜索框从“文章”改成“验证”,并不意味着所有任务的分类被修改;用户点了“已完成”,也不意味着未完成任务从数据源消失。它们只改变当前视图的选择方式。

同理,newTitle 也不应该立刻写进 todos。只有用户点击添加或提交输入后,经过 trim() 校验,才创建新的 TodoItem。这样用户输入到一半后取消、切换筛选、清空文本,都不会污染已存在的任务数据。

三、不要给同一事实保存第二份副本

下面这种设计表面直观,实际上非常危险:

// 不建议:同一批任务被存成多份状态
@State private allTodos: TodoItem[] = [];
@State private activeTodos: TodoItem[] = [];
@State private completedTodos: TodoItem[] = [];
@State private completedCount: number = 0;

勾选一条任务时,你必须同时更新 allTodos、从 activeTodos 移除、向 completedTodos 插入、修改 completedCount。新增、删除、恢复、搜索后又会出现更多同步分支。只要有一个按钮漏掉某个更新,页面不同区域就会互相矛盾。

当前实现只保存 todos,列表结果和数量通过方法计算:

private getRemainingTodoCount(): number {
  return this.todos.filter((item: TodoItem): boolean => !item.done).length;
}

private filterCount(filter: TodoFilter): number {
  if (filter === '全部') {
    return this.todos.length;
  }
  if (filter === '进行中') {
    return this.getRemainingTodoCount();
  }
  return this.todos.length - this.getRemainingTodoCount();
}

这段代码每次都会遍历数组。对于当前这样的轻量待办列表,换来的是更清楚的正确性:计数永远从任务事实得出,不需要猜测某个局部变量是否已经同步。如果未来任务量非常大,需要优化,再基于真实的性能测量决定是否缓存;不要在没有瓶颈时先引入多份状态。

四、修改任务时,使用一个明确的写入入口

任务卡片上的复选框会触发完成状态变化。当前工程没有让卡片直接修改自己持有的对象,而是把 id 和新的 done 值交给页面的 toggleTodo()

private toggleTodo(id: number, done: boolean): void {
  this.todos = this.todos.map((item: TodoItem): TodoItem => {
    if (item.id === id) {
      return {
        id: item.id,
        title: item.title,
        category: item.category,
        dueLabel: item.dueLabel,
        priority: item.priority,
        done: done
      };
    }
    return item;
  });
}

这里包含两层意图。第一,id 让更新定位到任务身份,而不是当前数组位置。第二,重新赋值给 this.todos 让状态变化入口一眼可见。以后查看 bug 时,搜索 toggleTodo 就能知道“完成状态是在哪里改的”。

写成原地修改也许更短,例如直接给某项赋值 item.done = done,但当页面数据被多个区域读取、以后又要接入持久化时,明确地生成新数组更容易追踪变化。这里并不是说所有业务代码都不能改对象,而是建议在状态边界上保留清晰的输入和输出。

五、新建任务不要跳过模型约束

新增动作把输入框临时状态转换为真正任务,必须经过一次明确的“提交边界”。当前方法先清理空白字符,空标题直接返回;成功后再补齐模型要求的分类、时间标签、优先级和完成状态:

private addTodo(): void {
  const title = this.newTitle.trim();
  if (title.length === 0) {
    return;
  }
  this.todos = [{
    id: this.nextId,
    title: title,
    category: '收集箱',
    dueLabel: '待安排',
    priority: '普通',
    done: false
  }, ...this.todos];
  this.nextId += 1;
  this.newTitle = '';
  this.filter = '全部';
}

这段代码解决了三个实际体验问题:空白输入不会生成空卡片;新任务有完整字段,渲染卡片时不必到处判断 undefined;添加后切到“全部”,用户能立即看到新增结果。后续增加日期选择、分类选择或优先级选择时,也应该在提交边界组装完整对象,而不是先插入半成品、再由多个组件分别填字段。
在这里插入图片描述

六、哪些东西现在不该硬塞进 TodoItem

为了避免模型膨胀,下面几类值暂时不适合进入当前任务接口:

  • 当前筛选页签:它描述页面,不描述任务;
  • 搜索词:它描述一次查询,不描述任务;
  • 卡片是否处于按下、悬停或展开状态:它属于 UI 局部交互;
  • 列表中第几项:它会随筛选和排序变化;
  • “是否已保存到本地”:如果未来需要,可由数据层的同步状态统一描述,不能混淆为任务完成状态;
  • 组件颜色:当前工程把颜色放在资源文件中,用 priorityColor() 映射,而不是把十六进制颜色写入每条任务。

模型越靠近业务事实,越容易被不同页面复用。比如将来做“今日任务”“按分类统计”“任务详情”,都可以继续读取同一份 TodoItem;而搜索框内容、卡片展开状态则由各自页面负责。

七、用几个边界场景检查模型有没有放错位置

判断状态边界是否合理,可以用以下场景做自检。

场景一:先搜索再勾选。 在“进行中”中搜索一个标题,勾选匹配任务后,列表应该根据同一筛选与搜索条件重新计算。若卡片还留在当前列表,通常说明局部状态没有回写到 todos

场景二:先添加再切换页签。 新任务默认未完成,添加后在“全部”和“进行中”可见,在“已完成”不可见。若只能在某一个页签看到,说明新增逻辑写进了派生结果而不是源数组。

场景三:搜索无结果。 搜索不到任务时,todos 不应变空;清空关键词后原任务仍出现。若清空后无法恢复,说明搜索动作错误地改写了源数据。

场景四:任务顺序变化。 新任务插到头部后,勾选旧任务仍应定位正确。这能检查是否使用稳定 id,而不是数组下标。

小结

待办页面的数据设计可以浓缩成一句话:任务模型保存任务事实,页面状态保存用户当前意图,临时输入只在提交时转为任务,列表和计数一律从源数据派生。

当前 TodoItem 虽然简单,却已经给后续扩展留下了正确边界。将来加入编辑、删除、持久化时,优先继续保持“一个任务源、一条写入路径、多处派生展示”的结构;这样新功能增加的是能力,而不是新的同步负担。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐