550 lines
14 KiB
Vue
550 lines
14 KiB
Vue
<template>
|
|
<div class="h-screen flex flex-col bg-gray-50 select-none">
|
|
<!-- 顶部工具栏 -->
|
|
<div class="bg-white border-b border-gray-200 px-4 py-3">
|
|
<div class="flex items-center space-x-4">
|
|
<button
|
|
class="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
|
|
@click="goBack"
|
|
>
|
|
<ArrowLeft class="w-4 h-4" />
|
|
<span>返回</span>
|
|
</button>
|
|
|
|
<div class="flex-1" />
|
|
|
|
<div class="flex items-center space-x-3">
|
|
<button
|
|
class="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700"
|
|
@click="startTask"
|
|
>
|
|
<Play class="w-4 h-4" />
|
|
<span>开始任务</span>
|
|
</button>
|
|
|
|
<button
|
|
class="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
|
@click="saveTask"
|
|
>
|
|
<Save class="w-4 h-4" />
|
|
<span>保存任务</span>
|
|
</button>
|
|
|
|
<button
|
|
class="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
|
|
@click="exportTask"
|
|
>
|
|
<Download class="w-4 h-4" />
|
|
<span>导出任务</span>
|
|
</button>
|
|
|
|
<button
|
|
class="flex items-center space-x-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
|
@click="importTask"
|
|
>
|
|
<Upload class="w-4 h-4" />
|
|
<span>导入任务</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 主内容区域 -->
|
|
<div class="flex-1 flex">
|
|
<!-- 左侧工作流面板 -->
|
|
<div class="bg-white shadow-lg overflow-hidden flex flex-col w-280px">
|
|
<div
|
|
class="p-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0"
|
|
>
|
|
<h2 class="text-lg font-semibold text-gray-800">
|
|
任务流
|
|
</h2>
|
|
<button
|
|
v-if="workflowNodes.length > 0"
|
|
class="text-xs text-red-600 hover:text-red-800"
|
|
@click="clearWorkflow"
|
|
>
|
|
清空
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-4">
|
|
<draggable
|
|
v-model="workflowNodes"
|
|
:animation="200"
|
|
class="space-y-2 min-h-full"
|
|
item-key="id"
|
|
handle=".drag-handle"
|
|
ghost-class="sortable-ghost"
|
|
chosen-class="sortable-chosen"
|
|
drag-class="sortable-drag"
|
|
:force-fallback="true"
|
|
@change="onWorkflowChange"
|
|
>
|
|
<template #item="{ element, index }">
|
|
<div
|
|
class="p-3 border rounded-lg hover:shadow-md transition-shadow"
|
|
:style="{
|
|
backgroundColor: element.backgroundColor,
|
|
color: element.textColor
|
|
}"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center space-x-2 flex-1 drag-handle cursor-move">
|
|
<div
|
|
class="flex flex-col space-y-0.5 opacity-60 hover:opacity-100 transition-opacity"
|
|
>
|
|
<div class="w-1.5 h-1.5 bg-current rounded-full" />
|
|
<div class="w-1.5 h-1.5 bg-current rounded-full" />
|
|
<div class="w-1.5 h-1.5 bg-current rounded-full" />
|
|
</div>
|
|
<component
|
|
:is="getIconComponent(element.icon)"
|
|
class="w-4 h-4"
|
|
/>
|
|
<span class="text-sm font-medium">{{ element.name }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center space-x-1 flex-shrink-0">
|
|
<button
|
|
v-if="element.config?.editable"
|
|
class="p-1 rounded hover:bg-white/20 cursor-pointer z-10 relative"
|
|
@click.stop="editNodeConfig(element)"
|
|
>
|
|
<Settings class="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
class="p-1 rounded hover:bg-white/20 cursor-pointer z-10 relative"
|
|
@click.stop="removeNode(index)"
|
|
>
|
|
<X class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="element.config?.duration"
|
|
class="mt-2 text-xs opacity-80 ml-6"
|
|
>
|
|
延时: {{ element.config.duration }}ms
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</draggable>
|
|
|
|
<div
|
|
v-if="workflowNodes.length === 0"
|
|
class="text-center py-8 text-gray-400 flex flex-col items-center justify-center min-h-[200px]"
|
|
>
|
|
<Move class="w-8 h-8 mx-auto mb-2" />
|
|
<p class="text-sm">
|
|
从右侧点击节点添加到此处
|
|
</p>
|
|
<p class="text-xs mt-1 opacity-75">
|
|
添加后可拖拽排序
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右侧节点库 -->
|
|
<div class="flex-1 flex flex-col bg-gray-50 min-h-0">
|
|
<div class="p-6 pb-4 bg-gray-50 flex-shrink-0">
|
|
<h1 class="text-2xl font-bold text-gray-900">
|
|
节点库
|
|
</h1>
|
|
<p class="text-gray-600 mt-1">
|
|
点击节点添加到左侧任务流
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 节点库 - 独立滚动区域 -->
|
|
<div class="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
|
|
<div class="space-y-6">
|
|
<div v-for="(nodes, category) in categorizedNodes" :key="category">
|
|
<h3 class="text-lg font-semibold text-gray-800 mb-3">
|
|
{{ category }}
|
|
</h3>
|
|
<div class="grid grid-cols-4 gap-4">
|
|
<button
|
|
v-for="element in nodes"
|
|
:key="element.id"
|
|
class="p-4 border rounded-lg cursor-pointer hover:shadow-lg transition-all hover:scale-105 text-center"
|
|
:style="{
|
|
backgroundColor: element.backgroundColor,
|
|
color: element.textColor
|
|
}"
|
|
@click="addNodeToWorkflow(element)"
|
|
>
|
|
<component
|
|
:is="getIconComponent(element.icon)"
|
|
class="w-8 h-8 mx-auto mb-2"
|
|
/>
|
|
<p class="text-sm font-medium">
|
|
{{ element.name }}
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 配置编辑对话框 -->
|
|
<div
|
|
v-if="editingNode"
|
|
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
@click="cancelEdit"
|
|
>
|
|
<div class="bg-white rounded-lg p-6 w-80 max-w-sm" @click.stop>
|
|
<h3 class="text-lg font-semibold mb-4">
|
|
编辑配置
|
|
</h3>
|
|
|
|
<div v-if="editingNode.config?.editable" class="mb-4">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
延时时间 (毫秒)
|
|
</label>
|
|
<input
|
|
v-model.number="editingNode.config.duration"
|
|
type="number"
|
|
min="100"
|
|
max="60000"
|
|
step="100"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-2">
|
|
<button
|
|
class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
|
|
@click="cancelEdit"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
|
@click="saveEdit"
|
|
>
|
|
保存
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { FlowNode } from "~/components/flow-nodes/nodes";
|
|
import {
|
|
ArrowLeft,
|
|
Clock,
|
|
Computer,
|
|
Download,
|
|
Monitor,
|
|
Move,
|
|
Pause,
|
|
Play,
|
|
Play as PlayIcon,
|
|
PlaySquare,
|
|
Power,
|
|
Save,
|
|
Settings,
|
|
SkipBack,
|
|
SkipForward,
|
|
Square,
|
|
Upload,
|
|
Volume2,
|
|
VolumeX,
|
|
X
|
|
} from "lucide-vue-next";
|
|
import { computed, ref } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import draggable from "vuedraggable";
|
|
import {
|
|
getNodesByCategory,
|
|
nodeDefinitions
|
|
} from "~/components/flow-nodes/nodes";
|
|
|
|
const router = useRouter();
|
|
const workflowNodes = ref<FlowNode[]>([]);
|
|
|
|
const editingNode = ref<FlowNode | null>(null);
|
|
|
|
const categorizedNodes = computed(() => getNodesByCategory());
|
|
|
|
const getIconComponent = (iconName: string) => {
|
|
const iconMap: Record<string, any> = {
|
|
power: Power,
|
|
computer: Computer,
|
|
monitor: Monitor,
|
|
play: PlayIcon,
|
|
pause: Pause,
|
|
square: Square,
|
|
"skip-back": SkipBack,
|
|
"skip-forward": SkipForward,
|
|
"volume-2": Volume2,
|
|
"volume-x": VolumeX,
|
|
"play-square": PlaySquare,
|
|
clock: Clock
|
|
};
|
|
return iconMap[iconName] || Square;
|
|
};
|
|
|
|
const cloneNode = (original: FlowNode): FlowNode => {
|
|
// 生成更健壮的唯一ID
|
|
const timestamp = Date.now();
|
|
const randomString = Math.random().toString(36).substring(2, 11);
|
|
const uniqueId = `${original.id}-${timestamp}-${randomString}`;
|
|
|
|
return {
|
|
...original,
|
|
id: uniqueId,
|
|
config: original.config ? { ...original.config } : undefined
|
|
};
|
|
};
|
|
|
|
// 点击添加节点到工作流
|
|
const addNodeToWorkflow = (node: FlowNode) => {
|
|
const clonedNode = cloneNode(node);
|
|
workflowNodes.value.push(clonedNode);
|
|
console.log("节点已添加到工作流:", clonedNode.name);
|
|
};
|
|
|
|
const removeNode = (index: number) => {
|
|
workflowNodes.value.splice(index, 1);
|
|
};
|
|
|
|
const clearWorkflow = () => {
|
|
workflowNodes.value = [];
|
|
};
|
|
|
|
const onWorkflowChange = (event: any) => {
|
|
console.log("工作流变化事件:", event);
|
|
|
|
// 验证工作流完整性
|
|
if (event.added) {
|
|
const addedNode = event.added.element;
|
|
console.log("添加的节点数据:", addedNode);
|
|
|
|
// 验证添加的节点具有所有必需属性
|
|
if (!addedNode.id || !addedNode.name || !addedNode.type) {
|
|
console.error("无效节点添加到工作流:", addedNode);
|
|
return;
|
|
}
|
|
console.log("节点已成功添加到工作流:", addedNode.name);
|
|
}
|
|
|
|
if (event.moved) {
|
|
console.log("工作流节点已重新排序");
|
|
}
|
|
|
|
if (event.removed) {
|
|
console.log("节点已从工作流中移除");
|
|
}
|
|
|
|
console.log("工作流已更新,当前节点数量:", workflowNodes.value.length);
|
|
console.log("当前工作流节点:", workflowNodes.value);
|
|
};
|
|
|
|
const editNodeConfig = (node: FlowNode) => {
|
|
editingNode.value = { ...node };
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
editingNode.value = null;
|
|
};
|
|
|
|
const saveEdit = () => {
|
|
if (editingNode.value) {
|
|
const index = workflowNodes.value.findIndex(
|
|
(n) => n.id === editingNode.value!.id
|
|
);
|
|
if (index !== -1) {
|
|
workflowNodes.value[index] = { ...editingNode.value };
|
|
}
|
|
cancelEdit();
|
|
}
|
|
};
|
|
|
|
const goBack = () => {
|
|
router.push("/");
|
|
};
|
|
|
|
const startTask = () => {
|
|
if (workflowNodes.value.length === 0) {
|
|
console.log("请先添加任务节点");
|
|
return;
|
|
}
|
|
|
|
const taskData = {
|
|
name: `任务_${new Date().toLocaleTimeString()}`,
|
|
nodes: workflowNodes.value.map((node) => ({
|
|
id: node.id,
|
|
name: node.name,
|
|
type: node.type,
|
|
taskId: node.taskId,
|
|
config: node.config || {}
|
|
}))
|
|
};
|
|
|
|
console.log("执行", taskData);
|
|
console.log("任务已开始,请查看控制台");
|
|
};
|
|
|
|
const saveTask = () => {
|
|
const taskData = {
|
|
name: `任务_${new Date().toLocaleTimeString()}`,
|
|
nodes: workflowNodes.value.map((node) => ({
|
|
id: node.id,
|
|
name: node.name,
|
|
type: node.type,
|
|
taskId: node.taskId,
|
|
config: node.config || {}
|
|
}))
|
|
};
|
|
|
|
const blob = new Blob([JSON.stringify(taskData, null, 2)], {
|
|
type: "application/json"
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `任务_${Date.now()}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const exportTask = () => {
|
|
saveTask();
|
|
};
|
|
|
|
const importTask = () => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "application/json";
|
|
input.onchange = (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const data = JSON.parse(e.target?.result as string);
|
|
if (data.nodes && Array.isArray(data.nodes)) {
|
|
workflowNodes.value = data.nodes.map((node: any) => {
|
|
const definition = nodeDefinitions[node.id.replace(/-\d+$/, "")];
|
|
return {
|
|
...definition,
|
|
id: node.id,
|
|
config: node.config || {}
|
|
};
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("文件格式错误", error);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
};
|
|
input.click();
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.w-280px {
|
|
width: 280px;
|
|
}
|
|
|
|
/* 禁用文本选择,提供桌面应用体验 */
|
|
* {
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
}
|
|
|
|
/* 输入框仍然允许选择 */
|
|
input,
|
|
textarea {
|
|
user-select: text;
|
|
-webkit-user-select: text;
|
|
-moz-user-select: text;
|
|
-ms-user-select: text;
|
|
}
|
|
|
|
/* 拖拽时的样式 */
|
|
.sortable-ghost {
|
|
opacity: 0.3;
|
|
background: rgba(59, 130, 246, 0.1) !important;
|
|
border: 2px dashed rgba(59, 130, 246, 0.5) !important;
|
|
}
|
|
|
|
.sortable-chosen {
|
|
transform: scale(1.02);
|
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.sortable-drag {
|
|
transform: rotate(2deg);
|
|
opacity: 0.8;
|
|
}
|
|
|
|
/* 拖拽手柄样式 */
|
|
.drag-handle {
|
|
cursor: grab;
|
|
border-radius: 6px;
|
|
padding: 4px;
|
|
margin: -4px;
|
|
}
|
|
|
|
.drag-handle:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.drag-handle:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* 确保滚动容器独立 */
|
|
.overflow-y-auto {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
|
background-color: rgba(156, 163, 175, 0.5);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
|
background-color: rgba(156, 163, 175, 0.7);
|
|
}
|
|
|
|
/* 防止拖拽时出现默认的拖拽图像 */
|
|
.drag-handle * {
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* 确保按钮可以正常点击 */
|
|
button {
|
|
pointer-events: auto !important;
|
|
z-index: 10;
|
|
}
|
|
|
|
/* 按钮悬停效果 */
|
|
button:hover {
|
|
transform: scale(1.1);
|
|
transition: transform 0.1s ease;
|
|
}
|
|
</style>
|