Files
vue-ts-example/src/pages/PkgsUsage/SortableJS.page.vue
严浩 182d87c773
All checks were successful
/ build-and-deploy-to-vercel (push) Successful in 3m26s
/ surge (push) Successful in 2m50s
/ playwright (push) Successful in 1m56s
/ lint-build-and-check (push) Successful in 4m53s
chore: 将变量名翻译为中文以提高可读性
2025-04-16 14:56:53 +08:00

430 lines
16 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">
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>