第 34 课:任务看板拖拽改状态
这一课我们继续沿着“任务管理页主线能力增强”往下推进。
上一课我们已经让任务页支持:
- 看板视图
- 按状态分列展示
- 本地持久化当前主视图模式
这一课继续把看板做得更像真实后台:
让用户可以直接把任务卡片从一个状态列拖到另一个状态列,并把这次状态流转立即写回页面状态和模拟服务。
这节课一句话在做什么?
这一课我们完成了 6 件事:
- 给看板卡片加上了原生拖拽能力。
- 给看板列加上了可放置高亮反馈。
- 拖拽落列后,会把目标任务状态直接更新成该列状态。
- 如果被拖拽的是当前正在编辑的任务,会主动关闭编辑弹窗。
- 刷新页面后,拖拽改过的状态仍然会保留。
- 补上了单元测试、E2E 测试和课程文档。
这一课最重要的设计结论
这一课最重要的结论是:
看板拖拽本质上不是“动画效果”,而是“单条任务状态流转”。
为什么看板拖拽应该走“页面级局部更新”?
很多初学者第一次做拖拽时,最容易把重点放错地方:
- 先想动画
- 先想第三方拖拽库
- 先想怎么把卡片挪来挪去
但真实后台里更重要的问题其实是:
- 这次拖拽改的到底是什么业务字段?
这节课里答案很明确:
- 改的是任务的
status
所以拖拽不是“纯视觉动作”,它对应的是:
- 一次单条任务更新
1. 拖拽改的是任务状态,不是列顺序
这一课我们没有做:
- 列内排序拖拽
- 自由拖拽排位
- 跨列自定义插入顺序
我们做的是更稳定的一类能力:
- 把任务拖到目标状态列
- 然后把任务的
status改成目标状态
所以这节课的核心建模其实是:
- 卡片拖拽只是输入方式
- 状态字段更新才是业务结果
2. 这类更新最适合做“局部状态回写”
当前任务页早就已经练过:
- 创建任务
- 编辑任务
- 删除任务
- 批量改状态
这一课的拖拽改状态,本质上和它们是一类事情:
- 只改一条记录
- 不需要整表重载
- 可以直接在本地状态里替换目标项
所以我们没有重新发起整页加载,而是:
- 找到目标任务
- 只更新这一条任务的
status - 同步写回模拟服务
- 让计算属性自动重新生成新的看板列
这就是非常典型的:
- 页面级局部更新
3. 拖拽只是组件输入,状态流转仍应交给页面层
这一课在TaskKanbanBoard.vue里做了拖拽交互,但它并没有自己直接改任务数组。
它做的只是:
- 记录当前拖拽中的任务
- 记录当前悬停的目标列
- 在 drop 时发出:
move-task-status
然后由页面层和useTasksPage去处理真正的数据更新。
这说明你要继续建立一个重要意识:
复杂输入组件负责收集用户动作,页面级状态负责决定真实数据怎么变。
这一课在TaskKanbanBoard.vue里做了什么?
文件:
src/components/tasks/TaskKanbanBoard.vue
1. 用原生拖拽事件实现看板卡片流转
这次我们没有引入额外拖拽库,而是直接使用浏览器原生事件:
dragstartdragenddragoverdragleavedrop
这样做的好处是:
- 学习成本更低
- 代码路径更直接
- 更适合你现在边做边理解事件流
2. 新增了拖拽中的任务状态
组件里新增了两个局部状态:
draggingTaskdragOverColumnKey
它们分别表示:
- 当前正在被拖拽的是哪条任务
- 当前用户正悬停在哪个目标列上
这两个状态都只属于:
- 当前看板组件内部交互反馈
所以放在组件内部是合适的。
3. 给卡片和列都加了拖拽反馈
这节课不仅让拖拽“能工作”,还补了两种很重要的反馈:
- 被拖拽的卡片会降低透明度并轻微缩放
- 当前可放置的目标列会高亮并轻微上浮
为什么这一步很重要?
因为拖拽交互最怕的问题就是:
- 用户不知道自己抓住了谁
- 用户不知道会落到哪里
所以你应该逐步形成一个习惯:
能拖,不代表交互完整;拖拽落点反馈和目标反馈同样属于功能本体。
这一课在useTasksPage里做了什么?
文件:
src/composables/useTasksPage.ts
1. 新增了moveTaskToStatus(taskId, nextStatus)
这是这一课真正的核心页面级能力。
它做的事情很清楚:
- 判断当前是否正在加载
- 找到目标任务
- 判断目标状态是否真的发生变化
- 必要时关闭受影响的编辑弹窗
- 在本地任务数组中替换这一条记录
- 把更新后的任务写回模拟服务
也就是说,这个函数不是“拖拽函数”,而是:
- 单条任务状态流转函数
拖拽只是调用它的一种方式。
2. 为什么不直接复用updateTask(taskDraft)?
因为updateTask(taskDraft)的设计目标是:
- 配合编辑弹窗表单
- 依赖
editingTaskId
而拖拽改状态的输入是:
taskIdnextStatus
两者上下文不同,所以我们新加了一个更贴近拖拽语义的函数,而不是硬把拖拽逻辑塞进表单更新流程。
这也是一个很重要的工程判断:
相似能力可以共享底层思路,但不一定要强行共用同一个入口函数。
3. 为什么这里要用“生成新数组再赋值”而不是直接splice?
这次看板拖拽还有一个很值得你记住的细节:
- 单条状态流转里,我们没有继续用原地
splice - 而是改成了“生成一份新数组,再整体赋值回
tasks.value”
这样做的原因是:
- 看板列是由一串计算属性推导出来的
- 单条任务状态变化后,我们希望依赖
tasks的结果一定重新计算 - 整体替换数组会让这条依赖链更稳定、更直观
你可以把它理解成一个非常实用的前端经验:
当页面上有很多由列表派生出来的计算结果时,不可变更新通常比原地修改更稳。
这一课在taskService里做了什么?
文件:
src/services/taskService.ts
1. 为什么前面“写回模拟服务”还不够?
一开始我们已经把拖拽后的任务状态写回了模拟服务层。
但 E2E 测试继续往前走时,发现还有一个真实问题:
- 拖拽后页面里立刻是对的
- 一整页刷新后,又回到了初始假数据
这说明什么?
- 说明之前的模拟服务只是模块级内存数据库
- 页面一旦整刷新,模块重新执行,内存数据就被重置了
这其实正好帮你区分了两个层次:
- 页面内局部状态更新
- 跨刷新持久化数据源
只有第 1 层,没有第 2 层,就会出现:
- “当前页里看起来改成功了”
- “刷新后又丢了”
2. 这次怎么让任务数据真正跨刷新保留?
这一课在taskService.ts里新增了一个更真实的模拟策略:
- 模块启动时优先从
localStorage恢复任务数据库 - 如果本地没有可用数据,就回退到默认假数据
- 每次新增、更新、删除后,都立刻把最新任务数据库写回
localStorage
所以现在任务服务层不再只是:
- 纯内存数据库
而是变成了:
内存数据库 + localStorage 持久化
这样整页刷新后,任务状态分布仍然能恢复。
3. 这一层为什么值得你单独学?
因为它非常接近真实项目里的一个核心认知:
- 页面状态不等于数据源
页面里能马上看到变化,只能说明:
- 视图层更新成功了
刷新后还能保留,才说明:
- 这次修改真的写进了“更稳定的数据层”
真实项目里这个“稳定数据层”通常是:
- 后端数据库
- 接口服务
- 本地离线存储
这一课我们用localStorage去模拟它。
4. 为什么拖拽会关闭编辑弹窗?
如果当前用户正在编辑某条任务,同时又通过看板把它拖到了另一列,那么编辑弹窗里的旧表单状态就可能变脏。
所以这一课延续前面批量操作的策略:
- 如果当前受影响任务正好是编辑目标
- 就主动关闭编辑弹窗
这不是“粗暴”,而是为了保证:
- 页面上下文一致
- 不让旧表单继续误导用户
这一课在页面层做了什么?
文件:
src/views/TasksView.vue
这一课页面层新增了:
handleMoveTaskToStatus(task, nextStatus)
它负责:
- 调用
moveTaskToStatus - 根据结果给出成功/失败提示
- 把事件继续留在页面层协调
这样做的好处是:
- 组件只管抛事件
- composable 只管改数据
- 页面层只管交互反馈
这个职责分层非常清楚。
这一课最值得你学会的前端思想
1. 拖拽只是输入方式,不是业务本体
以后你做任何拖拽功能,都可以先问自己:
- 这次拖拽到底在改什么业务数据?
只要你先把这个问题答清楚,后面实现就会顺很多。
这节课的答案是:
- 在改任务状态
2. 局部更新比整表重载更贴近真实后台
这节课再次强化一个你已经反复练过的能力:
- 局部更新
因为拖拽改状态只影响一条任务,所以最自然的做法就是:
- 只改这一条
- 不刷新全表
- 让看板列自动重算
这比“拖一下就整页重载”更快,也更符合真实项目体验。
3. 交互反馈也属于功能设计的一部分
这一课如果只做数据更新,不做:
- 卡片拖拽态
- 目标列高亮
- 页面级成功提示
那功能虽然“能用”,但体验还是不完整。
所以你应该开始建立一个更高标准:
输入反馈、状态反馈、结果反馈,三者合起来才算一个完整交互。
这一课补了哪些测试?
1. 单元测试
文件:
src/composables/__tests__/useTasksPage.spec.tssrc/services/__tests__/taskService.spec.ts
新增覆盖:
moveTaskToStatus()是否真的更新了目标任务状态- 是否把更新后的任务写回数据源
- 是否在影响编辑目标时关闭编辑弹窗
- 看板列数量是否随着状态流转同步变化
- 同状态重复流转是否会被忽略
这一层主要验证:
- 页面级状态更新是否正确
- 单条状态流转边界是否正确
- 模拟服务是否真的把最新任务数据库持久化到了
localStorage - 模块重新初始化后是否能恢复刚才写回的数据
2. E2E 测试
文件:
e2e/pages/TasksPage.tse2e/app.spec.ts
新增覆盖:
- 切到看板视图
- 拖拽一条任务到目标状态列
- 断言列数量更新
- 断言目标任务出现在新列里
- 刷新页面
- 再次断言最新状态分布仍然保留
这一层主要验证:
- 不只是函数能跑
- 真实浏览器中的拖拽事件也能打通
- 状态更新确实写回了数据源
这一课改了哪些文件?
src/components/tasks/TaskKanbanBoard.vuesrc/composables/useTasksPage.tssrc/services/taskService.tssrc/views/TasksView.vuesrc/composables/__tests__/useTasksPage.spec.tssrc/services/__tests__/taskService.spec.tse2e/pages/TasksPage.tse2e/app.spec.tsdocs/34-task-kanban-drag-and-drop-status-flow.mddocs/README.md
这一课最值得你真正记住什么?
如果你只记住“看板现在可以拖了”,那还不够。
你更应该记住下面这 6 点:
- 拖拽改状态的本质是单条任务状态流转。
- 拖拽输入和状态更新应该分层处理。
- 组件负责收集拖拽动作,页面级 composable 负责更新真实数据。
- 局部更新比整表重载更适合这种场景。
- 被拖拽卡片和目标列都需要明确反馈,交互才完整。
- 受影响的旧上下文必须清理,比如当前编辑弹窗。
这一课的验证命令
完成后至少应该验证:
npmrun test:unit ----runnpmrun type-checknpmrun lintnpmrun test:e2e ----project=chromium