vue3+ts实现drag拖拽任务看板(taskboard)

3,600 阅读2分钟

效果

拖拽实现任务看板_.gif

目标

实现任务看板拖拽

过程

获取所有任务,遍历任务状态,进行归类显示,绑定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);
}

vue2拖拽实现

# drag拖拽vue组件实现列表列配置

源码获取

下载地址

另赠送俩份更高级功能源码

甘特图_.gif

JSON编辑器_.gif