AI 爆款文章创作器的大纲编辑采用「递归组件 + SortableJS 拖拽 + 扁平化数据同步」三层结构:每个大纲节点用递归组件渲染,节点内部再嵌套一个可拖拽列表,拖拽完成后把树形数据拍平成带父级 id 的数组回传后端。实现核心在两点:一是用 group 配置限定拖拽范围,让节点既能同级重排又能跨层移动;二是把拖拽结束后的节点顺序与父子关系转换成后端能直接落库的扁平结构。
一、技术选型:为什么用 vuedraggable
Vue 3 项目里拖拽排序的常用方案有三个,对比后 vuedraggable 胜出:
| 方案 | 树形支持 | 维护成本 | 动画体验 | 适用度 |
|---|---|---|---|---|
| 原生 HTML5 DnD | 弱 | 高,需手写层级判断 | 一般 | 简单列表 |
| SortableJS 原生 | 中,需自行接 Vue 响应式 | 中 | 好 | 通用场景 |
| vuedraggable(Vue 3 版) | 强,官方支持嵌套 | 低 | 好 | 树形大纲 |
vuedraggable 是 SortableJS 的 Vue 封装,把 DOM 操作封装成 v-model,拖拽结束自动更新数组顺序,天然适配递归组件的嵌套渲染。
二、递归组件的结构
大纲是树形结构,每个节点可能含子节点,所以渲染组件必须递归调用自身。核心数据结构如下:
interface OutlineNode {
id: string
title: string
level: number
children: OutlineNode[]
}
递归组件 OutlineTreeNode.vue 渲染当前节点后,若存在 children 就再渲染一个 vuedraggable 列表,列表项内部再次引用 OutlineTreeNode,形成层层嵌套。
<template>
<div class="node">
<div class="node-title" @dblclick="startEdit">
<span v-if="node.title">{{ node.title }}</span>
<el-input v-else v-model="editingTitle" @blur="confirmEdit" />
</div>
<!-- 有子节点时嵌套拖拽列表 -->
<draggable
v-if="node.children && node.children.length"
v-model="node.children"
:group="groupConfig"
item-key="id"
class="child-list"
>
<template #item="{ element }">
<OutlineTreeNode
:node="element"
:group="groupConfig"
:parent-id="node.id"
@change="handleChange"
/>
</template>
</draggable>
</div>
</template>
2.1 递归组件的自我引用
Vue 3 里组件用自身名字即可递归,但需要保证每个层级的 v-model 绑到不同数组。上面代码里 v-model="node.children" 绑的是当前节点的 children,所以递归调用时每层操作的都是自己的子数组,互不干扰。
三、拖拽配置与范围控制
groupConfig 是所有层级共用的配置,同一 group 内的元素可相互拖拽,跨层级移动由此实现。
const groupConfig = {
name: 'outline-group',
pull: true,
put: true
}
3.1 关键配置项
item-key:必须绑定节点唯一 id,否则拖拽后 Vue 无法正确 diff,列表渲染会错乱;animation:给排序过渡加动画,拖拽体验更顺滑;ghost-class:拖拽中占位元素的样式类,标识落点位置;handle:可选,指定拖拽手柄,避免点击标题时误触拖拽。
<draggable
v-model="node.children"
:group="groupConfig"
item-key="id"
:animation="200"
ghost-class="drag-ghost"
>
3.2 层级与缩进
子节点在渲染时通过 node.level 增加 padding-left 实现缩进,视觉上体现层级关系。拖拽结束时由数据决定层级,而不是靠 DOM 猜测,保证数据与展示一致。
四、拖拽结束后的数据同步
vuedraggable 的 end 事件在拖拽完成时触发,此时 v-model 绑定的数组已被自动重排。需要把新顺序提交给后端,做法是拍平树:
- 深度优先遍历大纲树,为每个节点生成
{ id, parentId, sortOrder }; - 按遍历顺序给
sortOrder递增赋值; - 把扁平数组一次性提交到
PUT /api/tasks/{id}/outline; - 后端按
parentId重建树并落库。
function flattenOutline(
nodes: OutlineNode[],
parentId: string | null,
result: FlatNode[]
) {
nodes.forEach((node, index) => {
result.push({
id: node.id,
parentId,
sortOrder: index
})
if (node.children?.length) {
flattenOutline(node.children, node.id, result)
}
})
return result
}
4.1 为什么不直接传整棵树
后端要求扁平结构有两个原因:一是 MySQL 存储大纲节点用 parentId + sortOrder 两列,天然是扁平模型;二是增量保存时只需更新变更节点的顺序,避免整树 diff。前端拍平后也能顺手做字段校验(如 id 去重、循环引用检测)。
五、与流式生成的衔接
大纲刚从 SSE 流式渲染完时,树是「只读」状态,节点尚未落库。此时进入编辑态前先做一次「完整 JSON → 树」的权威重建,把流式渲染的临时节点替换为权威数据,再开放拖拽。这样保证用户拖拽的是完整、一致的树,而不是半截数据。拖拽本身不触发任何网络请求,全部在本地完成,只有点「保存」才提交扁平数组,避免高频网络抖动。
这套方案落地后,大纲编辑的体验是:节点可同级换序、可拖进拖出父子层级,支持双击改名,保存后顺序与层级完整还原。相比按钮式「上移下移」,拖拽方式在多层大纲上效率提升明显。
常见问题(FAQ)
Q1:拖拽后节点顺序乱了怎么办?
检查 item-key 是否绑定了唯一 id,v-model 是否绑对了数组。
Q2:为什么要把树拍平成扁平结构再提交?
后端存储用 parentId + sortOrder 列,扁平结构天然匹配,也便于增量更新。
Q3:跨层级拖拽需要额外配置吗?
只要所有层级 group.name 相同且 pull/put 为 true,即支持跨层移动。