Dom-Draggable.page.vue
Some checks failed
/ build-and-deploy-to-vercel (push) Successful in 3m18s
/ surge (push) Successful in 2m36s
/ playwright (push) Failing after 1m48s
/ lint-build-and-check (push) Successful in 4m43s

This commit is contained in:
严浩
2025-04-16 19:20:22 +08:00
parent b9ae95bfee
commit 9f82dc6dcb
5 changed files with 401 additions and 780 deletions

View File

@ -0,0 +1,400 @@
<script setup lang="ts">
interface ComponentItem {
id: number | string;
name: string;
}
const 组件列表 = ref<ComponentItem[]>([
{ id: 1, name: '组件1' },
{ id: 2, name: '组件2' },
{ id: '3', name: '组件3' },
{ id: 4, name: '组件4' },
{ id: 5, name: '组件5' },
]);
const 流程起点列表 = ref<ComponentItem[]>([]);
const 流程终点列表 = ref<ComponentItem[]>([]);
// 用于存储当前拖拽的组件 ID (类型与ComponentItem.id保持一致)
const draggingItemId = ref<ComponentItem['id'] | null>(null);
function getItemIndex(item: ComponentItem): number {
return 组件列表.value.findIndex((comp) => comp.id === item.id);
}
function getItemById(id: number | string): ComponentItem | undefined {
return 组件列表.value.find((comp) => comp.id === id);
}
// ===== 业务逻辑 =====
/**
* 添加元素到起点列表
*/
function addToStartList(item: ComponentItem): boolean {
const newStartIndex = getItemIndex(item);
const endIndex = 流程终点列表.value.length > 0 ? getItemIndex(流程终点列表.value[0]) : -1;
// 约束检查:如果终点已存在,则新起点必须在终点之前
if (endIndex !== -1 && newStartIndex >= endIndex) {
console.debug('⚠️ 流程起点必须在流程终点之前');
return false; // 表示添加失败
}
// 更新数据模型
if (流程起点列表.value.length > 0) {
流程起点列表.value.splice(0, 1, item); // 替换
} else {
流程起点列表.value.push(item); // 添加
}
return true; // 表示添加成功
}
/**
* 添加元素到终点列表
*/
function addToEndList(item: ComponentItem): boolean {
const newEndIndex = getItemIndex(item);
const startIndex = 流程起点列表.value.length > 0 ? getItemIndex(流程起点列表.value[0]) : -1;
// 约束检查:如果起点已存在,则新终点必须在起点之后
if (startIndex !== -1 && newEndIndex <= startIndex) {
console.debug('⚠️ 流程终点必须在流程起点之后');
return false; // 表示添加失败
}
// 更新数据模型
if (流程终点列表.value.length > 0) {
流程终点列表.value.splice(0, 1, item); // 替换
} else {
流程终点列表.value.push(item); // 添加
}
return true; // 表示添加成功
}
// --- 原生拖拽事件处理 ---
// ===== 拖拽事件处理 =====
/**
* 处理源列表项开始拖拽
*/
function handleDragStart(event: DragEvent, item: ComponentItem) {
if (event.dataTransfer) {
event.dataTransfer.setData('text/plain', String(item.id));
event.dataTransfer.effectAllowed = 'copyMove'; // 允许复制或移动
draggingItemId.value = item.id as ComponentItem['id']; // 记录拖拽的 ID
console.debug(`👆 开始拖拽: ${item.name} (ID: ${item.id})`);
}
}
/**
* 处理已放置项开始拖拽
*/
function handlePlacedItemDragStart(event: DragEvent, item: ComponentItem, listType: 'end' | 'start') {
// ESLint: 'end' 优先
if (event.dataTransfer) {
event.dataTransfer.setData('text/plain', String(item.id));
event.dataTransfer.setData('sourceList', listType); // 标记来源列表
event.dataTransfer.effectAllowed = 'move'; // 只允许移动
draggingItemId.value = item.id as ComponentItem['id'];
console.debug(`👆 从 ${listType === 'start' ? '起点' : '终点'} 列表开始拖拽: ${item.name} (ID: ${item.id})`);
}
}
/**
* 处理拖拽结束事件
*/
function handleDragEnd() {
const item = draggingItemId.value ? getItemById(draggingItemId.value) : null;
if (item) {
console.debug(`👇 拖拽结束: ${item.name}`);
} else {
console.debug('👇 拖拽结束: 未知组件');
}
draggingItemId.value = null; // 清除拖拽 ID
}
/**
* 处理拖拽进入目标区域
*/
function handleDragEnter(event: DragEvent) {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
target.classList.add('drag-over'); // 添加视觉反馈
}
/**
* 处理拖拽在目标区域上方移动
*/
function handleDragOver(event: DragEvent) {
event.preventDefault(); // 必须阻止默认行为才能触发 drop
if (event.dataTransfer) {
// 根据 effectAllowed 判断是复制还是移动
event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copyMove' ? 'copy' : 'move';
}
}
/**
* 处理拖拽离开目标区域
*/
function handleDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement;
target.classList.remove('drag-over'); // 移除视觉反馈
}
/**
* 处理在起点列表区域放置
*/
function handleDropStart(event: DragEvent) {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
target.classList.remove('drag-over'); // 移除视觉反馈
if (!draggingItemId.value || !event.dataTransfer) {
console.debug('⚠️ 放置失败:无法获取拖拽数据');
return;
}
const itemId = draggingItemId.value;
const sourceList = event.dataTransfer.getData('sourceList'); // 获取来源列表标记
const newItem = getItemById(itemId);
if (!newItem) {
console.debug(`❌ 放置失败:找不到 ID 为 ${itemId} 的组件`);
return;
}
console.debug(`✋ 尝试放置到起点列表: ${newItem.name} (ID: ${itemId}), 来源: ${sourceList || '源列表'}`);
// 尝试添加到起点列表
const added = addToStartList(newItem);
if (added) {
console.debug(`✅ 成功放置到起点: ${newItem.name}`);
// 如果是从终点列表拖过来的,需要清空终点列表
if (sourceList === 'end') {
流程终点列表.value = [];
console.debug('🔄 已清空终点列表,因为项已移动到起点');
}
} else {
console.debug(`⚠️ 放置到起点失败 (约束检查未通过): ${newItem.name}`);
}
}
/**
* 处理在终点列表区域放置
*/
function handleDropEnd(event: DragEvent) {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
target.classList.remove('drag-over'); // 移除视觉反馈
if (!draggingItemId.value || !event.dataTransfer) {
console.debug('⚠️ 放置失败:无法获取拖拽数据');
return;
}
const itemId = draggingItemId.value;
const sourceList = event.dataTransfer.getData('sourceList'); // 获取来源列表标记
const newItem = getItemById(itemId);
if (!newItem) {
console.debug(`❌ 放置失败:找不到 ID 为 ${itemId} 的组件`);
return;
}
console.debug(`✋ 尝试放置到终点列表: ${newItem.name} (ID: ${itemId}), 来源: ${sourceList || '源列表'}`);
// 尝试添加到终点列表
const added = addToEndList(newItem);
if (added) {
console.debug(`✅ 成功放置到终点: ${newItem.name}`);
// 如果是从起点列表拖过来的,需要清空起点列表
if (sourceList === 'start') {
流程起点列表.value = [];
console.debug('🔄 已清空起点列表,因为项已移动到终点');
}
} else {
console.debug(`⚠️ 放置到终点失败 (约束检查未通过): ${newItem.name}`);
}
}
// ===== 计算属性 =====
/**
* 计算流程节点(包含起点和终点)
*/
const 完整流程节点 = computed(() => {
const nodes: ComponentItem[] = [];
// 添加起点
if (流程起点列表.value.length > 0) {
nodes.push(流程起点列表.value[0]);
}
// 添加中间节点
if (流程起点列表.value.length > 0 && 流程终点列表.value.length > 0) {
const startIndex = getItemIndex(流程起点列表.value[0]);
const endIndex = getItemIndex(流程终点列表.value[0]);
if (startIndex !== -1 && endIndex !== -1 && startIndex < endIndex - 1) {
nodes.push(...组件列表.value.slice(startIndex + 1, endIndex));
}
}
// 添加终点
if (流程终点列表.value.length > 0) {
nodes.push(流程终点列表.value[0]);
}
return nodes;
});
watchEffect(() => {
console.debug(`完整流程节点 :>> `, JSON.stringify(完整流程节点.value, null, 2));
});
</script>
<template>
<div class="flex gap-5 p-5 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans min-h-100">
<!-- 组件选择区域 -->
<div class="flex-1 p-4 rounded border border-gray-200">
<h3 class="text-center mb-5 text-gray-700 font-bold">组件选择</h3>
<div class="min-h-150px rounded p-2.5 bg-gray-50 dark:bg-gray-800">
<!-- 源列表项添加 draggable dragstart 事件 -->
<div
v-for="item in 组件列表"
:key="item.id"
class="py-2.5 px-4 mb-2 bg-white dark:bg-gray-700 border border-blue-500 dark:border-blue-400 text-gray-800 dark:text-gray-200 rounded cursor-grab text-center drag-item component-item hover:bg-blue-50 dark:hover:bg-gray-600"
:data-id="item.id"
draggable="true"
@dragstart="handleDragStart($event, item)"
@dragend="handleDragEnd"
>
{{ item.name }}
</div>
</div>
</div>
<!-- 构建流程区域 -->
<div class="flex-1 p-4 rounded border border-gray-200">
<h3 class="text-center mb-5 text-gray-700 font-bold">构建流程</h3>
<div class="flex items-start mb-2.5">
<div class="w-20 text-right mr-2.5 pt-2.5 text-[#ccc] dark:text-gray-400">流程起点</div>
<!-- 起点列表区域添加拖放事件监听器 -->
<div
class="flex-1 min-h-150px rounded p-2.5 border-2 border-dashed border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 mt-1.25 mb-5 target-list start-list"
@dragenter="handleDragEnter"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDropStart"
>
<!-- 占位符 -->
<div
v-if="流程起点列表.length === 0"
class="flex justify-center items-center h-full min-h-130px text-[#888] dark:text-gray-500 text-center placeholder pointer-events-none"
>
拖入组件作为流程起点
</div>
<!-- 已放置的起点项添加 draggable dragstart/dragend 事件 -->
<div
v-for="item in 流程起点列表"
:key="item.id"
class="py-2.5 px-4 mb-2 bg-white dark:bg-gray-700 border border-blue-500 dark:border-blue-400 text-gray-800 dark:text-gray-200 rounded cursor-grab text-center drag-item dropped-item hover:bg-blue-50 dark:hover:bg-gray-600"
:data-id="item.id"
draggable="true"
@dragstart="handlePlacedItemDragStart($event, item, 'start')"
@dragend="handleDragEnd"
>
{{ item.name }}
</div>
</div>
</div>
<div class="flex items-start mb-2.5">
<div class="w-20 text-right mr-2.5 pt-2.5 text-[#ccc] dark:text-gray-400">流程终点</div>
<!-- 终点列表区域添加拖放事件监听器 -->
<div
class="flex-1 min-h-150px rounded p-2.5 border-2 border-dashed border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 mt-1.25 target-list end-list"
@dragenter="handleDragEnter"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDropEnd"
>
<!-- 占位符 -->
<div
v-if="流程终点列表.length === 0"
class="flex justify-center items-center h-full min-h-130px text-[#888] dark:text-gray-500 text-center placeholder pointer-events-none"
>
拖入组件作为流程终点
</div>
<!-- 已放置的终点项添加 draggable dragstart/dragend 事件 -->
<div
v-for="item in 流程终点列表"
:key="item.id"
class="py-2.5 px-4 mb-2 bg-white dark:bg-gray-700 border border-blue-500 dark:border-blue-400 text-gray-800 dark:text-gray-200 rounded cursor-grab text-center drag-item dropped-item hover:bg-blue-50 dark:hover:bg-gray-600"
:data-id="item.id"
draggable="true"
@dragstart="handlePlacedItemDragStart($event, item, 'end')"
@dragend="handleDragEnd"
>
{{ item.name }}
</div>
</div>
</div>
</div>
<!-- 预览区域 -->
<div class="flex-1 p-4 rounded border border-gray-200">
<h3 class="text-center mb-5 text-gray-700 font-bold">预览</h3>
<div
class="mt-2.5 p-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded min-h-100px flex flex-col items-stretch justify-start gap-2"
>
<template v-if="完整流程节点.length > 0">
<div
v-for="item in 完整流程节点"
:key="item.id"
class="py-2.5 px-4 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded cursor-default flex-shrink-0 text-center drag-item component-item preview-item border border-gray-300 dark:border-gray-600"
>
{{ item.name }}
</div>
</template>
<div
v-else
class="flex justify-center items-center h-20 text-[#888] dark:text-gray-500 text-center w-full preview-placeholder placeholder"
>
拖入起点和终点组件生成预览
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 拖拽悬停时的目标区域样式 */
.target-list.drag-over {
border-color: #3b82f6;
background-color: #f0f9ff; /* 拖拽悬停反馈 */
}
.app-dark .target-list.drag-over {
background-color: #1e3a8a;
}
/* 拖拽过程中的源项样式 (可选) */
.component-item[draggable='true']:active {
opacity: 0.7;
cursor: grabbing;
box-shadow: 0 0 0 2px #3b82f6;
}
.app-dark .component-item[draggable='true']:active {
box-shadow: 0 0 0 2px #93c5fd;
}
/* 放置后的项样式 */
.dropped-item {
cursor: grab; /* 允许再次拖动 */
}
/* 占位符样式 */
.placeholder {
user-select: none; /* 防止文本被选中 */
pointer-events: none; /* 确保不干扰拖放事件 */
}
</style>