430 lines
16 KiB
Vue
430 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import Sortable from 'sortablejs';
|
||
|
||
interface ComponentItem {
|
||
id: number;
|
||
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[]>([]);
|
||
|
||
const 源列表引用 = useTemplateRef<HTMLDivElement | null>('源列表引用');
|
||
const 起点列表引用 = useTemplateRef<HTMLDivElement | null>('起点列表引用');
|
||
const 终点列表引用 = useTemplateRef<HTMLDivElement | null>('终点列表引用');
|
||
|
||
function getItemIndex(item: ComponentItem): number {
|
||
return 组件列表.value.findIndex((comp) => comp.id === item.id);
|
||
}
|
||
|
||
function getItemById(id: number): ComponentItem | undefined {
|
||
return 组件列表.value.find((comp) => comp.id === id);
|
||
}
|
||
|
||
// 添加元素到起点列表,维护相应的 DOM 和数据模型
|
||
function addToStartList(item: ComponentItem, domItem: HTMLElement) {
|
||
const newStartIndex = getItemIndex(item);
|
||
const endIndex = 流程终点列表.value.length > 0 ? getItemIndex(流程终点列表.value[0]) : -1;
|
||
|
||
// 约束检查:如果终点已存在,则新起点必须在终点之前
|
||
if (endIndex !== -1 && newStartIndex >= endIndex) {
|
||
consola.warn('流程起点必须在流程终点之前');
|
||
domItem.remove(); // 移除不符合约束的 DOM 项
|
||
return false; // 表示添加失败
|
||
}
|
||
|
||
// 更新数据模型
|
||
if (流程起点列表.value.length > 0) {
|
||
流程起点列表.value.splice(0, 1, item); // 替换
|
||
} else {
|
||
流程起点列表.value.push(item); // 添加
|
||
}
|
||
|
||
return true; // 表示添加成功
|
||
}
|
||
|
||
// 添加元素到终点列表,维护相应的 DOM 和数据模型
|
||
function addToEndList(item: ComponentItem, domItem: HTMLElement) {
|
||
const newEndIndex = getItemIndex(item);
|
||
const startIndex = 流程起点列表.value.length > 0 ? getItemIndex(流程起点列表.value[0]) : -1;
|
||
|
||
// 约束检查:如果起点已存在,则新终点必须在起点之后
|
||
if (startIndex !== -1 && newEndIndex <= startIndex) {
|
||
consola.warn('流程终点必须在流程起点之后');
|
||
domItem.remove();
|
||
return false; // 表示添加失败
|
||
}
|
||
|
||
// 更新数据模型
|
||
if (流程终点列表.value.length > 0) {
|
||
流程终点列表.value.splice(0, 1, item); // 替换
|
||
} else {
|
||
流程终点列表.value.push(item); // 添加
|
||
}
|
||
|
||
return true; // 表示添加成功
|
||
}
|
||
|
||
// 清理起点列表的 DOM,确保与数据模型一致
|
||
function cleanupStartListDOM(keepItemId?: number) {
|
||
if (!起点列表引用.value) return;
|
||
|
||
consola.info(`清理起点列表 DOM,保留的项 ID:${keepItemId ?? '无'}`);
|
||
|
||
const children = [...起点列表引用.value.children];
|
||
let found = false;
|
||
|
||
for (const child of children) {
|
||
if (child instanceof HTMLElement && child.classList.contains('drag-item') && child.dataset.id) {
|
||
const childId = Number.parseInt(child.dataset.id, 10);
|
||
|
||
// 如果指定了要保留的项
|
||
if (keepItemId !== undefined && childId === keepItemId) {
|
||
if (found) {
|
||
consola.info(`移除重复的起点项,ID: ${childId}`);
|
||
child.remove(); // 如果已经找到过一个相同ID的项,则这个是重复的,移除
|
||
} else {
|
||
consola.info(`保留起点项,ID: ${childId}`);
|
||
found = true; // 标记找到,保留这个
|
||
}
|
||
}
|
||
// 对于其他项
|
||
else if (keepItemId === undefined || childId !== keepItemId) {
|
||
if (流程起点列表.value.length === 1 && 流程起点列表.value[0].id === childId) {
|
||
// 如果这个项与数据模型匹配,保留
|
||
if (found) {
|
||
consola.info(`移除重复的起点项,ID: ${childId}`);
|
||
child.remove(); // 但如果已找到一个相同ID的,则这个是重复的
|
||
} else {
|
||
consola.info(`保留与数据模型匹配的起点项,ID: ${childId}`);
|
||
found = true;
|
||
}
|
||
} else {
|
||
// 不在数据模型中的项,移除
|
||
consola.info(`移除不在数据模型中的起点项,ID: ${childId}`);
|
||
child.remove();
|
||
}
|
||
}
|
||
} else if (child.classList.contains('placeholder') && 流程起点列表.value.length > 0) {
|
||
// 数据模型有项时,移除占位符
|
||
consola.info('移除起点列表中的占位符,因为数据模型中已有项');
|
||
child.remove();
|
||
}
|
||
}
|
||
|
||
// 如果数据模型为空,且DOM中没有占位符,添加占位符
|
||
if (流程起点列表.value.length === 0 && !起点列表引用.value.querySelector('.placeholder')) {
|
||
consola.info('添加起点列表占位符,因为数据模型为空');
|
||
const placeholder = document.createElement('div');
|
||
placeholder.className = 'placeholder';
|
||
placeholder.textContent = '请拖入组件构成流程起点';
|
||
起点列表引用.value.append(placeholder);
|
||
}
|
||
}
|
||
|
||
// 清理终点列表的 DOM,确保与数据模型一致
|
||
function cleanupEndListDOM(keepItemId?: number) {
|
||
if (!终点列表引用.value) return;
|
||
|
||
consola.info(`清理终点列表 DOM,保留的项 ID:${keepItemId ?? '无'}`);
|
||
|
||
const children = [...终点列表引用.value.children];
|
||
let found = false;
|
||
|
||
for (const child of children) {
|
||
if (child instanceof HTMLElement && child.classList.contains('drag-item') && child.dataset.id) {
|
||
const childId = Number.parseInt(child.dataset.id, 10);
|
||
|
||
// 如果指定了要保留的项
|
||
if (keepItemId !== undefined && childId === keepItemId) {
|
||
if (found) {
|
||
consola.info(`移除重复的终点项,ID: ${childId}`);
|
||
child.remove(); // 如果已经找到过一个相同ID的项,则这个是重复的,移除
|
||
} else {
|
||
consola.info(`保留终点项,ID: ${childId}`);
|
||
found = true; // 标记找到,保留这个
|
||
}
|
||
}
|
||
// 对于其他项
|
||
else if (keepItemId === undefined || childId !== keepItemId) {
|
||
if (流程终点列表.value.length === 1 && 流程终点列表.value[0].id === childId) {
|
||
// 如果这个项与数据模型匹配,保留
|
||
if (found) {
|
||
consola.info(`移除重复的终点项,ID: ${childId}`);
|
||
child.remove(); // 但如果已找到一个相同ID的,则这个是重复的
|
||
} else {
|
||
consola.info(`保留与数据模型匹配的终点项,ID: ${childId}`);
|
||
found = true;
|
||
}
|
||
} else {
|
||
consola.info(`移除不在数据模型中的终点项,ID: ${childId}`);
|
||
// 不在数据模型中的项,移除
|
||
child.remove();
|
||
}
|
||
}
|
||
} else if (child.classList.contains('placeholder') && 流程终点列表.value.length > 0) {
|
||
// 数据模型有项时,移除占位符
|
||
consola.info('移除终点列表中的占位符,因为数据模型中已有项');
|
||
child.remove();
|
||
}
|
||
}
|
||
|
||
// 如果数据模型为空,且DOM中没有占位符,添加占位符
|
||
if (流程终点列表.value.length === 0 && !终点列表引用.value.querySelector('.placeholder')) {
|
||
consola.info('添加终点列表占位符,因为数据模型为空');
|
||
const placeholder = document.createElement('div');
|
||
placeholder.className = 'placeholder';
|
||
placeholder.textContent = '请拖入组件构成流程终点';
|
||
终点列表引用.value.append(placeholder);
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (!源列表引用.value || !起点列表引用.value || !终点列表引用.value) {
|
||
consola.error('未能获取到 SortableJS 容器元素');
|
||
return;
|
||
}
|
||
|
||
// 初始化源列表
|
||
Sortable.create(源列表引用.value, {
|
||
group: {
|
||
name: 'components',
|
||
pull: 'clone', // 允许克隆
|
||
put: false, // 不允许拖入
|
||
},
|
||
sort: false, // 源列表内不允许排序
|
||
animation: 150,
|
||
// 将 data-id 附加到拖拽数据中,供目标列表识别
|
||
setData: (dataTransfer, dragEl) => {
|
||
// 确保 dragEl 是有效的 HTMLElement 且包含 data-id
|
||
if (dragEl instanceof HTMLElement && dragEl.dataset.id) {
|
||
dataTransfer.setData('text/plain', dragEl.dataset.id);
|
||
}
|
||
},
|
||
});
|
||
|
||
// 初始化流程起点列表
|
||
Sortable.create(起点列表引用.value, {
|
||
group: {
|
||
name: 'flowPoints', // 修改为新的组名,用于区分"流程点"和普通组件
|
||
pull: true, // 允许拖出
|
||
put: ['components', 'flowPoints'], // 允许接收"组件"和"流程点"
|
||
},
|
||
animation: 150,
|
||
draggable: '.drag-item', // 只有带 .drag-item 的元素才能被拖动
|
||
onAdd: (evt) => {
|
||
const itemId = Number.parseInt(evt.item.dataset.id || '0', 10);
|
||
const newItem = getItemById(itemId);
|
||
|
||
if (!newItem) {
|
||
consola.error('找不到拖拽的组件项数据 (起点)');
|
||
evt.item.remove(); // 从 DOM 中移除无效项
|
||
return;
|
||
}
|
||
|
||
// 检查来源,如果是从终点列表拖来的
|
||
if (evt.from === 终点列表引用.value) {
|
||
// 从终点列表移除
|
||
流程终点列表.value = [];
|
||
}
|
||
|
||
// 尝试添加到起点列表
|
||
const added = addToStartList(newItem, evt.item);
|
||
|
||
// 在下一个 tick 清理 DOM
|
||
nextTick(() => {
|
||
cleanupStartListDOM(added ? itemId : undefined);
|
||
|
||
// 如果来源是终点列表,需同时清理终点列表的 DOM
|
||
if (evt.from === 终点列表引用.value) {
|
||
cleanupEndListDOM();
|
||
}
|
||
});
|
||
},
|
||
});
|
||
|
||
// 初始化流程终点列表
|
||
Sortable.create(终点列表引用.value, {
|
||
group: {
|
||
name: 'flowPoints', // 与起点列表使用相同的组名
|
||
pull: true, // 允许拖出
|
||
put: ['components', 'flowPoints'], // 允许接收"组件"和"流程点"
|
||
},
|
||
draggable: '.drag-item',
|
||
animation: 150,
|
||
onAdd: (evt) => {
|
||
const itemId = Number.parseInt(evt.item.dataset.id || '0', 10);
|
||
const newItem = getItemById(itemId);
|
||
|
||
if (!newItem) {
|
||
consola.error('找不到拖拽的组件项数据 (终点)');
|
||
evt.item.remove();
|
||
return;
|
||
}
|
||
|
||
// 检查来源,如果是从起点列表拖来的
|
||
if (evt.from === 起点列表引用.value) {
|
||
// 从起点列表移除
|
||
流程起点列表.value = [];
|
||
}
|
||
|
||
// 尝试添加到终点列表
|
||
const added = addToEndList(newItem, evt.item);
|
||
|
||
// 在下一个 tick 清理 DOM
|
||
nextTick(() => {
|
||
cleanupEndListDOM(added ? itemId : undefined);
|
||
|
||
// 如果来源是起点列表,需同时清理起点列表的 DOM
|
||
if (evt.from === 起点列表引用.value) {
|
||
cleanupStartListDOM();
|
||
}
|
||
});
|
||
},
|
||
});
|
||
});
|
||
|
||
// 计算流程中的中间节点 (基于起点和终点在原始列表中的位置)
|
||
const intermediateNodes = computed(() => {
|
||
if (流程起点列表.value.length === 0 || 流程终点列表.value.length === 0) {
|
||
return [];
|
||
}
|
||
const startIndex = getItemIndex(流程起点列表.value[0]);
|
||
const endIndex = getItemIndex(流程终点列表.value[0]);
|
||
|
||
// 确保起点和终点都有效,且它们之间至少有一个组件
|
||
if (startIndex !== -1 && endIndex !== -1 && startIndex < endIndex - 1) {
|
||
// 返回原始列表中位于起点和终点之间的组件
|
||
return 组件列表.value.slice(startIndex + 1, endIndex);
|
||
}
|
||
return [];
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex gap-5 p-5 bg-[#1a1a1a] text-[#e0e0e0] font-sans min-h-100">
|
||
<!-- 组件选择区域 -->
|
||
<div class="flex-1 p-4 rounded">
|
||
<h3 class="text-center mb-5 text-white font-bold">组件选择</h3>
|
||
<div ref="源列表引用" class="min-h-150px rounded p-2.5 transition-bg duration-200">
|
||
<div
|
||
v-for="item in 组件列表"
|
||
:key="item.id"
|
||
class="py-2.5 px-4 mb-2 bg-[#333] border border-[#007bff] text-[#e0e0e0] rounded cursor-grab text-center drag-item component-item"
|
||
:data-id="item.id"
|
||
>
|
||
<!-- data-id 用于 SortableJS 识别拖拽项 -->
|
||
{{ item.name }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 构建流程区域 -->
|
||
<div class="flex-1 p-4 rounded">
|
||
<h3 class="text-center mb-5 text-white 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]">流程起点:</div>
|
||
<div
|
||
ref="起点列表引用"
|
||
class="flex-1 min-h-150px rounded p-2.5 border-2 border-dashed border-[#555] bg-[#2a2a2a] mt-1.25 mb-5 target-list start-list"
|
||
>
|
||
<div
|
||
v-if="流程起点列表.length === 0"
|
||
class="flex justify-center items-center h-full min-h-130px text-[#888] text-center placeholder"
|
||
>
|
||
请拖入组件构成流程起点
|
||
</div>
|
||
<div
|
||
v-for="item in 流程起点列表"
|
||
:key="item.id"
|
||
class="py-2.5 px-4 mb-2 bg-[#444] border border-[#17a2b8] text-[#e0e0e0] rounded cursor-default text-center drag-item dropped-item"
|
||
:data-id="item.id"
|
||
>
|
||
{{ 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]">流程终点:</div>
|
||
<div
|
||
ref="终点列表引用"
|
||
class="flex-1 min-h-150px rounded p-2.5 border-2 border-dashed border-[#555] bg-[#2a2a2a] mt-1.25 target-list end-list"
|
||
>
|
||
<div
|
||
v-if="流程终点列表.length === 0"
|
||
class="flex justify-center items-center h-full min-h-130px text-[#888] text-center placeholder"
|
||
>
|
||
请拖入组件构成流程终点
|
||
</div>
|
||
<div
|
||
v-for="item in 流程终点列表"
|
||
:key="item.id"
|
||
class="py-2.5 px-4 mb-2 bg-[#444] border border-[#17a2b8] text-[#e0e0e0] rounded cursor-default text-center drag-item dropped-item"
|
||
:data-id="item.id"
|
||
>
|
||
{{ item.name }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 预览区域 -->
|
||
<div class="flex-1 p-4 rounded">
|
||
<h3 class="text-center mb-5 text-white font-bold">预览</h3>
|
||
<div
|
||
class="mt-2.5 p-2.5 bg-[#2a2a2a] border border-[#444] rounded min-h-100px flex flex-col items-stretch justify-start gap-2"
|
||
>
|
||
<template v-if="流程起点列表.length > 0 || 流程终点列表.length > 0">
|
||
<div
|
||
v-if="流程起点列表.length > 0"
|
||
:key="`start-${流程起点列表[0].id}`"
|
||
class="py-2.5 px-4 bg-[#333] border border-[#007bff] text-[#e0e0e0] rounded cursor-default flex-shrink-0 text-center drag-item component-item preview-item"
|
||
>
|
||
{{ 流程起点列表[0].name }}
|
||
</div>
|
||
<div
|
||
v-for="item in intermediateNodes"
|
||
:key="`middle-${item.id}`"
|
||
class="py-2.5 px-4 bg-[#333] border border-[#6c757d] text-[#e0e0e0] rounded cursor-default flex-shrink-0 text-center drag-item component-item preview-item preview-intermediate"
|
||
>
|
||
{{ item.name }}
|
||
</div>
|
||
<div
|
||
v-if="流程终点列表.length > 0"
|
||
:key="`end-${流程终点列表[0].id}`"
|
||
class="py-2.5 px-4 bg-[#333] border border-[#007bff] text-[#e0e0e0] rounded cursor-default flex-shrink-0 text-center drag-item component-item preview-item"
|
||
>
|
||
{{ 流程终点列表[0].name }}
|
||
</div>
|
||
</template>
|
||
<div
|
||
v-else
|
||
class="flex justify-center items-center h-20 text-[#888] text-center w-full preview-placeholder placeholder"
|
||
>
|
||
请拖入起点和终点组件以生成预览
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* SortableJS 拖拽过程中的辅助样式 */
|
||
.sortable-ghost {
|
||
opacity: 0.5;
|
||
background: #4a90e2;
|
||
}
|
||
|
||
/* .sortable-chosen {} */ /* 被选中的元素 */
|
||
/* .sortable-drag {} */ /* 拖拽中的元素 */
|
||
</style>
|