SortableJS.page.vue
All checks were successful
/ build-and-deploy-to-vercel (push) Successful in 6m3s
/ lint-build-and-check (push) Successful in 7m10s
/ surge (push) Successful in 5m27s
/ playwright (push) Successful in 4m17s

This commit is contained in:
严浩
2025-04-15 14:17:43 +08:00
parent b43b076b9d
commit 22e5c31f58
7 changed files with 799 additions and 0 deletions

View File

@ -0,0 +1,429 @@
<script setup lang="ts">
import Sortable from 'sortablejs';
interface ComponentItem {
id: number;
name: string;
}
const componentsList = ref<ComponentItem[]>([
{ id: 1, name: '组件1' },
{ id: 2, name: '组件2' },
{ id: 3, name: '组件3' },
{ id: 4, name: '组件4' },
{ id: 5, name: '组件5' },
]);
const processStartList = ref<ComponentItem[]>([]);
const processEndList = ref<ComponentItem[]>([]);
const sourceListRef = ref<HTMLElement | null>(null);
const startListRef = ref<HTMLElement | null>(null);
const endListRef = ref<HTMLElement | null>(null);
function getItemIndex(item: ComponentItem): number {
return componentsList.value.findIndex((comp) => comp.id === item.id);
}
function getItemById(id: number): ComponentItem | undefined {
return componentsList.value.find((comp) => comp.id === id);
}
// 添加元素到起点列表,维护相应的 DOM 和数据模型
function addToStartList(item: ComponentItem, domItem: HTMLElement) {
const newStartIndex = getItemIndex(item);
const endIndex = processEndList.value.length > 0 ? getItemIndex(processEndList.value[0]) : -1;
// 约束检查:如果终点已存在,则新起点必须在终点之前
if (endIndex !== -1 && newStartIndex >= endIndex) {
consola.warn('流程起点必须在流程终点之前');
domItem.remove(); // 移除不符合约束的 DOM 项
return false; // 表示添加失败
}
// 更新数据模型
if (processStartList.value.length > 0) {
processStartList.value.splice(0, 1, item); // 替换
} else {
processStartList.value.push(item); // 添加
}
return true; // 表示添加成功
}
// 添加元素到终点列表,维护相应的 DOM 和数据模型
function addToEndList(item: ComponentItem, domItem: HTMLElement) {
const newEndIndex = getItemIndex(item);
const startIndex = processStartList.value.length > 0 ? getItemIndex(processStartList.value[0]) : -1;
// 约束检查:如果起点已存在,则新终点必须在起点之后
if (startIndex !== -1 && newEndIndex <= startIndex) {
consola.warn('流程终点必须在流程起点之后');
domItem.remove();
return false; // 表示添加失败
}
// 更新数据模型
if (processEndList.value.length > 0) {
processEndList.value.splice(0, 1, item); // 替换
} else {
processEndList.value.push(item); // 添加
}
return true; // 表示添加成功
}
// 清理起点列表的 DOM确保与数据模型一致
function cleanupStartListDOM(keepItemId?: number) {
if (!startListRef.value) return;
consola.info(`清理起点列表 DOM保留的项 ID${keepItemId ?? '无'}`);
const children = [...startListRef.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 (processStartList.value.length === 1 && processStartList.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') && processStartList.value.length > 0) {
// 数据模型有项时,移除占位符
consola.info('移除起点列表中的占位符,因为数据模型中已有项');
child.remove();
}
}
// 如果数据模型为空且DOM中没有占位符添加占位符
if (processStartList.value.length === 0 && !startListRef.value.querySelector('.placeholder')) {
consola.info('添加起点列表占位符,因为数据模型为空');
const placeholder = document.createElement('div');
placeholder.className = 'placeholder';
placeholder.textContent = '请拖入组件构成流程起点';
startListRef.value.append(placeholder);
}
}
// 清理终点列表的 DOM确保与数据模型一致
function cleanupEndListDOM(keepItemId?: number) {
if (!endListRef.value) return;
consola.info(`清理终点列表 DOM保留的项 ID${keepItemId ?? '无'}`);
const children = [...endListRef.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 (processEndList.value.length === 1 && processEndList.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') && processEndList.value.length > 0) {
// 数据模型有项时,移除占位符
consola.info('移除终点列表中的占位符,因为数据模型中已有项');
child.remove();
}
}
// 如果数据模型为空且DOM中没有占位符添加占位符
if (processEndList.value.length === 0 && !endListRef.value.querySelector('.placeholder')) {
consola.info('添加终点列表占位符,因为数据模型为空');
const placeholder = document.createElement('div');
placeholder.className = 'placeholder';
placeholder.textContent = '请拖入组件构成流程终点';
endListRef.value.append(placeholder);
}
}
onMounted(() => {
if (!sourceListRef.value || !startListRef.value || !endListRef.value) {
consola.error('未能获取到 SortableJS 容器元素');
return;
}
// 初始化源列表
Sortable.create(sourceListRef.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(startListRef.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 === endListRef.value) {
// 从终点列表移除
processEndList.value = [];
}
// 尝试添加到起点列表
const added = addToStartList(newItem, evt.item);
// 在下一个 tick 清理 DOM
nextTick(() => {
cleanupStartListDOM(added ? itemId : undefined);
// 如果来源是终点列表,需同时清理终点列表的 DOM
if (evt.from === endListRef.value) {
cleanupEndListDOM();
}
});
},
});
// 初始化流程终点列表
Sortable.create(endListRef.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 === startListRef.value) {
// 从起点列表移除
processStartList.value = [];
}
// 尝试添加到终点列表
const added = addToEndList(newItem, evt.item);
// 在下一个 tick 清理 DOM
nextTick(() => {
cleanupEndListDOM(added ? itemId : undefined);
// 如果来源是起点列表,需同时清理起点列表的 DOM
if (evt.from === startListRef.value) {
cleanupStartListDOM();
}
});
},
});
});
// 计算流程中的中间节点 (基于起点和终点在原始列表中的位置)
const intermediateNodes = computed(() => {
if (processStartList.value.length === 0 || processEndList.value.length === 0) {
return [];
}
const startIndex = getItemIndex(processStartList.value[0]);
const endIndex = getItemIndex(processEndList.value[0]);
// 确保起点和终点都有效,且它们之间至少有一个组件
if (startIndex !== -1 && endIndex !== -1 && startIndex < endIndex - 1) {
// 返回原始列表中位于起点和终点之间的组件
return componentsList.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="sourceListRef" class="min-h-150px rounded p-2.5 transition-bg duration-200">
<div
v-for="item in componentsList"
: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="startListRef"
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="processStartList.length === 0"
class="flex justify-center items-center h-full min-h-130px text-[#888] text-center placeholder"
>
请拖入组件构成流程起点
</div>
<div
v-for="item in processStartList"
: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="endListRef"
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="processEndList.length === 0"
class="flex justify-center items-center h-full min-h-130px text-[#888] text-center placeholder"
>
请拖入组件构成流程终点
</div>
<div
v-for="item in processEndList"
: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="processStartList.length > 0 || processEndList.length > 0">
<div
v-if="processStartList.length > 0"
:key="`start-${processStartList[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"
>
{{ processStartList[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="processEndList.length > 0"
:key="`end-${processEndList[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"
>
{{ processEndList[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>

View File

@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed, ref } from 'vue'; // 导入 computed
import { VueDraggable } from 'vue-draggable-plus';
// 定义组件项的类型
interface ComponentItem {
id: number;
name: string;
}
// 左侧可选组件列表
const componentsList = ref<ComponentItem[]>([
{ id: 1, name: '目标信号分析组件' },
{ id: 2, name: '邻星选择组件' },
{ id: 3, name: '参数估计与运算组件' },
{ id: 4, name: '参考信号发射组件' },
{ id: 5, name: '定位点生成组件' },
]);
// 中间流程起点列表
const processStartList = ref<ComponentItem[]>([]);
// 中间流程终点列表
const processEndList = ref<ComponentItem[]>([]);
// 获取组件在原始列表中的索引
function getItemIndex(item: ComponentItem): number {
return componentsList.value.findIndex((comp) => comp.id === item.id);
}
// 处理添加到“流程起点”列表的函数
function onAddStart(evt: { newIndex?: number; item?: HTMLElement }) {
const newItem = processStartList.value[evt.newIndex!]; // 获取新拖入的项
const endIndex = processEndList.value.length > 0 ? getItemIndex(processEndList.value[0]) : -1;
const newStartIndex = getItemIndex(newItem);
// 检查约束:新起点不能在终点之后(如果终点已存在)
if (endIndex !== -1 && newStartIndex >= endIndex) {
consola.warn('流程起点必须在流程终点之前');
// 异步移除,因为 v-model 可能尚未完全同步
nextTick(() => {
processStartList.value.splice(evt.newIndex!, 1);
});
return; // 阻止后续替换逻辑
}
// 处理替换逻辑
if (processStartList.value.length > 1) {
const oldIndex = evt.newIndex === 0 ? 1 : 0;
processStartList.value.splice(oldIndex, 1); // 移除旧项
consola.info('流程起点组件已替换');
}
}
// 处理添加到“流程终点”列表的函数
function onAddEnd(evt: { newIndex?: number; item?: HTMLElement }) {
const newItem = processEndList.value[evt.newIndex!]; // 获取新拖入的项
const startIndex = processStartList.value.length > 0 ? getItemIndex(processStartList.value[0]) : -1;
const newEndIndex = getItemIndex(newItem);
// 检查约束:新终点不能在起点之前(如果起点已存在)
if (startIndex !== -1 && newEndIndex <= startIndex) {
consola.warn('流程终点必须在流程起点之后');
// 异步移除
nextTick(() => {
processEndList.value.splice(evt.newIndex!, 1);
});
return; // 阻止后续替换逻辑
}
// 处理替换逻辑
if (processEndList.value.length > 1) {
const oldIndex = evt.newIndex === 0 ? 1 : 0;
processEndList.value.splice(oldIndex, 1); // 移除旧项
consola.info('流程终点组件已替换');
}
}
// 计算中间节点
const intermediateNodes = computed(() => {
if (processStartList.value.length === 0 || processEndList.value.length === 0) {
return [];
}
const startIndex = getItemIndex(processStartList.value[0]);
const endIndex = getItemIndex(processEndList.value[0]);
if (startIndex !== -1 && endIndex !== -1 && startIndex < endIndex - 1) {
// 提取起点和终点之间的组件
return componentsList.value.slice(startIndex + 1, endIndex);
}
return [];
});
</script>
<template>
<div class="drag-container">
<!-- 组件选择区域 -->
<div class="component-selection">
<h3>组件选择</h3>
<VueDraggable
v-model="componentsList"
class="drag-list source-list"
:group="{ name: 'components', pull: 'clone', put: false }"
:sort="false"
>
<div v-for="item in componentsList" :key="item.id" class="drag-item component-item">
{{ item.name }}
</div>
</VueDraggable>
</div>
<!-- 构建流程区域 -->
<div class="build-process">
<h3>构建流程</h3>
<div class="process-area">
<div class="process-label">流程起点</div>
<VueDraggable
v-model="processStartList"
class="drag-list target-list start-list"
group="components"
@add="onAddStart"
>
<div v-if="processStartList.length === 0" class="placeholder">请拖入组件构成流程起点</div>
<div v-for="item in processStartList" :key="item.id" class="drag-item dropped-item">
{{ item.name }}
</div>
</VueDraggable>
</div>
<div class="process-area">
<div class="process-label">流程终点</div>
<VueDraggable
v-model="processEndList"
class="drag-list target-list end-list"
group="components"
@add="onAddEnd"
draggable=".drag-item"
>
<div v-if="processEndList.length === 0" class="placeholder">请拖入组件构成流程终点</div>
<div v-for="item in processEndList" :key="item.id" class="drag-item dropped-item">
{{ item.name }}
</div>
</VueDraggable>
</div>
</div>
<!-- 预览区域 -->
<div class="preview-area">
<h3>预览</h3>
<div class="preview-content">
<template v-if="processStartList.length > 0 || processEndList.length > 0">
<!-- 显示起点组件 -->
<div
v-if="processStartList.length > 0"
:key="`start-${processStartList[0].id}`"
class="drag-item component-item preview-item"
>
{{ processStartList[0].name }}
</div>
<!-- 显示计算出的中间节点 -->
<div
v-for="item in intermediateNodes"
:key="`middle-${item.id}`"
class="drag-item component-item preview-item preview-intermediate"
>
{{ item.name }}
</div>
<!-- 显示终点组件 -->
<div
v-if="processEndList.length > 0"
:key="`end-${processEndList[0].id}`"
class="drag-item component-item preview-item"
>
{{ processEndList[0].name }}
</div>
</template>
<!-- 如果都没有选择显示占位符 -->
<div v-else class="placeholder preview-placeholder">请拖入起点和终点组件以生成预览</div>
</div>
</div>
</div>
</template>
<style scoped>
.drag-container {
display: flex;
gap: 20px;
padding: 20px;
background-color: #1a1a1a; /* 深色背景模拟图片 */
color: #e0e0e0;
font-family: sans-serif;
min-height: 400px; /* 保证容器有足够高度 */
}
.component-selection,
.build-process,
.preview-area {
flex: 1;
padding: 15px;
border-radius: 4px;
}
.component-selection h3,
.build-process h3,
.preview-area h3 {
text-align: center;
margin-bottom: 20px;
color: #ffffff;
font-weight: bold;
}
.drag-list {
min-height: 150px; /* 列表最小高度 */
border-radius: 4px;
padding: 10px;
transition: background-color 0.2s ease;
}
.source-list {
/* 源列表特定样式 */
}
.target-list {
border: 2px dashed #555; /* 虚线边框 */
background-color: #2a2a2a; /* 目标区域背景 */
margin-top: 5px; /* 与标签的间距 */
}
.target-list.start-list {
margin-bottom: 20px; /* 起点和终点区域间距 */
}
.drag-item {
padding: 10px 15px;
margin-bottom: 8px;
background-color: #333;
border: 1px solid #007bff; /* 蓝色边框 */
color: #e0e0e0;
border-radius: 4px;
cursor: grab;
text-align: center;
}
.component-item {
/* 源列表项特定样式 */
}
.dropped-item {
background-color: #444; /* 放入目标区域后的样式 */
border-color: #17a2b8; /* 不同颜色边框 */
cursor: default;
}
.placeholder {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 130px; /* 确保占位符区域可见 */
color: #888;
text-align: center;
}
.process-area {
display: flex;
align-items: flex-start; /* 标签和列表顶部对齐 */
margin-bottom: 10px; /* 增加区域间距 */
}
.process-label {
width: 80px; /* 固定标签宽度 */
text-align: right;
margin-right: 10px;
padding-top: 10px; /* 调整标签垂直位置 */
color: #ccc;
}
.target-list {
flex: 1; /* 列表占据剩余空间 */
}
.preview-content {
margin-top: 10px;
padding: 10px; /* 调整内边距以适应项目 */
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
min-height: 100px; /* 增加最小高度 */
display: flex;
flex-direction: column; /* 设置为垂直方向排列 */
align-items: stretch; /* 使项目宽度撑满容器 */
justify-content: flex-start; /* 从顶部开始排列 */
gap: 8px; /* 调整垂直间距 */
/* 移除 flex-wrap因为单列不需要换行 */
}
.preview-item {
cursor: default; /* 预览项不可拖动 */
flex-shrink: 0; /* 防止项目被压缩 */
}
.preview-intermediate {
/* 可以为中间节点添加特定样式,例如不同的边框颜色 */
border-color: #6c757d; /* 灰色边框 */
}
/* 预览区域内的占位符特定样式 */
.preview-placeholder {
display: flex; /* 确保 flex 布局生效 */
justify-content: center;
align-items: center;
min-height: auto; /* 移除继承的最小高度 */
height: 80px; /* 给一个合适的高度 */
color: #888;
text-align: center;
width: 100%; /* 占满容器宽度 */
}
/* 拖拽过程中的样式 (可选) */
.sortable-ghost {
opacity: 0.5;
background: #4a90e2;
}
.sortable-chosen {
/* 被选中的元素样式 */
}
.sortable-drag {
/* 拖拽中的元素样式 */
}
</style>