Files
playtool-demo/app/components/flow-nodes/WorkflowEditor.vue
2025-08-22 15:14:05 +08:00

598 lines
15 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.

<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 { open, save } from "@tauri-apps/plugin-dialog";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
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";
// import { toast } from "#build/ui";
const toast = useToast();
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("请先添加任务节点");
toast.add({
title: "任务异常",
description: "当前任务清单并无任何节点,请先添加节点。",
color: "warning"
});
return;
}
const taskData = {
name: `任务_${new Date().toLocaleTimeString()}`,
createdAt: new Date().toISOString(),
nodes: workflowNodes.value.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
taskId: node.taskId,
config: node.config || {}
}))
};
console.log("=== 工作流执行 ===");
console.log("任务名称:", taskData.name);
console.log("创建时间:", taskData.createdAt);
console.log("节点数量:", taskData.nodes.length);
console.log("完整JSON数据:");
console.log(JSON.stringify(taskData, null, 2));
console.log("=== 执行完成 ===");
toast.add({
title: "任务执行成功",
description: `总共执行了 ${taskData.nodes.length} 个节点。`,
color: "success"
});
};
const saveTask = async () => {
if (workflowNodes.value.length === 0) {
console.log("工作流为空,无法保存");
return;
}
const taskData = {
name: `任务_${new Date().toLocaleTimeString()}`,
createdAt: new Date().toISOString(),
version: "1.0",
nodes: workflowNodes.value.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
taskId: node.taskId,
config: node.config || {}
}))
};
try {
const filePath = await save({
filters: [{
name: "JSON",
extensions: ["json"]
}],
defaultPath: `workflow_${Date.now()}.json`
});
if (filePath) {
await writeTextFile(filePath, JSON.stringify(taskData, null, 2));
console.log("工作流已保存到:", filePath);
}
} catch (error) {
console.error("保存工作流失败:", error);
}
};
const exportTask = async () => {
await saveTask();
};
const importTask = async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: "JSON",
extensions: ["json"]
}]
});
if (selected) {
const content = await readTextFile(selected as string);
const data = JSON.parse(content);
if (data.nodes && Array.isArray(data.nodes)) {
// 验证并重建节点
const importedNodes = data.nodes.map((node: any) => {
// 提取原始节点ID去除时间戳后缀
const originalId = node.id.replace(/-\d+-[a-z0-9]+$/, "");
const definition = nodeDefinitions[originalId];
if (!definition) {
console.warn(`未找到节点定义: ${originalId}`);
return null;
}
return {
...definition,
id: node.id, // 保持导入的ID
config: node.config || definition.config || {}
};
}).filter(Boolean); // 过滤掉null值
workflowNodes.value = importedNodes as FlowNode[];
console.log(`成功导入 ${importedNodes.length} 个节点`);
console.log("导入的工作流数据:", data);
} else {
console.error("无效的工作流文件格式");
}
}
} catch (error) {
console.error("导入工作流失败:", error);
}
};
</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>