Skip to content

L13 · 拖拽排序:交互升级 ​

🎯 本节目标:实现任务的拖拽排序和分类看板(Kanban)
📦 本节产出:支持 Drag & Drop 的看板视图 + 列表拖拽排序
🔗 前置钩子:L12 的任务分类系统(看板卡片展示分类信息)
🔗 后续钩子:L14 跨层通信、L15 异步组件

TIP

本节较长(10 个章节),推荐学习路径:

  • 必学: §1 技术选型、§3 列表拖拽排序、§4 核心事件、§5 Kanban 看板
  • 建议了解: §2 SortableJS 能力、§6 排序持久化
  • 可跳过(按需查阅): §7 移动端适配、§8 视图切换、§9 常见问题排查

1. 拖拽交互的技术选型 ​

在实现拖拽之前,我们需要选择合适的方案。不同场景的复杂度差异极大:

方案底层优点缺点适用场景
原生 HTML5 Drag & Dropdraggable 属性 + 6 个事件无依赖触摸支持与交互行为需额外适配文件拖入
@vueuse/core useDraggablePointer Events轻量只支持单元素位移拖拽面板、浮窗
vuedraggableSortableJSVue 3 深度集成、列表排序需要额外的 SortableJS 依赖列表排序、看板 ✅
dnd-kit (React)Pointer Events灵活React 生态—
bash
npm install vuedraggable@4

本课固定 Vue 3 对应的 vuedraggable 4。官方文档也提供 @next 安装方式;提交锁文件以固定实际版本。


2. 理解 SortableJS 的核心能力 ​

vuedraggable 是 SortableJS 的 Vue 3 封装。SortableJS 提供三大核心能力:


3. 列表拖拽排序 ​

3.1 基础实现 ​

先在 taskStore 中加入并返回 reorderTodos。它只重排传入 ID 对应的位置,因此筛选后的拖拽不会删掉隐藏任务:

typescript
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" />,否则下面的拖拽手柄插槽不会显示。

vue
<!-- 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-modelT[]绑定的数组,拖拽后自动更新
item-keystring每项的唯一字段名(如 "id")
animationnumber过渡动画毫秒数,0 = 无动画
groupstring | object跨容器分组。"tasks" 或 { name: "tasks", pull: true, put: true }
handlestringCSS 选择器,只有匹配的子元素才触发拖拽
ghost-classstring占位元素的 CSS 类名
chosen-classstring选中元素的 CSS 类名
drag-classstring拖拽中元素的 CSS 类名
disabledboolean禁用拖拽(如移动端视图)
sortboolean是否允许排序(false 则只能跨容器移动)

4. 核心事件 ​

typescript
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 保存列内顺序:

typescript
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 完整实现 ​

vue
<!-- 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:

typescript
// taskStore 的现有持久化配置继续保留
// persist: { key: 'vue-task-store', pick: ['todos', 'filter'], ... }
// reorderTodos 和 moveTodo 修改 todos,自动触发持久化

vuedraggable 4 的两种数据接口有区别:v-model 接收新的 modelValue;:list 通过 splice 修改传入数组。看板传入的是派生列数组,所以必须通过 @change 把变化同步回 Store。 ​

7. 移动端触摸适配 ​

SortableJS 支持触摸交互。下面是可按设备体验调整的属性片段,合并到前面的 draggable 元素,不是独立完整组件:

vue
<draggable
  v-model="items"
  item-key="id"
  :delay="150"
  :delay-on-touch-only="true"
  :touch-start-threshold="5"
/>
属性说明
delay按住多久后开始拖拽(防止误触)
delay-on-touch-onlydelay 只在触摸设备生效,鼠标设备不延迟
touch-start-threshold等待 delay 期间,移动达到多少像素会取消这次延迟拖拽

8. 视图切换:列表 ↔ 看板 ​

在路由中添加看板视图,让用户自由切换:

typescript
// src/router/index.ts
{
  path: '/kanban',
  name: 'kanban',
  component: () => import('@/views/KanbanView.vue'),
  meta: { title: '看板视图' },
},
vue
<!-- 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()。原有两处解构改为:

typescript
const { filter, stats } = storeToRefs(taskStore)
const { addTodo, clearDone } = taskStore

filteredTodos、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 提交 ​

bash
git add .
git commit -m "L13: 拖拽排序 + Kanban 看板视图"

🔗 → 下一节 ​

L14 将学习 provide/inject 等高级组件通信方式——当看板中的子组件需要访问跨层级数据时,provide/inject 比逐层传 props 优雅得多。