vuedraggable 实现拖拽组件, 图表配置化

210 阅读4分钟

image.png

1. 安装依赖

npm i -S vuedraggable

2. 模块库

2.1 模块库容器

image.png /src/views/DesignView/LibPanel.vue

<!-- 分离样式与业务逻辑。让外部专注于 drag 业务 -->
<template>
  <div class="bg-BgElevated p-3 grid grid-cols-1 gap-4" style="grid-template-rows: auto 32px 1fr">
    <a-typography-title :level="4" class="!text-White ml-3 mt-3" content="自定义" />

    <BasicTab v-model="currentTab" :options="$props.tabOptions" type="block" />

    <!--  模块库  -->
    <div class="size-full relative">
      <!--  通过slot传入 drag 业务界面  -->
      <slot />

      <!--    状态反馈层    -->
      <p
        :class="[$props.removeTip ? 'opacity-100' : 'opacity-0', $style.stateFeedbackLayer]"
        class="pointer-events-none border-Error"
      >
        <a-typography-text class="!text-[24px]" content="拖拽到此处删除模块" type="danger" />
      </p>
    </div>

    <div class="flex items-center justify-end py-4">
      <a-button size="large" @click="$emit('cancel')">取消</a-button>
      <a-button class="ml-6" size="large" type="primary" @click="$emit('confirm')">确定</a-button>
    </div>
  </div>
</template>

<script lang="ts" setup>
import BasicTab from '@com/BasicTab.vue';

defineProps<{
  removeTip?: boolean;
  tabOptions: string[];
}>();
const currentTab = defineModel<string>();
defineEmits(['cancel', 'confirm']);
</script>

<style module scoped>
/* 状态反馈层 */
.stateFeedbackLayer {
  @apply fullAbsolute border-[1px] border-dashed bg-Blue9 z-50 flex justify-center items-center transition-opacity;
}
</style>

2.2. 模块

image.png /src/views/DesignView/DesignViewItem.vue

<template>
  <div
    :class="[
      $props.isLib ? ($props.disabled ? 'cursor-not-allowed' : 'cursor-move') : 'bg-BgContainer',
    ]"
    class="size-full relative"
    @mouseenter="state.hover = true"
    @mouseleave="state.hover = false"
  >
    <BasicBox :label="$props.label">
      <template v-if="!$props.isLib" #rightTool>
        <HolderOutlined
          class="!text-[24px] cursor-move"
          @mouseenter="state.holderHover = true"
          @mouseleave="state.holderHover = false"
        />
        <DeleteOutlined
          class="!text-[24px] ml-3 text-Error cursor-pointer"
          @click="$emit('remove')"
          @mouseenter="state.removeHover = true"
          @mouseleave="state.removeHover = false"
        />
      </template>
      <div class="px-3 py-8">
        <a-skeleton :paragraph="{ rows: 5 }" active avatar />
      </div>
    </BasicBox>

    <!--  叠加层  -->
    <div
      :class="[
        getAlready || getHover || state.holderHover || state.removeHover
          ? 'opacity-100'
          : 'opacity-0',
        getHover && 'border-[1px] border-dashed border-PrimaryText',
      ]"
      class="pointer-events-none fullAbsolute flex flex-col justify-center items-center bg-Blue9 transition-opacity"
    >
      <!--  已用模块  -->
      <p v-if="getAlready" class="text-PrimaryText">当前页面已存在该模块</p>
      <!--  高亮效果  -->
      <template v-else-if="getHover">
        <div class="size-[120px] flex flex-col justify-center items-center bg-Blue10 mb-4">
          <PlusOutlined class="!text-[25px]" />
          <span class="mt-4">添加模块</span>
        </div>
        <span>拖拽添加至空闲区域</span>
      </template>
      <!--   在拖动锚点上   -->
      <p v-else-if="state.holderHover">拖拽进行移动</p>
      <a-typography-text
        v-else-if="state.removeHover"
        class="!text-[16px]"
        content="点击删除模块"
        type="danger"
      />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { DeleteOutlined, HolderOutlined, PlusOutlined } from '@ant-design/icons-vue';
import BasicBox from '@com/BasicBox.vue';
import { computed, reactive } from 'vue';

defineOptions({ name: 'DesignViewItem' });
const $props = defineProps<{
  label: string;
  isLib?: boolean;
  disabled?: boolean;
}>();
const $emit = defineEmits<{
  remove: [];
}>();

