Sortable Lane Schedule(拖拽排序 / 泳道排程)
用 SortableJS(本仓库常见依赖 sortablejs)实现两类交互:
- 意向池优先级:单列表纵向拖拽,改
priority/sort - 看板泳道排程:多容器(泳道)互拖,改
laneId+ 列内顺序
何时启用
- 意向池、线索池、待办池需要「拖一下就改优先级」
- 看板 / 阶段泳道需要跨列拖卡片排程
- 用户点名 Sortable、拖拽排序、泳道拖拽
硬约束
- 数据源是真相:DOM 顺序变了必须同步到数组(
splice/ 重写sort),不能只动 DOM。 - 拖完必落库或可撤销:
onEnd后调用保存 API,或提供 Undo;禁止静默只改本地。 - 句柄优先:复杂卡片用
.drag-handle,避免与按钮、链接、输入框抢事件(filter/handle)。 - 跨泳道用同一
group:意向池单列用独立 group 名;看板多列共享group: 'kanban'(或等价)。 - 销毁防泄漏:组件
onBeforeUnmount调用sortable.destroy()。 - 触摸与无障碍:移动端可开
forceFallback(按需);关键操作保留非拖拽入口(上移/下移按钮可选)。
标准工作流
Sortable Lane Schedule
- [ ] 确认已依赖 sortablejs(及 @types/sortablejs)
- [ ] 场景分流:单列优先级 / 多列泳道
- [ ] 绑定容器 ref + Sortable.create
- [ ] onEnd:重排数组 + 写回 priority/laneId
- [ ] 持久化(API / store)+ 失败回滚
- [ ] 卸载 destroy
- [ ] 验收:拖拽、跨列、点按钮仍可用
Step 1:依赖
npm i sortablejs
npm i -D @types/sortablejs
import Sortable from 'sortablejs';
Step 2A:意向池优先级(单列表)
function bindPriorityList(el: HTMLElement, items: Ref<PoolItem[]>, save: () => Promise<void>) {
return Sortable.create(el, {
animation: 150,
handle: '.drag-handle',
draggable: '.pool-row',
ghostClass: 'is-ghost',
chosenClass: 'is-chosen',
onEnd: async (evt) => {
const { oldIndex, newIndex } = evt;
if (oldIndex == null || newIndex == null || oldIndex === newIndex) return;
const next = items.value.slice();
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
items.value = next.map((row, i) => ({ ...row, priority: i + 1 }));
try {
await save();
} catch {
// 失败:重新拉列表回滚
}
},
});
}
语义:priority 越小越靠前(或按产品约定;Skill 内保持一种并写进 README)。
Step 2B:看板泳道(多容器)
每条泳道一个容器,共享 group:
function bindLane(el: HTMLElement, laneId: string, board: Ref<LaneCard[]>, persist: () => Promise<void>) {
return Sortable.create(el, {
group: 'kanban-lanes',
animation: 150,
handle: '.drag-handle',
draggable: '.lane-card',
emptyInsertThreshold: 24,
onAdd: (evt) => syncBoardFromDom(board), // 或按 evt 精确改 laneId
onUpdate: () => syncBoardFromDom(board),
onEnd: async () => {
await persist();
},
});
}
跨列时至少更新:
card.laneId = targetLaneId(阶段 / 负责人泳道)- 目标列内
sort/order重排 - 源列剩余项
order重排
Step 3:从事件精确改数据(推荐)
避免「读 DOM 猜顺序」出错时,用索引法:
onEnd(evt) {
const fromLane = evt.from.dataset.laneId!;
const toLane = evt.to.dataset.laneId!;
const oldIndex = evt.oldIndex!;
const newIndex = evt.newIndex!;
moveCard({ fromLane, toLane, oldIndex, newIndex });
}
moveCard 内操作同一份 board 状态,再 persist()。
Step 4:Vue 3 生命周期
let sortable: Sortable | null = null;
onMounted(() => {
if (listRef.value) sortable = bindPriorityList(listRef.value, items, saveOrder);
});
onBeforeUnmount(() => {
sortable?.destroy();
sortable = null;
});
多泳道:用 Map<string, Sortable>,卸载时全部 destroy。
意向池 vs 泳道对照
| 维度 | 意向池优先级 | 看板泳道排程 |
|---|---|---|
| 容器数 | 1 | N(每泳道 1 个) |
| group | 可省略或独占名 | 必须相同才能互拖 |
| 写入字段 | priority / sort |
laneId + sort |
| 空列 | 少见 | 需 min-height + emptyInsertThreshold |
反模式
| 禁止 | 原因 |
|---|---|
只 Sortable.create 不改数组 |
刷新后顺序丢失 / Vue 抽风 |
在 onMove 里打重度 API |
拖拽卡顿 |
| 整卡可拖且无 filter | 点不开详情/按钮 |
| 泳道 group 名不一致 | 无法跨列 |
| 忘记 destroy | 重复绑定、幽灵拖拽 |
Agent 交付检查
- [ ] 单列或/与多列场景按需求实现
- [ ]
onEnd后数据与展示一致 - [ ] 有保存或明确的本地持久化策略
- [ ] 句柄/过滤不挡业务点击
- [ ] 卸载已 destroy
附加资源
- 选项与排错:reference.md
- Vue 模板:templates/vue-pool-and-board.md