1. 安装依赖
npm i -S vuedraggable
2. 模块库
2.1 模块库容器
/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. 模块
/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 面板容器
/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>