const state = reactive({
  hover: false,
  holderHover: false,
  removeHover: false,
});

const getHover = computed(() => [$props.isLib, !$props.disabled, state.hover].every((i) => i));
const getAlready = computed(() => $props.isLib && $props.disabled);
</script>

2.2.1 模块容器

/src/components/BasicBox.vue

<template>
  <div class="size-full flex flex-col border-Blue3 border-[1px] backdrop-blur-3xl">
    <div :class="$style.title" class="h-[40px]">
      <a-typography-title :level="5">
        <span :class="$style.titleText" class="bg-clip-text">{{ $props.label }}</span>
      </a-typography-title>

      <div v-if="$slots.rightTool?.()" class="ml-10 flex-auto flex justify-end">
        <slot name="rightTool" />
      </div>
    </div>

    <div class="overflow-hidden flex-auto bg-Blue1">
      <slot class="size-full" />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { pngBasicTitleBg } from '@img';
import { computed } from 'vue';

defineOptions({ name: 'BasicBox' });

const $props = defineProps<{
  label?: string;
}>();

const getBg = computed(() => `url(${pngBasicTitleBg})`);
</script>

<style module scoped>
.title {
  @apply bg-Blue2 p-2 overflow-hidden flex items-center relative bg-no-repeat;
  background-image: v-bind(getBg);
  background-size: 100% 100%;
}

.titleText {
  background-image: theme('colors.LinearGradient1');
  -webkit-text-fill-color: transparent;
}
</style>

3. 两边面板

3.1 面板容器

image.png /src/views/DesignView/ModulePanel.vue

<!-- 分离样式与业务逻辑。让外部专注于 drag 业务 -->
<template>
  <div :class="$style.content" class="relative">
    <!--  通过slot传入 drag 业务界面  -->
    <slot />

    <!--   背景层   -->
    <div class="pointer-events-none z-0">
      <EmptyArea v-for="n in 3" :key="n" />
    </div>

    <!--    状态反馈层    -->
    <p
      :class="[
        $props.isFull ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none',
        $style.stateFeedbackLayer,
      ]"
      class="border-Warning"
    >
      <a-typography-text class="!text-[24px]" content="模块已满" type="warning" />
    </p>
  </div>
</template>

<script lang="ts" setup>
import EmptyArea from '@/views/DesignView/EmptyArea.vue';

const $props = defineProps<{
  isFull?: boolean;
}>();
</script>

<style module scoped>
.content {
  & > div,
  & > :slotted(div) {
    @apply grid grid-cols-1 grid-rows-3 gap-3 fullAbsolute;
  }

  & > :slotted(div) {
    @apply z-10;
  }
}

/* 状态反馈层 */
.stateFeedbackLayer {
  @apply fullAbsolute border-[1px] border-dashed bg-Blue9 z-50 flex justify-center items-center transition-opacity;
}
</style>

/src/views/DesignView/EmptyArea.vue

<template>
  <p class="size-full border-[1px] border-dashed border-Purple1 flex justify-center items-center">
    拖拽到该区域
  </p>
</template>

4. 拖拽页面

/src/views/DesignView/index.vue

<template>
  <div
    class="grid gap-3 p-3 grid-flow-col size-full overflow-hidden bg-BgContainer grid-rows-1 pointer-events-auto"
    style="grid-template-columns: 1fr 2fr 1fr"
  >
    <!--  模块库  -->
    <LibPanel
      v-model="state.currentTab"
      :remove-tip="state.removeTip"
      :tab-options="state.tabOptions"
      @cancel="$router.push('/')"
      @confirm="$router.push('/')"
    >
      <Draggable
        v-model="state.componentList"
        :group="{
          name: dragGroupName,
          // pull: isAllFull ? false : 'clone',
          pull: 'clone',
        }"
        :sort="false"
        class="fullAbsolute grid grid-cols-2 overflow-x-hidden overflow-y-scroll gap-3"
        draggable=".cursor-move"
        style="grid-auto-rows: 284px"
        v-bind="dragOptions"
        @add="onLibAdd"
        @end="onLibEnd"
      >
        <template #item="{ element }">
          <DesignViewItem
            :disabled="element.disabled"
            :label="element.key"
            isLib
            @dragstart="state.addFullTip = true"
          />
        </template>
      </Draggable>
    </LibPanel>

    <!--  两边面板  -->
    <ModulePanel
      v-for="prop in Object.values(DragPropEnum)"
      :key="prop"
      :class="[prop === DragPropEnum.contentLeftList && 'order-first']"
      :is-full="(state.addFullTip === true || state.addFullTip === prop) && isFull[prop]"
    >
      <Draggable
        v-model="state[prop]"
        :group="{ name: dragGroupName }"
        handle=".cursor-move"
        v-bind="dragOptions"
      >
        <template #item="{ element }">
          <DesignViewItem
            :draggable="true"
            :label="element.key"
            @dragend="onModuleDragend"
            @dragstart="(event) => onModuleDragstart(event, prop, element.key)"
            @remove="onModuleRemove(prop, element.key)"
          />
        </template>
      </Draggable>
    </ModulePanel>
  </div>
