效果
目标
实现任务看板拖拽
过程
获取所有任务,遍历任务状态,进行归类显示,绑定drag事件,进行拖拽
技术实现
vue3+ts
主要代码展示
TaskBoard.vue调用组件
<template>
<div class="task-board-warper">
<task-board
:task-lane-list="taskLaneList"
@change="handleChange"
@contextmenu="handleEvent($event, 'contextmenu')"
@dblclick="handleEvent($event, 'dblclick')"
@click="handleEvent($event, 'click')"
>
</task-board>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
name: 'TaskBoardExample',
setup() {
let taskLaneList = ref([
{
id: '1',
name: '进行中',
tasks: [
{
id: '11',
type: '',
name: '任务11',
status: 0
},
{
id: '12',
type: '',
name: '任务12',
status: 0
},
{
id: '13',
type: '',
name: '任务13',
status: 0
}
]
},
{
id: '2',
name: '已完成',
tasks: [
{
id: '22',
type: '',
name: '任务2',
status: 0
}
]
},
{
id: '3',
name: '未开始',
tasks: [
{
id: '33',
type: '',
name: '任务3',
status: 0
},
{
id: '34',
type: '',
name: '任务34',
status: 0
}
]
},
{
id: '4',
name: '已终止',
tasks: [
{
id: '44',
type: '',
name: '任务4',
status: 0
}
]
},
{
id: '5',
name: '待规划',
tasks: [
{
id: '55',
type: '',
name: '任务55',
status: 0
}
]
},
{
id: '6',
name: '已过期',
tasks: [
{
id: '66',
type: '',
name: '任务66',
status: 0
}
]
}
]);
const handleChange = (data: any) => {
taskLaneList.value = data;
};
const handleEvent = (data: any, eventName: string) => {
console.log('🔥log=>TaskBoard=>91:eventName:%o,data:%0', eventName, data);
};
return {
handleChange,
taskLaneList,
handleEvent
};
}
});
</script>
<style lang="less" scoped>
.task-board-warper {
width: 100%;
height: calc(100% - 50px);
padding-left: 40px;
margin: 0 auto;
}
</style>
main.ts
import { createApp } from 'vue';
import App from './App.vue';
import router from './router/index';
import { TaskBoard } from '../package';
import CommonDirective from './directive';
const app = createApp(App);
app.use(CommonDirective);
app.use(GanttChart);
app.use(router);
app.mount('#app');
TaskBoard.vue组件
<template>
<div class="task-board">
<TaskList
v-for="item in stateData"
:key="item.id"
:task-title="item.name"
:tasks="item.tasks"
:parent-id="item.id"
:drag-lane-active="dragActive(item.id)"
@dragleave="onDragLeave"
@dragover="onDragOver($event, item.id, { type: 'taskLane' })"
@drop="onDrop($event, item.id)"
>
<template #header>
<slot name="taskLaneHeader" :taskLane="item"></slot>
</template>
<template #taskNode="{ task }">
<slot name="taskNode" :task="task"></slot>
</template>
</TaskList>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, provide, watch, ref, Ref, computed } from 'vue';
import TaskList from './component/TaskList.vue';
import { useDrag, DragKeyList } from './hooks/useDrag';
import { TaskLaneList, Task } from './type';
import clonedeep from 'lodash.clonedeep';
export default defineComponent({
name: 'TaskBoard',
components: { TaskList },
props: {
taskLaneList: {
type: Array as PropType<TaskLaneList>,
default: []
}
},
emits: ['change', 'update:data', 'click', 'dblclick', 'contextmenu'],
setup(props, { emit }) {
const emitChange = () => {
const rst = clonedeep(stateData.value);
emit('update:data', rst);
emit('change', rst);
};
const triggerEvent = (eventName: 'change' | 'update:data' | 'click' | 'dblclick' | 'contextmenu', data: any) => {
emit(eventName, clonedeep(data));
};
const stateData = ref<TaskLaneList>([]);
watch(
() => props.taskLaneList,
val => {
stateData.value = val;
},
{
immediate: true
}
);
// 删除拖拽任务,并返回
const deleteTask = (dragLaneKey: string, dragTaskKey: string): Task | undefined => {
let targetNode: Task | undefined = undefined;
stateData.value.forEach(x => {
if (x.id === dragLaneKey) {
for (let i = 0; i < x.tasks.length; i++) {
if (x.tasks[i].id === dragTaskKey) {
targetNode = clonedeep(x.tasks[i]);
x.tasks.splice(i, 1);
i--;
}
}
}
});
return targetNode;
};
const afterDrop = (e: DragEvent, dragKeys: Readonly<Ref<DragKeyList>>) => {
if (dragKeys.value.dragStartParams && dragKeys.value.dragStartParams.parentId && dragKeys.value.dragStartKey) {
const isOrder = dragKeys.value.dragOverParams.type === 'task'; // 拖拽到任务上需要进行排序
if (isOrder && dragKeys.value.dragStartKey === dragKeys.value.dragOverKey) return;
const targetNode = deleteTask(dragKeys.value.dragStartParams.parentId, dragKeys.value.dragStartKey);
if (targetNode) {
stateData.value.forEach(x => {
if (x.id === dragKeys.value.dragEndKey) {
if (isOrder) {
for (let i = 0; i < x.tasks.length; i++) {
if (x.tasks[i].id === dragKeys.value.dragOverKey) {
x.tasks.splice(i, 0, targetNode);
emitChange();
return;
}
}
} else {
x.tasks.push(targetNode);
emitChange();
}
}
});
}
}
};
const { dragKeys, onDragStart, onDragEnd, onDrop, onDragOver, onDragLeave } = useDrag({
afterDrop
});
const dragActive = computed(() => {
return (key: string) => {
return (
dragKeys.value.dragOverKey === key &&
dragKeys.value.dragOverParams.type === 'taskLane' &&
dragKeys.value.dragOverKey !== dragKeys.value.dragStartParams.parentId
);
};
});
provide('dragContent', { onDragStart, onDragOver, onDragEnd, dragKeys });
provide('triggerEvent', triggerEvent);
return {
stateData,
onDrop,
onDragOver,
onDragLeave,
dragKeys,
dragActive
};
}
});
</script>
<style lang="less" scoped>
.task-board {
width: 100%;
height: 100%;
overflow-x: auto;
display: flex;
padding-bottom: 50px;
}
</style>
vue3组件注册
import { App } from 'vue';
import TaskBoard from './TaskBoard.vue';
const install = (app: App) => {
app.component('TaskBoard', TaskBoard);
return app;
};
export default {
install
};
taskList
<template>
<div :class="{ 'task-list': true, 'drag-active': dragLaneActive }">
<div class="task-header">
<slot name="header">
<span class="task-title" :style="{ backgroundColor: getColor }">{{ taskTitle }}</span>
<div class="task-operate"></div>
</slot>
</div>
<div class="task-content">
<task-item
v-for="item in tasks"
:key="item.id"
:task-name="item.name"
:task-tags="item.tags"
:draggable="true"
:drag-task-active="dragTaskActive(item.id)"
@click="triggerEvent('click', item)"
@contextmenu="triggerEvent('contextmenu', item)"
@dblclick="triggerEvent('dblclick', item)"
@dragstart="onDragStart($event, item.id, { parentId })"
@dragover="onDragOver($event, item.id, { type: 'task' })"
>
<template #task>
<slot name="taskNode" :task="item"></slot>
</template>
</task-item>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, inject, PropType, Ref } from 'vue';
import TaskItem from './TaskItem.vue';
import { TaskLists } from './../type';
import { DragKeyList } from './../hooks/useDrag';
interface DragContent {
dragKeys: Readonly<Ref<DragKeyList>>;
onDragStart: (e: DragEvent, key: String, appendParam?: any) => void;
onDragOver: (e: DragEvent, key: String, appendParam?: any) => void;
}
type triggerEvent = (eventName: 'change' | 'update:data' | 'click' | 'dblclick' | 'contextmenu', data: any) => void;
export default defineComponent({
name: 'TaskList',
components: { TaskItem },
props: {
parentId: {
type: String,
required: true
},
taskTitle: {
type: String,
default: ''
},
tasks: {
type: Array as PropType<TaskLists>,
required: true
},
dragLaneActive: {
type: Boolean,
default: false
}
},
setup() {
const getColor = computed(() => {
let colorList = ['#FF9999', '#FF9900', '#99CC33', '#FF6666', '#FF6600', '#FF6666', '#3bd27d'];
return colorList[Math.floor(Math.random() * (5 + 1))];
});
const { onDragStart, onDragOver, dragKeys } = inject('dragContent') as DragContent;
const triggerEvent = inject('triggerEvent') as triggerEvent;
const dragTaskActive = computed(() => {
return (key: string) => {
return (
dragKeys.value.dragOverKey === key &&
dragKeys.value.dragOverParams.type === 'task' &&
dragKeys.value.dragOverKey !== dragKeys.value.dragStartKey
);
};
});
return {
getColor,
onDragStart,
onDragOver,
dragTaskActive,
triggerEvent
};
}
});
</script>
<style lang="less" scoped>
.task-list {
position: relative;
width: 300px;
min-height: 150px;
flex-shrink: 0;
height: 100%;
box-shadow: 0 0 10px #f4f4f4;
box-sizing: border-box;
&.drag-active {
.task-header {
background-color: #ecf5ff;
}
.task-content {
background-color: #ecf5ff;
}
}
.task-header {
position: absolute;
top: 0;
padding: 0 10px;
height: 50px;
width: 100%;
line-height: 50px;
background-color: #f4f4f4;
border-radius: 8px;
.task-title {
padding: 4px 8px;
border-radius: 4px;
color: #fff;
}
}
.task-content {
height: calc(100% - 55px);
overflow-y: auto;
margin-top: 55px;
background-color: rgb(246, 246, 246);
padding: 10px;
}
& + .task-list {
margin-left: 15px;
}
}
// .list-item {
// display: inline-block;
// margin-right: 10px;
// }
// .list-enter-active,
// .list-leave-active {
// transition: all 0.5s ease;
// }
// .list-enter-from {
// opacity: 0;
// transform: translateY(30px);
// }
// .list-leave-to {
// opacity: 0;
// }
</style>
TaskItem.vue
<template>
<div :class="['task-warper', { 'drag-active': dragTaskActive }]">
<slot name="task">
<div class="task-item">
{{ taskName }}
</div>
</slot>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'TaskItem',
props: {
taskName: {
type: String,
default: ''
},
taskType: {
type: String,
default: ''
},
taskTags: {
type: Array,
default: () => []
},
dragTaskActive: {
type: Boolean,
default: false
}
},
setup() {}
});
</script>
<style lang="less" scoped>
.task-warper {
position: relative;
&.drag-active::before {
position: absolute;
top: -5px;
content: '';
height: 4px;
width: 100%;
background-color: #409eff;
}
.task-item {
width: 100%;
background-color: #fff;
margin-top: 10px;
min-height: 100px;
padding: 5px;
border-radius: 4px;
cursor: pointer;
& + .task {
margin-top: 15px;
}
&:last-child {
margin-bottom: 15px;
}
}
}
</style>
useDrag.ts hooks关键代码
import { ref, Ref, readonly } from 'vue';
export interface DragKeyList {
dragStartKey: string | undefined;
dragOverKey: string | undefined;
dragEndKey: string | undefined;
dragStartParams?: any;
dragOverParams?: any;
dragDropParams?: any;
}
export interface UseDragParams {
afterDragStart?: (e: DragEvent) => void;
afterDragEnter?: (e: DragEvent, dragKeyList: Readonly<Ref<DragKeyList>>) => void;
afterDragOver?: (e: DragEvent, dragKeyList: Readonly<Ref<DragKeyList>>) => void;
afterDragLeave?: (e: DragEvent, dragKeyList: Readonly<Ref<DragKeyList>>) => void;
afterDragEnd?: (e: DragEvent, dragKeyList: Readonly<Ref<DragKeyList>>) => void;
afterDrop?: (e: DragEvent, dragKeyList: Readonly<Ref<DragKeyList>>) => void;
}
export interface UseDragResponse {
dragKeys: Readonly<Ref<DragKeyList>>;
onDragStart: (e: DragEvent, key: string, appendParam?: any) => void;
onDragEnter: (e: DragEvent) => void;
onDragOver: (e: DragEvent, key: string, appendParam?: any) => void;
onDragLeave: (e: DragEvent) => void;
onDragEnd: (e: DragEvent, key: string) => void;
onDrop: (e: DragEvent, key: string, appendParam?: any) => void;
}
export const useDrag = ({
afterDragStart,
afterDragEnter,
afterDragOver,
afterDragLeave,
afterDragEnd,
afterDrop
}: UseDragParams): UseDragResponse => {
const dragKeys = ref<DragKeyList>({
dragStartKey: undefined,
dragOverKey: undefined,
dragEndKey: undefined,
dragStartParams: undefined, //start 附加参数
dragOverParams: undefined, // over 附加参数
dragDropParams: undefined // drop 附加参数
});
let dragElenmt: HTMLElement | undefined = undefined; // 被拖拽元素
let dragCloneElenmt: HTMLElement | undefined = undefined; // 被克隆元素
const clickElement = { x: 0, y: 0 }; // 拖拽元素点击位置
const clearDrag = () => {
if (dragElenmt) dragElenmt.style.opacity = '1';
if (dragCloneElenmt) {
document.body.removeChild(dragCloneElenmt);
dragCloneElenmt = undefined;
}
dragKeys.value.dragStartKey = undefined;
dragKeys.value.dragOverKey = undefined;
dragKeys.value.dragEndKey = undefined;
dragKeys.value.dragStartParams = undefined;
dragKeys.value.dragOverParams = undefined;
dragKeys.value.dragDropParams = undefined;
window.removeEventListener('dragover', bindDragOver);
window.removeEventListener('dragend', clearDrag);
};
const bindDragOver = (e: any) => {
if (dragCloneElenmt) {
// 修改跟随样式
dragCloneElenmt.style.position = 'fixed';
dragCloneElenmt.style.top = e.clientY - clickElement.y + 'px';
dragCloneElenmt.style.left = e.clientX - clickElement.x + 'px';
dragCloneElenmt.style.zIndex = '999';
dragCloneElenmt.style.pointerEvents = 'none';
}
};
const onDragStart = (e: DragEvent, key: string, appendParam?: any) => {
if (e.dataTransfer) clearDefaultImage(e.dataTransfer);
if (key && e.target && e.target instanceof HTMLElement) {
dragCloneElenmt = document.createElement('div');
dragCloneElenmt.style.width = window.getComputedStyle(e.target).width;
dragCloneElenmt.style.height = window.getComputedStyle(e.target).height;
const { left, top } = e.target.getBoundingClientRect();
clickElement.x = e.clientX - left;
clickElement.y = e.clientY - top;
dragCloneElenmt.appendChild(e.target.cloneNode(true) as HTMLElement);
document.body.appendChild(dragCloneElenmt);
dragKeys.value.dragStartKey = key;
e.target.style.opacity = '0.5';
dragElenmt = e.target;
window.addEventListener('dragover', bindDragOver, { capture: true });
window.addEventListener('dragend', clearDrag, { capture: true });
if (afterDragStart) afterDragStart(e);
if (appendParam) dragKeys.value.dragStartParams = appendParam;
}
};
const onDragEnter = (e: DragEvent) => {
pauseEvent(e);
if (afterDragEnter) afterDragEnter(e, readonly(dragKeys));
};
const onDragLeave = (e: DragEvent) => {
pauseEvent(e);
if (afterDragLeave) afterDragLeave(e, readonly(dragKeys));
};
const onDragOver = (e: DragEvent, key: string, appendParam?: any) => {
pauseEvent(e);
console.log('log=>useDrag=>106:key:%o', key);
dragKeys.value.dragOverKey = key;
if (afterDragOver) afterDragOver(e, readonly(dragKeys));
if (appendParam) dragKeys.value.dragOverParams = appendParam;
};
const onDragEnd = (e: DragEvent, key: string) => {
dragKeys.value.dragEndKey = key;
pauseEvent(e);
if (afterDragEnd) afterDragEnd(e, readonly(dragKeys));
clearDrag();
};
// 阻止事件冒泡
const pauseEvent = (e: any) => {
e.stopPropagation();
e.preventDefault();
};
const onDrop = (e: DragEvent, key: string, appendParam?: any) => {
dragKeys.value.dragEndKey = key;
if (appendParam) dragKeys.value.dragDropParams = appendParam;
if (afterDrop) afterDrop(e, readonly(dragKeys));
clearDrag();
};
return {
dragKeys: readonly(dragKeys),
onDragStart,
onDragEnter,
onDragOver,
onDragLeave,
onDragEnd,
onDrop
};
};
function clearDefaultImage(dataTransfer: DataTransfer) {
const img = new Image();
img.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath /%3E%3C/svg%3E";
dataTransfer.setDragImage(img, 0, 0);
}