|
|
@@ -1,97 +1,129 @@
|
|
|
<template>
|
|
|
- <div class="panel">
|
|
|
- <div class="toolbar">
|
|
|
- <div class="filters">
|
|
|
- <el-button type="primary" @click="createEmpty">新建空工作流</el-button>
|
|
|
- <el-button @click="load">刷新</el-button>
|
|
|
+ <div class="workflow-page">
|
|
|
+ <aside class="workflow-list panel">
|
|
|
+ <div class="toolbar">
|
|
|
+ <div class="filters">
|
|
|
+ <el-button type="primary" @click="createEmpty">新建</el-button>
|
|
|
+ <el-button @click="load">刷新</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-table :data="workflows.items" height="620" border stripe highlight-current-row @row-click="openEdit">
|
|
|
+ <el-table-column prop="name" label="工作流" min-width="150" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="node_count" label="节点" width="70" />
|
|
|
+ <el-table-column label="操作" width="86">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button size="small" type="danger" @click.stop="remove(row)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <main class="workflow-canvas-panel panel">
|
|
|
+ <div class="toolbar">
|
|
|
+ <div class="filters">
|
|
|
+ <el-input v-model="form.name" placeholder="工作流名称" style="width: 220px" />
|
|
|
+ <el-button type="primary" @click="save">保存</el-button>
|
|
|
+ <el-button type="success" :disabled="!form.id" :loading="running" @click="runWorkflow">执行工作流</el-button>
|
|
|
+ <el-button :type="connectMode ? 'warning' : 'default'" @click="toggleConnectMode">
|
|
|
+ {{ connectMode ? '退出连线' : '连接节点' }}
|
|
|
+ </el-button>
|
|
|
+ <el-button type="danger" :disabled="!selectedNode" @click="deleteSelectedNode">删除节点</el-button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <el-table :data="workflows.items" border stripe>
|
|
|
- <el-table-column prop="id" label="ID" width="80" />
|
|
|
- <el-table-column prop="name" label="名称" min-width="180" />
|
|
|
- <el-table-column prop="description" label="描述" min-width="260" show-overflow-tooltip />
|
|
|
- <el-table-column prop="node_count" label="节点数" width="100" />
|
|
|
- <el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
|
|
- <el-table-column label="操作" width="210" fixed="right">
|
|
|
- <template #default="{ row }">
|
|
|
- <el-button size="small" @click="openEdit(row)">编辑</el-button>
|
|
|
- <el-button size="small" type="danger" @click="remove(row)">删除</el-button>
|
|
|
- </template>
|
|
|
- </el-table-column>
|
|
|
- </el-table>
|
|
|
-
|
|
|
- <el-dialog v-model="dialog" :title="form.id ? '编辑工作流' : '新建工作流'" width="860px">
|
|
|
- <el-form label-width="90px">
|
|
|
- <el-form-item label="名称">
|
|
|
- <el-input v-model="form.name" />
|
|
|
- </el-form-item>
|
|
|
- <el-form-item label="描述">
|
|
|
- <el-input v-model="form.description" type="textarea" :rows="2" />
|
|
|
- </el-form-item>
|
|
|
- </el-form>
|
|
|
|
|
|
<div class="toolbar">
|
|
|
<div class="filters">
|
|
|
- <el-select v-model="newNodeType" style="width: 180px">
|
|
|
- <el-option v-for="item in nodeTypes" :key="item.value" :label="item.label" :value="item.value" />
|
|
|
- </el-select>
|
|
|
- <el-button @click="addNode">添加节点</el-button>
|
|
|
+ <el-button v-for="item in nodeTypes" :key="item.value" @click="addNode(item.value)">{{ item.label }}</el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div class="node-editor">
|
|
|
+ <div ref="canvasRef" class="workflow-canvas" @click="selectedNodeKey = null">
|
|
|
+ <svg class="workflow-lines">
|
|
|
+ <line
|
|
|
+ v-for="line in connectionLines"
|
|
|
+ :key="line.key"
|
|
|
+ :x1="line.x1"
|
|
|
+ :y1="line.y1"
|
|
|
+ :x2="line.x2"
|
|
|
+ :y2="line.y2"
|
|
|
+ marker-end="url(#arrow)"
|
|
|
+ />
|
|
|
+ <defs>
|
|
|
+ <marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
|
+ <path d="M0,0 L8,4 L0,8 Z" />
|
|
|
+ </marker>
|
|
|
+ </defs>
|
|
|
+ </svg>
|
|
|
+
|
|
|
<div
|
|
|
- v-for="(node, index) in form.nodes"
|
|
|
- :key="index"
|
|
|
- class="node-row"
|
|
|
- draggable="true"
|
|
|
- @dragstart="dragIndex = index"
|
|
|
- @dragover.prevent
|
|
|
- @drop="dropNode(index)"
|
|
|
+ v-for="node in form.nodes"
|
|
|
+ :key="node.node_key"
|
|
|
+ class="workflow-node"
|
|
|
+ :class="{ selected: selectedNodeKey === node.node_key, connecting: connectSourceKey === node.node_key }"
|
|
|
+ :style="{ left: `${node.position_x}px`, top: `${node.position_y}px` }"
|
|
|
+ @click.stop="selectNode(node)"
|
|
|
+ @mousedown.stop="startDrag(node, $event)"
|
|
|
>
|
|
|
- <div class="node-order">{{ index + 1 }}</div>
|
|
|
- <el-select v-model="node.node_type" style="width: 150px">
|
|
|
- <el-option v-for="item in nodeTypes" :key="item.value" :label="item.label" :value="item.value" />
|
|
|
- </el-select>
|
|
|
- <el-input v-model="node.title" placeholder="节点标题" style="width: 190px" />
|
|
|
- <el-input-number v-model="node.screen_id" :min="1" placeholder="界面 ID" />
|
|
|
- <el-input
|
|
|
- v-model="node.configText"
|
|
|
- type="textarea"
|
|
|
- :rows="2"
|
|
|
- placeholder="节点 JSON 配置"
|
|
|
- class="node-config"
|
|
|
- />
|
|
|
- <el-button type="danger" @click="form.nodes.splice(index, 1)">删除</el-button>
|
|
|
+ <div class="workflow-node-type">{{ nodeTypeLabel(node.node_type) }}</div>
|
|
|
+ <div class="workflow-node-title">{{ node.title || nodeTypeLabel(node.node_type) }}</div>
|
|
|
+ <div class="workflow-node-meta">ID: {{ node.screen_id || '-' }}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ </main>
|
|
|
|
|
|
- <template #footer>
|
|
|
- <el-button @click="dialog = false">取消</el-button>
|
|
|
- <el-button type="primary" @click="save">保存</el-button>
|
|
|
+ <aside class="workflow-inspector panel">
|
|
|
+ <div class="section-title">节点属性</div>
|
|
|
+ <template v-if="selectedNode">
|
|
|
+ <el-form label-width="86px">
|
|
|
+ <el-form-item label="类型">
|
|
|
+ <el-select v-model="selectedNode.node_type">
|
|
|
+ <el-option v-for="item in nodeTypes" :key="item.value" :label="item.label" :value="item.value" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="标题">
|
|
|
+ <el-input v-model="selectedNode.title" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="界面 ID">
|
|
|
+ <el-input-number v-model="selectedNode.screen_id" :min="1" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="配置">
|
|
|
+ <el-input v-model="selectedNode.configText" type="textarea" :rows="8" class="node-config" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="下游">
|
|
|
+ <el-tag v-for="key in selectedNode.next_node_keys" :key="key" closable @close="disconnect(key)">
|
|
|
+ {{ nodeTitle(key) }}
|
|
|
+ </el-tag>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
</template>
|
|
|
- </el-dialog>
|
|
|
+ <div v-else class="muted">点击画布中的节点进行编辑。开启连接节点后,依次点击源节点和目标节点建立连线。</div>
|
|
|
+
|
|
|
+ <div class="section-title">执行结果</div>
|
|
|
+ <pre class="workflow-run-output">{{ runOutput || '暂无执行结果' }}</pre>
|
|
|
+ </aside>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { onMounted, reactive, ref } from 'vue'
|
|
|
+import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import { api } from '../api'
|
|
|
|
|
|
const nodeTypes = [
|
|
|
- { label: '鼠标操作', value: 'mouse' },
|
|
|
- { label: '键盘操作', value: 'keyboard' },
|
|
|
- { label: '键盘输入', value: 'text_input' },
|
|
|
- { label: '启动程序', value: 'start_program' },
|
|
|
- { label: '关闭程序', value: 'close_programs' },
|
|
|
+ { label: '鼠标操作', value: 'mouse', config: { x: 0, y: 0, mouse_action: 'click' } },
|
|
|
+ { label: '键盘操作', value: 'keyboard', config: { keys: ['ctrl', 's'] } },
|
|
|
+ { label: '键盘输入', value: 'text_input', config: { text: '' } },
|
|
|
+ { label: '启动程序', value: 'start_program', config: { command: 'msedge' } },
|
|
|
+ { label: '关闭程序', value: 'close_programs', config: {} },
|
|
|
]
|
|
|
|
|
|
const workflows = ref({ items: [] })
|
|
|
-const dialog = ref(false)
|
|
|
-const newNodeType = ref('mouse')
|
|
|
-const dragIndex = ref(null)
|
|
|
+const canvasRef = ref(null)
|
|
|
+const selectedNodeKey = ref(null)
|
|
|
+const connectMode = ref(false)
|
|
|
+const connectSourceKey = ref(null)
|
|
|
+const running = ref(false)
|
|
|
+const runOutput = ref('')
|
|
|
const form = reactive({
|
|
|
id: null,
|
|
|
name: '',
|
|
|
@@ -99,6 +131,26 @@ const form = reactive({
|
|
|
nodes: [],
|
|
|
})
|
|
|
|
|
|
+const selectedNode = computed(() => form.nodes.find((node) => node.node_key === selectedNodeKey.value))
|
|
|
+const connectionLines = computed(() => {
|
|
|
+ const lines = []
|
|
|
+ const map = new Map(form.nodes.map((node) => [node.node_key, node]))
|
|
|
+ for (const node of form.nodes) {
|
|
|
+ for (const nextKey of node.next_node_keys || []) {
|
|
|
+ const target = map.get(nextKey)
|
|
|
+ if (!target) continue
|
|
|
+ lines.push({
|
|
|
+ key: `${node.node_key}-${nextKey}`,
|
|
|
+ x1: node.position_x + 110,
|
|
|
+ y1: node.position_y + 42,
|
|
|
+ x2: target.position_x,
|
|
|
+ y2: target.position_y + 42,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return lines
|
|
|
+})
|
|
|
+
|
|
|
async function load() {
|
|
|
const { data } = await api.get('/api/automation/workflows')
|
|
|
workflows.value = data
|
|
|
@@ -106,15 +158,15 @@ async function load() {
|
|
|
|
|
|
function resetForm() {
|
|
|
form.id = null
|
|
|
- form.name = ''
|
|
|
+ form.name = `空工作流 ${new Date().toLocaleString()}`
|
|
|
form.description = ''
|
|
|
form.nodes = []
|
|
|
+ selectedNodeKey.value = null
|
|
|
+ runOutput.value = ''
|
|
|
}
|
|
|
|
|
|
function createEmpty() {
|
|
|
resetForm()
|
|
|
- form.name = `空工作流 ${new Date().toLocaleString()}`
|
|
|
- dialog.value = true
|
|
|
}
|
|
|
|
|
|
async function openEdit(row) {
|
|
|
@@ -122,29 +174,125 @@ async function openEdit(row) {
|
|
|
form.id = data.id
|
|
|
form.name = data.name
|
|
|
form.description = data.description || ''
|
|
|
- form.nodes = (data.nodes || []).map((node) => ({
|
|
|
+ form.nodes = (data.nodes || []).map((node, index) => normalizeNode(node, index))
|
|
|
+ selectedNodeKey.value = form.nodes[0]?.node_key || null
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeNode(node, index) {
|
|
|
+ const key = node.node_key || `node_${Date.now()}_${index}`
|
|
|
+ return {
|
|
|
+ node_key: key,
|
|
|
node_type: node.node_type,
|
|
|
- screen_id: node.screen_id,
|
|
|
- title: node.title || '',
|
|
|
- configText: JSON.stringify(node.config || {}, null, 2),
|
|
|
- }))
|
|
|
- dialog.value = true
|
|
|
+ screen_id: node.screen_id || null,
|
|
|
+ title: node.title || nodeTypeLabel(node.node_type),
|
|
|
+ position_x: node.position_x ?? 80 + index * 36,
|
|
|
+ position_y: node.position_y ?? 80 + index * 36,
|
|
|
+ next_node_keys: node.next_node_keys || [],
|
|
|
+ configText: JSON.stringify(node.config || defaultConfig(node.node_type), null, 2),
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-function addNode() {
|
|
|
- form.nodes.push({
|
|
|
- node_type: newNodeType.value,
|
|
|
+function addNode(type) {
|
|
|
+ const index = form.nodes.length
|
|
|
+ const node = {
|
|
|
+ node_key: `node_${Date.now()}_${index}`,
|
|
|
+ node_type: type,
|
|
|
screen_id: null,
|
|
|
- title: nodeTypes.find((item) => item.value === newNodeType.value)?.label || newNodeType.value,
|
|
|
- configText: '{}',
|
|
|
- })
|
|
|
+ title: nodeTypeLabel(type),
|
|
|
+ position_x: 90 + index * 32,
|
|
|
+ position_y: 90 + index * 32,
|
|
|
+ next_node_keys: [],
|
|
|
+ configText: JSON.stringify(defaultConfig(type), null, 2),
|
|
|
+ }
|
|
|
+ form.nodes.push(node)
|
|
|
+ selectedNodeKey.value = node.node_key
|
|
|
}
|
|
|
|
|
|
-function dropNode(targetIndex) {
|
|
|
- if (dragIndex.value === null || dragIndex.value === targetIndex) return
|
|
|
- const [node] = form.nodes.splice(dragIndex.value, 1)
|
|
|
- form.nodes.splice(targetIndex, 0, node)
|
|
|
- dragIndex.value = null
|
|
|
+function defaultConfig(type) {
|
|
|
+ return nodeTypes.find((item) => item.value === type)?.config || {}
|
|
|
+}
|
|
|
+
|
|
|
+function nodeTypeLabel(type) {
|
|
|
+ return nodeTypes.find((item) => item.value === type)?.label || type
|
|
|
+}
|
|
|
+
|
|
|
+function nodeTitle(key) {
|
|
|
+ const node = form.nodes.find((item) => item.node_key === key)
|
|
|
+ return node?.title || key
|
|
|
+}
|
|
|
+
|
|
|
+function selectNode(node) {
|
|
|
+ if (connectMode.value) {
|
|
|
+ if (!connectSourceKey.value) {
|
|
|
+ connectSourceKey.value = node.node_key
|
|
|
+ selectedNodeKey.value = node.node_key
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (connectSourceKey.value !== node.node_key) {
|
|
|
+ const source = form.nodes.find((item) => item.node_key === connectSourceKey.value)
|
|
|
+ if (source && !source.next_node_keys.includes(node.node_key)) source.next_node_keys.push(node.node_key)
|
|
|
+ }
|
|
|
+ connectSourceKey.value = null
|
|
|
+ connectMode.value = false
|
|
|
+ }
|
|
|
+ selectedNodeKey.value = node.node_key
|
|
|
+}
|
|
|
+
|
|
|
+function toggleConnectMode() {
|
|
|
+ connectMode.value = !connectMode.value
|
|
|
+ connectSourceKey.value = null
|
|
|
+}
|
|
|
+
|
|
|
+function disconnect(key) {
|
|
|
+ if (!selectedNode.value) return
|
|
|
+ selectedNode.value.next_node_keys = selectedNode.value.next_node_keys.filter((item) => item !== key)
|
|
|
+}
|
|
|
+
|
|
|
+function deleteSelectedNode() {
|
|
|
+ if (!selectedNodeKey.value) return
|
|
|
+ form.nodes = form.nodes.filter((node) => node.node_key !== selectedNodeKey.value)
|
|
|
+ for (const node of form.nodes) {
|
|
|
+ node.next_node_keys = node.next_node_keys.filter((key) => key !== selectedNodeKey.value)
|
|
|
+ }
|
|
|
+ selectedNodeKey.value = null
|
|
|
+}
|
|
|
+
|
|
|
+function startDrag(node, event) {
|
|
|
+ const startX = event.clientX
|
|
|
+ const startY = event.clientY
|
|
|
+ const originX = node.position_x
|
|
|
+ const originY = node.position_y
|
|
|
+ const move = (moveEvent) => {
|
|
|
+ node.position_x = Math.max(0, originX + moveEvent.clientX - startX)
|
|
|
+ node.position_y = Math.max(0, originY + moveEvent.clientY - startY)
|
|
|
+ }
|
|
|
+ const up = () => {
|
|
|
+ window.removeEventListener('mousemove', move)
|
|
|
+ window.removeEventListener('mouseup', up)
|
|
|
+ }
|
|
|
+ window.addEventListener('mousemove', move)
|
|
|
+ window.addEventListener('mouseup', up)
|
|
|
+}
|
|
|
+
|
|
|
+function buildPayloadNodes() {
|
|
|
+ return form.nodes.map((node) => {
|
|
|
+ let config
|
|
|
+ try {
|
|
|
+ config = JSON.parse(node.configText || '{}')
|
|
|
+ } catch {
|
|
|
+ throw new Error(`节点“${node.title}”配置不是合法 JSON`)
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ node_key: node.node_key,
|
|
|
+ node_type: node.node_type,
|
|
|
+ screen_id: node.screen_id || null,
|
|
|
+ title: node.title,
|
|
|
+ position_x: node.position_x,
|
|
|
+ position_y: node.position_y,
|
|
|
+ next_node_keys: node.next_node_keys,
|
|
|
+ config,
|
|
|
+ }
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
async function save() {
|
|
|
@@ -154,31 +302,45 @@ async function save() {
|
|
|
}
|
|
|
let nodes
|
|
|
try {
|
|
|
- nodes = form.nodes.map((node) => ({
|
|
|
- node_type: node.node_type,
|
|
|
- screen_id: node.screen_id || null,
|
|
|
- title: node.title,
|
|
|
- config: JSON.parse(node.configText || '{}'),
|
|
|
- }))
|
|
|
- } catch {
|
|
|
- ElMessage.error('节点配置不是合法 JSON')
|
|
|
+ nodes = buildPayloadNodes()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error.message)
|
|
|
return
|
|
|
}
|
|
|
const payload = { name: form.name.trim(), description: form.description, nodes }
|
|
|
- if (form.id) await api.put(`/api/automation/workflows/${form.id}`, payload)
|
|
|
- else await api.post('/api/automation/workflows', payload)
|
|
|
- dialog.value = false
|
|
|
+ const { data } = form.id
|
|
|
+ ? await api.put(`/api/automation/workflows/${form.id}`, payload)
|
|
|
+ : await api.post('/api/automation/workflows', payload)
|
|
|
+ form.id = data.id
|
|
|
ElMessage.success('已保存')
|
|
|
await load()
|
|
|
}
|
|
|
|
|
|
+async function runWorkflow() {
|
|
|
+ if (!form.id) return
|
|
|
+ running.value = true
|
|
|
+ try {
|
|
|
+ const { data } = await api.post(`/api/automation/workflows/${form.id}/run`, {})
|
|
|
+ runOutput.value = JSON.stringify(data, null, 2)
|
|
|
+ ElMessage[data.status === 'SUCCESS' ? 'success' : 'warning'](data.status === 'SUCCESS' ? '工作流执行完成' : '工作流执行中止')
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error.response?.data?.detail || '执行工作流失败')
|
|
|
+ } finally {
|
|
|
+ running.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
async function remove(row) {
|
|
|
await ElMessageBox.confirm(`确认删除工作流“${row.name}”?`, '删除工作流', { type: 'warning' })
|
|
|
await api.delete(`/api/automation/workflows/${row.id}`)
|
|
|
+ if (form.id === row.id) resetForm()
|
|
|
ElMessage.success('已删除')
|
|
|
await load()
|
|
|
}
|
|
|
|
|
|
defineExpose({ load })
|
|
|
-onMounted(load)
|
|
|
+onMounted(async () => {
|
|
|
+ await load()
|
|
|
+ resetForm()
|
|
|
+})
|
|
|
</script>
|