Files
vue-ts-example/src/pages/Page/Dom-Draggable.page.vue
严浩 9f82dc6dcb
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
Dom-Draggable.page.vue
2025-04-16 19:20:22 +08:00

401 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>