598 lines
15 KiB
Vue
598 lines
15 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 { 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>
|