</template>

<script lang="tsx" setup>
import { ModuleKeysEnum, modulesMap } from '#enums/moduleEnum';
import useModulesStore from '@/stores/modules/modules-store.ts';
import DesignViewItem from '@/views/DesignView/DesignViewItem.vue';
import LibPanel from '@/views/DesignView/LibPanel.vue';
import ModulePanel from '@/views/DesignView/ModulePanel.vue';
import { computed, onBeforeMount, reactive, toValue } from 'vue';
import Draggable from 'vuedraggable';

defineOptions({ name: 'DesignView' });

type ComponentListItem = { node: ModuleKeysEnum; key: ModuleKeysEnum; disabled: boolean };

// 组件列表
const componentList: ComponentListItem[] = [];
{
  modulesMap.forEach((node, key) => {
    componentList.push({ node: key, key, disabled: false });
  });
}

// 公共 draggable 配置
const dragOptions = {
  animation: 200,
  disabled: false,
  ghostClass: 'ghost',
  'item-key': 'key',
  'component-data': { tag: 'div', type: 'transition-group' },
};
const dragGroupName = '__dragGroupName';
enum DragPropEnum {
  contentLeftList = 'contentLeftList',
  contentRightList = 'contentRightList',
}

const modulesStore = useModulesStore();
const state = reactive({
  componentList: [...componentList],
  [DragPropEnum.contentLeftList]: [] as ComponentListItem[],
  [DragPropEnum.contentRightList]: [] as ComponentListItem[],
  removeTip: false,
  addFullTip: false as boolean | DragPropEnum,
  tabOptions: ['分类一', '分类二', '分类三'],
  currentTab: '',
});

const isFull = computed(() => {
  return Object.values(DragPropEnum).reduce((obj, prop) => {
    return {
      [prop]: state[prop].length >= 3,
      ...obj,
    };
  }, {}) as { [p in DragPropEnum]: boolean };
});
const isAllFull = computed(() => Object.values(toValue(isFull)).every((i) => i));

/**
 * 拖动到模块库时触发
 */
function onLibAdd(event) {
  const { newIndex, originalEvent } = event;
  // 不允许添加进来
  state.componentList.splice(newIndex, 1);
  // 激活模块
  const key = originalEvent.dataTransfer.getData('key');
  regressionLib(key);
}

/**
 * 从模块库克隆时触发
 */
function onLibEnd(event) {
  state.addFullTip = false;
  const { pullMode, oldIndex } = event;
  if (pullMode !== 'clone') return;
  state.componentList[oldIndex].disabled = true;
}

/**
 * 根据 key 激活库中的模块
 */
function regressionLib(key: string) {
  const index = state.componentList.findIndex((i) => String(i.key) === key);
  if (~index) state.componentList[index].disabled = false;
}

/**
 * 删除模块
 */
function onModuleRemove(prop: DragPropEnum, key: string) {
  const index = state[prop].findIndex((i) => String(i.key) === key);
  if (~index) state[prop].splice(index, 1);
  regressionLib(key); // 重新激活
}
function onModuleDragstart(event, prop: DragPropEnum, key: string) {
  event.dataTransfer.setData('key', key); // 用于激活
  state.removeTip = true;
  if (prop === DragPropEnum.contentLeftList) state.addFullTip = DragPropEnum.contentRightList;
  if (prop === DragPropEnum.contentRightList) state.addFullTip = DragPropEnum.contentLeftList;
}
function onModuleDragend(event) {
  event.dataTransfer.clearData();
  state.removeTip = state.addFullTip = false;
}

