L13 · 拖拽排序:交互升级
🎯 本节目标:实现任务的拖拽排序和分类看板(Kanban)
📦 本节产出:支持 Drag & Drop 的看板视图 + 列表拖拽排序
🔗 前置钩子:L12 的任务分类系统(看板卡片展示分类信息)
🔗 后续钩子:L14 跨层通信、L15 异步组件TIP
本节较长(10 个章节),推荐学习路径:
- 必学: §1 技术选型、§3 列表拖拽排序、§4 核心事件、§5 Kanban 看板
- 建议了解: §2 SortableJS 能力、§6 排序持久化
- 可跳过(按需查阅): §7 移动端适配、§8 视图切换、§9 常见问题排查
1. 拖拽交互的技术选型
在实现拖拽之前,我们需要选择合适的方案。不同场景的复杂度差异极大:
| 方案 | 底层 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 原生 HTML5 Drag & Drop | draggable 属性 + 6 个事件 | 无依赖 | 触摸支持与交互行为需额外适配 | 文件拖入 |
@vueuse/core useDraggable | Pointer Events | 轻量 | 只支持单元素位移 | 拖拽面板、浮窗 |
| vuedraggable | SortableJS | Vue 3 深度集成、列表排序 | 需要额外的 SortableJS 依赖 | 列表排序、看板 ✅ |
| dnd-kit (React) | Pointer Events | 灵活 | React 生态 | — |
npm install vuedraggable@4本课固定 Vue 3 对应的 vuedraggable 4。官方文档也提供
@next安装方式;提交锁文件以固定实际版本。
2. 理解 SortableJS 的核心能力
vuedraggable 是 SortableJS 的 Vue 3 封装。SortableJS 提供三大核心能力:
3. 列表拖拽排序
3.1 基础实现
先在 taskStore 中加入并返回 reorderTodos。它只重排传入 ID 对应的位置,因此筛选后的拖拽不会删掉隐藏任务:
function reorderTodos(orderedIds: number[]) {
const ids = new Set(orderedIds)
const byId = new Map(todos.value.map(todo => [todo.id, todo]))
if (ids.size !== orderedIds.length || orderedIds.some(id => !byId.has(id))) return
const ordered = orderedIds.map(id => byId.get(id)!)
let index = 0
todos.value = todos.value.map(todo => ids.has(todo.id) ? ordered[index++]! : todo)
}
// 在 Store 的 return 中加入 reorderTodos在 TodoItem 根元素内部、完成按钮之前添加 <slot name="prefix" />,否则下面的拖拽手柄插槽不会显示。
<!-- src/components/todo/DraggableTodoList.vue -->
<script setup lang="ts">
import draggable from 'vuedraggable'
import { useTaskStore } from '@/stores/taskStore'
import { useTagStore } from '@/stores/tagStore'
import TodoItem from './TodoItem.vue'
import TagSelector from './TagSelector.vue'
import { computed, ref } from 'vue'
const taskStore = useTaskStore()
const tagStore = useTagStore()
// 用可写 computed 把筛选结果的重排映射回源数组
const sortableTodos = computed({
get: () => taskStore.filteredTodos,
set: (items: typeof taskStore.todos) => taskStore.reorderTodos(items.map(todo => todo.id)),
})
// 拖拽状态
const isDragging = ref(false)
// 顺序已由 computed setter 写回 Store,结束时只重置交互状态
function onDragEnd() {
isDragging.value = false
}
</script>
<template>
<draggable
v-model="sortableTodos"
item-key="id"
:animation="200"
ghost-class="ghost"
chosen-class="chosen"
drag-class="dragging"
handle=".drag-handle"
@start="isDragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div class="todo-row">
<TodoItem
v-bind="element"
@toggle="taskStore.toggleTodo"
@delete="taskStore.deleteTodo"
@update="taskStore.updateTodo"
>
<template #prefix>
<span class="drag-handle" :title="'长按拖拽排序'">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" />
<circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" />
<circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" />
<circle cx="11" cy="13" r="1.5" />
</svg>
</span>
</template>
</TodoItem>
<div class="todo-meta">
<TagSelector
:model-value="element.tags"
@update:model-value="taskStore.updateTodoMeta(element.id, { tags: $event })"
/>
<select
:value="element.categoryId ?? ''"
aria-label="任务分类"
@change="taskStore.updateTodoMeta(element.id, { categoryId: ($event.target as HTMLSelectElement).value || undefined })"
>
<option value="">未分类</option>
<option v-for="category in tagStore.sortedCategories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
<select
:value="element.priority"
aria-label="任务优先级"
@change="taskStore.updateTodoMeta(element.id, { priority: ($event.target as HTMLSelectElement).value as 'low' | 'medium' | 'high' })"
>
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
</div>
</div>
</template>
<!-- 空状态 -->
<template #footer>
<div v-if="sortableTodos.length === 0" class="empty-state">
<p>暂无任务</p>
</div>
</template>
</draggable>
</template>
<style scoped>
.todo-row { margin-bottom: 12px; }
.todo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 0; }
/* 拖拽手柄 */
.drag-handle {
cursor: grab;
color: #ccc;
user-select: none;
padding: 4px 8px;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.drag-handle:hover {
color: #42b883;
background: #42b88310;
}
.drag-handle:active {
cursor: grabbing;
}
/* 拖拽时留在原位的占位 —— ghost */
.ghost {
opacity: 0.3;
background: #42b88320;
border: 2px dashed #42b883;
border-radius: 8px;
}
/* 被选中(按下但未开始拖拽)—— chosen */
.chosen {
box-shadow: 0 4px 20px rgba(66, 184, 131, 0.3);
}
/* 正在拖拽的元素 —— dragging */
.dragging {
opacity: 0.9;
transform: rotate(2deg);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 40px;
color: #999;
}
</style>每个 #item 只有一个 .todo-row 根元素,其中保留 L12 的标签、分类和优先级编辑。只有手柄能发起拖拽,因此选择框和标签按钮仍可正常操作。
3.2 核心属性详解
| 属性 | 类型 | 说明 |
|---|---|---|
v-model | T[] | 绑定的数组,拖拽后自动更新 |
item-key | string | 每项的唯一字段名(如 "id") |
animation | number | 过渡动画毫秒数,0 = 无动画 |
group | string | object | 跨容器分组。"tasks" 或 { name: "tasks", pull: true, put: true } |
handle | string | CSS 选择器,只有匹配的子元素才触发拖拽 |
ghost-class | string | 占位元素的 CSS 类名 |
chosen-class | string | 选中元素的 CSS 类名 |
drag-class | string | 拖拽中元素的 CSS 类名 |
disabled | boolean | 禁用拖拽(如移动端视图) |
sort | boolean | 是否允许排序(false 则只能跨容器移动) |
4. 核心事件
interface SortableEventSummary {
// @start 和 @end 的事件对象
oldIndex?: number // 并非所有事件都提供索引
newIndex?: number // 新位置索引
from: HTMLElement // 来源容器
to: HTMLElement // 目标容器
item: HTMLElement // 被拖拽的 DOM
}
interface ChangeEvent<T> {
// @change 事件对象(更细粒度)
added?: { element: T; newIndex: number }
removed?: { element: T; oldIndex: number }
moved?: { element: T; oldIndex: number; newIndex: number }
}5. Kanban 看板视图
本节沿用 L12 的分类,把任务分成多列,并支持跨列移动和列内排序。
IMPORTANT
每列对应一个分类,另设“未分类”列。优先级与完成状态继续保留为独立属性,移动分类不会把低优先级任务隐藏,也不会把优先级误当成“进行中”状态。
在 taskStore 中加入下面的 action,并在 return 中返回它。使用已有的 reorderTodos 保存列内顺序:
function moveTodo(id: number, categoryId: string | undefined, newIndex: number) {
const todo = todos.value.find(item => item.id === id)
if (!todo) return
todo.categoryId = categoryId
const target = todos.value.filter(item => item.categoryId === categoryId && item.id !== id)
const index = Math.max(0, Math.min(newIndex, target.length))
target.splice(index, 0, todo)
reorderTodos(target.map(item => item.id))
}5.1 完整实现
<!-- src/views/KanbanView.vue -->
<script setup lang="ts">
import draggable from 'vuedraggable'
import { computed, ref } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import { useTagStore } from '@/stores/tagStore'
import type { Todo } from '@/types/todo'
const taskStore = useTaskStore()
const tagStore = useTagStore()
// 看板列定义
interface KanbanColumn {
id: string
title: string
emoji: string
color: string
items: Todo[]
}
const columns = computed<KanbanColumn[]>(() => [
...tagStore.sortedCategories.map(category => ({
id: category.id,
title: category.name,
emoji: category.icon,
color: '#42b883',
items: taskStore.todos.filter(todo => todo.categoryId === category.id),
})),
{
id: 'uncategorized', title: '未分类', emoji: '📋', color: '#888888',
items: taskStore.todos.filter(todo => !todo.categoryId),
},
])
interface ColumnChange {
added?: { element: Todo; newIndex: number }
moved?: { element: Todo; newIndex: number; oldIndex: number }
removed?: { element: Todo; oldIndex: number }
}
// :list 修改的是列的临时数组;在 added / moved 时明确写回 Store
function onColumnChange(columnId: string, event: ColumnChange) {
const change = event.added ?? event.moved
if (!change) return // 跨列移动只在目标列写回一次
taskStore.moveTodo(
change.element.id,
columnId === 'uncategorized' ? undefined : columnId,
change.newIndex,
)
}
// 当前拖拽来源列(本例高亮来源列)
const activeColumn = ref<string | null>(null)
</script>
<template>
<div class="kanban-page">
<header class="kanban-header">
<h1>📌 看板视图</h1>
<p class="kanban-subtitle">拖拽任务卡片更改分类,或在同一列内排序</p>
</header>
<div class="kanban-board">
<div
v-for="column in columns"
:key="column.id"
class="kanban-column"
:class="{ 'is-active': activeColumn === column.id }"
:style="{ '--column-color': column.color }"
>
<!-- 列头 -->
<div class="column-header">
<span class="column-title">
{{ column.emoji }} {{ column.title }}
</span>
<span class="column-count">{{ column.items.length }}</span>
</div>
<!-- 可拖拽列表 -->
<draggable
:list="column.items"
group="tasks"
item-key="id"
:animation="200"
ghost-class="kanban-ghost"
class="kanban-list"
@change="(event: ColumnChange) => onColumnChange(column.id, event)"
@start="activeColumn = column.id"
@end="activeColumn = null"
>
<template #item="{ element }">
<div class="kanban-card" :class="{ 'is-done': element.done }">
<div class="card-header">
<span class="card-title">{{ element.text }}</span>
<span class="priority-badge" :class="element.priority">
{{ element.priority }}
</span>
</div>
<div class="card-meta">
<span v-if="element.categoryId" class="card-category">
📁 {{ tagStore.categories.find(category => category.id === element.categoryId)?.name ?? element.categoryId }}
</span>
<span v-if="element.tags?.length" class="card-tags">
<span v-for="tag in element.tags" :key="tag" class="tag">
{{ tagStore.getTagById(tag)?.name ?? tag }}
</span>
</span>
</div>
<div class="card-footer">
<span class="card-date">
{{ element.createdAt }}
</span>
</div>
</div>
</template>
<!-- 列为空时的提示 -->
<template #footer>
<div v-if="column.items.length === 0" class="column-empty">
<p>拖拽任务到这里</p>
</div>
</template>
</draggable>
</div>
</div>
</div>
</template>
<style scoped>
.kanban-page {
padding: 24px;
}
.kanban-header {
margin-bottom: 24px;
}
.kanban-header h1 {
font-size: 1.5rem;
margin: 0 0 4px 0;
}
.kanban-subtitle {
color: #888;
font-size: 0.875rem;
margin: 0;
}
/* ─── 看板容器 ─── */
.kanban-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
align-items: start;
}
/* ─── 列 ─── */
.kanban-column {
background: #f8f9fa;
border-radius: 12px;
padding: 16px;
min-height: 300px;
border: 2px solid transparent;
transition: border-color 0.2s, background 0.2s;
}
.kanban-column.is-active {
border-color: var(--column-color);
background: color-mix(in srgb, var(--column-color) 5%, #f8f9fa);
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--column-color, #e0e0e0);
}
.column-title {
font-weight: 600;
font-size: 1rem;
}
.column-count {
background: var(--column-color, #e0e0e0);
color: white;
font-size: 0.75rem;
font-weight: 700;
padding: 2px 10px;
border-radius: 12px;
min-width: 24px;
text-align: center;
}
/* ─── 拖拽列表 ─── */
.kanban-list {
min-height: 100px;
}
/* ─── 卡片 ─── */
.kanban-card {
background: #fff;
padding: 14px;
border-radius: 10px;
margin-bottom: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
cursor: grab;
transition: box-shadow 0.2s, transform 0.15s;
border-left: 3px solid transparent;
}
.kanban-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.kanban-card:active {
cursor: grabbing;
}
.kanban-card.is-done {
opacity: 0.65;
}
.kanban-card.is-done .card-title {
text-decoration: line-through;
}
/* ─── 卡片内部 ─── */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.card-title {
font-weight: 500;
font-size: 0.9rem;
line-height: 1.4;
flex: 1;
}
.priority-badge {
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 8px;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
}
.priority-badge.high { background: #fee2e2; color: #dc2626; }
.priority-badge.medium { background: #fef3c7; color: #d97706; }
.priority-badge.low { background: #d1fae5; color: #059669; }
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.card-category {
font-size: 0.75rem;
color: #666;
}
.card-tags {
display: flex;
gap: 4px;
}
.tag {
font-size: 0.65rem;
background: #e8f5e9;
color: #2e7d32;
padding: 1px 6px;
border-radius: 4px;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.card-date {
font-size: 0.7rem;
color: #aaa;
}
/* ─── Ghost(占位) ─── */
.kanban-ghost {
opacity: 0.3;
background: #42b88320;
border: 2px dashed #42b883;
border-radius: 10px;
}
.kanban-ghost > * {
visibility: hidden;
}
/* ─── 空列提示 ─── */
.column-empty {
text-align: center;
padding: 32px 16px;
color: #bbb;
font-size: 0.85rem;
border: 2px dashed #e0e0e0;
border-radius: 8px;
}
</style>5.2 跨列拖拽的数据流
group="tasks" 允许这些列之间移动。event.added 提供目标列的新索引,event.moved 提供列内新索引;两者都通过 Store action 写回。仅修改 computed 返回的过滤数组,顺序不会成为源数据的一部分。
6. 拖拽排序持久化
L11 已配置持久化 todos,只要排序和分类变更写回该源数组,插件就会保存,刷新后也会按数组顺序恢复。本课不再额外维护一份容易失配的 todo-order:
// taskStore 的现有持久化配置继续保留
// persist: { key: 'vue-task-store', pick: ['todos', 'filter'], ... }
// reorderTodos 和 moveTodo 修改 todos,自动触发持久化vuedraggable 4 的两种数据接口有区别:v-model 接收新的 modelValue;:list 通过 splice 修改传入数组。看板传入的是派生列数组,所以必须通过 @change 把变化同步回 Store。
7. 移动端触摸适配
SortableJS 支持触摸交互。下面是可按设备体验调整的属性片段,合并到前面的 draggable 元素,不是独立完整组件:
<draggable
v-model="items"
item-key="id"
:delay="150"
:delay-on-touch-only="true"
:touch-start-threshold="5"
/>| 属性 | 说明 |
|---|---|
delay | 按住多久后开始拖拽(防止误触) |
delay-on-touch-only | delay 只在触摸设备生效,鼠标设备不延迟 |
touch-start-threshold | 等待 delay 期间,移动达到多少像素会取消这次延迟拖拽 |
8. 视图切换:列表 ↔ 看板
在路由中添加看板视图,让用户自由切换:
// src/router/index.ts
{
path: '/kanban',
name: 'kanban',
component: () => import('@/views/KanbanView.vue'),
meta: { title: '看板视图' },
},<!-- Header 中添加切换按钮 -->
<nav class="view-switcher">
<RouterLink to="/" class="view-btn" active-class="active">
📋 列表
</RouterLink>
<RouterLink to="/kanban" class="view-btn" active-class="active">
📌 看板
</RouterLink>
</nav>9. 常见问题排查
| 问题 | 原因 | 解决 |
|---|---|---|
| 拖拽后列表数据没变 | 绑定只读 computed,或只修改过滤后的临时数组 | 使用可写 computed 或 change handler 明确写回 Store |
| 跨列后卡片消失或回原位 | 分列条件遗漏任务,或没有同步所属分类 | 保证分列覆盖所有任务,并处理目标列 added 事件 |
| 动画不生效 | 忘记设置 animation | 添加 :animation="200" |
| ghost 样式不生效 | 类名冲突或 scoped 样式 | ghost-class 用全局样式或 :deep() |
| 拖拽和滚动冲突 | 触摸手势未区分用途 | 配合手柄、触摸延迟测试,避免禁掉整个列表的滚动 |
在 HomeView 中导入 DraggableTodoList,用 <DraggableTodoList /> 替换 L12 的整个 <TransitionGroup>。保留 CategorySidebar、FilterBar、TodoInput、TodoFilter 和 TodoStats,不要同时渲染旧列表。元数据编辑已移到 DraggableTodoList 的每一行,不需要删掉这部分功能。
同时清理 HomeView 中已经迁移到子组件的代码:删除 TodoItem、TagSelector、useTagStore 的导入和 const tagStore = useTagStore()。原有两处解构改为:
const { filter, stats } = storeToRefs(taskStore)
const { addTodo, clearDone } = taskStorefilteredTodos、toggleTodo、deleteTodo、updateTodo 由 DraggableTodoList 使用,HomeView 不再重复解构。原列表专用的 .list-* 动画样式也可删除,拖拽位移动画由 animation 属性控制。
看板依赖合法分类引用,L12 的删除分类 action 会把相关任务移回“未分类”。API 依据 Vue Draggable 4 与 SortableJS。
10. 本节总结
检查清单
- [ ] 能用 vuedraggable 实现列表拖拽排序
- [ ] 理解
v-model、item-key、group、handle核心属性 - [ ] 能实现 Kanban 看板的跨列拖拽并同步状态
- [ ] 能用
ghost-class、chosen-class、drag-class自定义拖拽体验 - [ ] 能在
@change事件中处理跨列数据同步 - [ ] 能处理排序结果的持久化
- [ ] 能处理移动端触摸的延迟配置
Git 提交
git add .
git commit -m "L13: 拖拽排序 + Kanban 看板视图"🔗 → 下一节
L14 将学习 provide/inject 等高级组件通信方式——当看板中的子组件需要访问跨层级数据时,provide/inject 比逐层传 props 优雅得多。