onBeforeMount(() => {
  const modules = modulesStore.moduleKeys.map((key) => {
    const index = state.componentList.findIndex((i) => i.key === key);
    state.componentList[index].disabled = true;
    return {
      key,
      node: key,
      disabled: false,
    };
  });

  const left = modules.slice(0, 3);
  const right = modules.slice(3, 6);
  state[DragPropEnum.contentLeftList] = [...left];
  state[DragPropEnum.contentRightList] = [...right];
});
</script>

4.1 模块配置

/src/enums/moduleEnum.ts

import DisasterMonitor from '@/views/DisasterMonitor/index.vue';
import AffectDevice from '@/views/FireView/AffectDevice.vue';
import EnableResources from '@/views/FireView/EnableResources.vue';
import FireWarn from '@/views/FireView/FireWarn.vue';
import OfflineMonitor from '@/views/OfflineMonitor/index.vue';
import OpinionInfo from '@/views/OpinionInfo/index.vue';
import RiskAssess from '@/views/RiskAssess/index.vue';
import type { VNode } from 'vue';

export enum ModuleKeysEnum {
  /**
   * 单项专属模块
   */
  FireWarn = '11预警',
  WaterWarn = '22预警',
  TyphoonWarn = '33预警',
  IceWarn = '44预警',

  /**
   * 通用模块
   */
  OfflineMonitor = '55监测',
  OpinionInfo = '66诉求',
  RiskAssess = '77研判',
  AffectDevice = '88设备',
  EnableResources = '99资源',

  /**
   * xxx专属模块
   */
  DisasterMonitor = 'xx监测',
}

export const modulesMap = new Map<ModuleKeysEnum, VNode>();
{
  /**
   * 通用模块
   */
  modulesMap.set(ModuleKeysEnum.RiskAssess, RiskAssess);
  modulesMap.set(ModuleKeysEnum.OfflineMonitor, OfflineMonitor);
  modulesMap.set(ModuleKeysEnum.OpinionInfo, OpinionInfo);
  modulesMap.set(ModuleKeysEnum.EnableResources, EnableResources);
  modulesMap.set(ModuleKeysEnum.AffectDevice, AffectDevice);

  /**
   * xxx专属模块
   */
  modulesMap.set(ModuleKeysEnum.DisasterMonitor, DisasterMonitor);

  /**
   * 单项专属模块
   */
  modulesMap.set(ModuleKeysEnum.FireWarn, FireWarn);
}

4.2 全局状态数据

/src/stores/modules/modules-store.ts

import { ModuleKeysEnum } from '#enums/moduleEnum';
import type { AppStoreState } from '#types/stores';
import { defineStore } from 'pinia';
import { reactive, toRefs } from 'vue';

const useModulesStore = defineStore(
  'modules',
  () => {
    const state: AppStoreState = reactive({
      /**
       * 页面模块相关
       */
      moduleKeys: [
        ModuleKeysEnum.DisasterMonitor,
        ModuleKeysEnum.OfflineMonitor,
        ModuleKeysEnum.OpinionInfo,
        ModuleKeysEnum.RiskAssess,
        ModuleKeysEnum.AffectDevice,
        ModuleKeysEnum.EnableResources,
      ]
    } as unknown as AppStoreState);
    return { ...toRefs(state) };
  },
  // { persist: true },
);

export default useModulesStore;

5. 页面渲染模块

/src/view/HomeView/index.vue

<script lang="ts" setup>
defineOptions({ name: 'HomeView' });

import { modulesMap } from '#enums/moduleEnum';
import LayoutDefault from '@/layout/default.vue';
import useModulesStore from '@/stores/modules/modules-store.ts';
import FloatLayer from '@/views/HomeView/FloatLayer.vue';
import TopHeader from '@bizCom/TopHeader/index.vue';
import { computed, type VNode } from 'vue';

const modulesStore = useModulesStore();
const getModules = computed<VNode[]>(
  () => modulesStore.moduleKeys?.slice(0, 6).map((key) => modulesMap.get(key)) as VNode[],
);
</script>

<template>
  <LayoutDefault>
    <!--  浮动控件  -->
    <FloatLayer />

    <!--  顶栏  -->
    <template #float>
      <TopHeader />
    </template>

    <!--  侧边模块  -->
    <template v-for="num in 6" :key="num" #[num]>
      <component :is="getModules[num - 1] || 'div'" />
    </template>
  </LayoutDefault>
</template>