AutomationWorkflowEditorPage.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. <template>
  2. <div class="workflow-editor-page">
  3. <header class="workflow-editor-topbar">
  4. <div class="workflow-editor-title">
  5. <el-button @click="$emit('back')">返回</el-button>
  6. <el-input v-model="workflowName" placeholder="工作流名称" class="workflow-name-input" />
  7. <el-input v-model="workflowKey" placeholder="workflow key" class="workflow-key-input" />
  8. <el-input v-model="workflowDescription" placeholder="描述" class="workflow-desc-input" />
  9. </div>
  10. <div class="filters">
  11. <el-button type="primary" :loading="saving" @click="save">保存</el-button>
  12. <el-button type="success" :disabled="!workflowId" :loading="running" @click="runWorkflow">执行</el-button>
  13. <el-button @click="fitView()">适配画布</el-button>
  14. </div>
  15. </header>
  16. <div class="workflow-editor-body">
  17. <aside class="workflow-node-palette">
  18. <div class="section-title">节点库</div>
  19. <el-collapse v-model="openedCategories">
  20. <el-collapse-item v-for="group in groupedNodeDefinitions" :key="group.category" :title="categoryLabel(group.category)" :name="group.category">
  21. <button v-for="definition in group.items" :key="definition.type" class="palette-node" type="button" @click="addNode(definition)">
  22. <span>{{ definition.label }}</span>
  23. <small>{{ definition.type }}</small>
  24. </button>
  25. </el-collapse-item>
  26. </el-collapse>
  27. </aside>
  28. <main class="workflow-flow-wrap">
  29. <VueFlow
  30. v-model:nodes="flowNodes"
  31. v-model:edges="flowEdges"
  32. class="workflow-flow"
  33. :default-viewport="{ zoom: 0.9 }"
  34. :min-zoom="0.2"
  35. :max-zoom="2"
  36. fit-view-on-init
  37. @connect="onConnect"
  38. @node-click="onNodeClick"
  39. @pane-click="selectedNodeId = null"
  40. >
  41. <Background pattern-color="#cbd5e1" :gap="24" />
  42. <Controls />
  43. </VueFlow>
  44. </main>
  45. <aside class="workflow-inspector-panel">
  46. <div class="section-title">工作流变量</div>
  47. <div class="workflow-variable-list">
  48. <div v-for="(variable, name) in workflowVariables" :key="name" class="workflow-variable-row">
  49. <div class="workflow-variable-head">
  50. <strong>{{ name }}</strong>
  51. <el-button size="small" type="danger" @click="deleteVariable(name)">删除</el-button>
  52. </div>
  53. <div class="workflow-variable-fields">
  54. <el-select v-model="ensureVariableObject(name).type" size="small">
  55. <el-option label="文本" value="string" />
  56. <el-option label="数字" value="number" />
  57. <el-option label="布尔" value="boolean" />
  58. </el-select>
  59. <component
  60. :is="variableDefaultComponent(ensureVariableObject(name))"
  61. v-model="ensureVariableObject(name).default"
  62. v-bind="variableDefaultProps(ensureVariableObject(name))"
  63. placeholder="默认值"
  64. />
  65. </div>
  66. <el-input v-model="ensureVariableObject(name).description" size="small" placeholder="说明" />
  67. </div>
  68. <el-button class="add-variable-button" @click="addVariable">新增变量</el-button>
  69. </div>
  70. <template v-if="selectedNode">
  71. <div class="section-title">节点属性</div>
  72. <el-form label-width="86px" class="workflow-inspector-form">
  73. <el-form-item label="标题">
  74. <el-input v-model="selectedNode.data.title" @input="syncNodeLabel" />
  75. </el-form-item>
  76. <el-form-item label="类型">
  77. <el-tag>{{ selectedNode.data.nodeType }}</el-tag>
  78. </el-form-item>
  79. </el-form>
  80. <div class="section-title">参数</div>
  81. <el-form label-width="92px" class="workflow-inspector-form">
  82. <el-form-item v-for="(field, key) in selectedDefinition?.params || {}" :key="key" :label="field.label || key">
  83. <component
  84. :is="fieldComponent(field)"
  85. v-model="selectedNode.data.params[key]"
  86. v-bind="fieldProps(field)"
  87. class="workflow-field"
  88. >
  89. <el-option v-for="option in field.options || []" :key="option" :label="option" :value="option" />
  90. </component>
  91. </el-form-item>
  92. <div v-if="!Object.keys(selectedDefinition?.params || {}).length" class="muted">此节点没有固定参数。</div>
  93. </el-form>
  94. <div class="section-title">输入</div>
  95. <div v-for="(field, key) in selectedDefinition?.inputs || {}" :key="key" class="input-binding-row">
  96. <div class="input-binding-name">{{ field.label || key }}</div>
  97. <el-select v-model="inputBinding(key).source" class="input-source">
  98. <el-option label="固定值" value="literal" />
  99. <el-option label="变量" value="variable" />
  100. <el-option label="节点输出" value="node_output" />
  101. <el-option label="运行时" value="runtime" />
  102. </el-select>
  103. <el-input v-if="inputBinding(key).source === 'literal'" v-model="inputBinding(key).value" placeholder="值" />
  104. <el-input v-if="inputBinding(key).source === 'variable'" v-model="inputBinding(key).name" placeholder="变量名" />
  105. <template v-if="inputBinding(key).source === 'node_output'">
  106. <el-select v-model="inputBinding(key).node_id" placeholder="节点">
  107. <el-option v-for="node in flowNodes.filter((item) => item.id !== selectedNode.id)" :key="node.id" :label="node.data.title" :value="node.id" />
  108. </el-select>
  109. <el-input v-model="inputBinding(key).output" placeholder="输出名" />
  110. </template>
  111. <el-input v-if="inputBinding(key).source === 'runtime'" v-model="inputBinding(key).name" placeholder="运行时键名" />
  112. </div>
  113. <div v-if="!Object.keys(selectedDefinition?.inputs || {}).length" class="muted">此节点不需要输入。</div>
  114. <div class="section-title">连线</div>
  115. <el-table :data="selectedEdges" size="small" border>
  116. <el-table-column prop="label" label="连线" min-width="130" />
  117. <el-table-column label="类型" width="100">
  118. <template #default="{ row }">
  119. <el-select v-model="row.data.kind" size="small" @change="syncEdgeLabel(row)">
  120. <el-option label="控制流" value="control" />
  121. <el-option label="数据流" value="data" />
  122. </el-select>
  123. </template>
  124. </el-table-column>
  125. <el-table-column label="操作" width="74">
  126. <template #default="{ row }">
  127. <el-button size="small" type="danger" @click="deleteEdge(row.id)">删</el-button>
  128. </template>
  129. </el-table-column>
  130. </el-table>
  131. <div class="filters inspector-actions">
  132. <el-button type="danger" @click="deleteSelectedNode">删除节点</el-button>
  133. </div>
  134. </template>
  135. <div v-else class="muted">从左侧添加节点,或点击画布中的节点编辑参数和输入绑定。</div>
  136. <div class="section-title">执行结果</div>
  137. <pre class="workflow-run-output">{{ runOutput || '暂无执行结果' }}</pre>
  138. </aside>
  139. </div>
  140. </div>
  141. </template>
  142. <script setup>
  143. import { computed, nextTick, onMounted, ref } from 'vue'
  144. import { VueFlow, useVueFlow } from '@vue-flow/core'
  145. import { Background } from '@vue-flow/background'
  146. import { Controls } from '@vue-flow/controls'
  147. import '@vue-flow/core/dist/style.css'
  148. import '@vue-flow/core/dist/theme-default.css'
  149. import '@vue-flow/controls/dist/style.css'
  150. import { ElMessage, ElMessageBox } from 'element-plus'
  151. import { api } from '../api'
  152. const props = defineProps({
  153. workflowId: { type: Number, default: null },
  154. })
  155. const emit = defineEmits(['back', 'saved'])
  156. const { fitView } = useVueFlow()
  157. const nodeDefinitions = ref([])
  158. const flowNodes = ref([])
  159. const flowEdges = ref([])
  160. const workflowName = ref('')
  161. const workflowKey = ref('')
  162. const workflowDescription = ref('')
  163. const workflowVariables = ref({})
  164. const workflowSettings = ref({ max_steps: 100, default_timeout_ms: 30000, on_unhandled_error: 'pause_for_user' })
  165. const workflowId = ref(props.workflowId)
  166. const selectedNodeId = ref(null)
  167. const openedCategories = ref(['flow', 'mouse', 'keyboard'])
  168. const saving = ref(false)
  169. const running = ref(false)
  170. const runOutput = ref('')
  171. const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value))
  172. const selectedDefinition = computed(() => nodeDefinitions.value.find((item) => item.type === selectedNode.value?.data?.nodeType))
  173. const selectedEdges = computed(() => flowEdges.value.filter((edge) => edge.source === selectedNodeId.value || edge.target === selectedNodeId.value))
  174. const groupedNodeDefinitions = computed(() => {
  175. const groups = new Map()
  176. for (const item of nodeDefinitions.value) {
  177. if (!groups.has(item.category)) groups.set(item.category, [])
  178. groups.get(item.category).push(item)
  179. }
  180. return [...groups.entries()].map(([category, items]) => ({ category, items }))
  181. })
  182. function categoryLabel(category) {
  183. return {
  184. flow: '流程',
  185. mouse: '鼠标',
  186. keyboard: '键盘',
  187. text: '文本',
  188. program: '程序',
  189. screen: '屏幕',
  190. wait: '等待',
  191. human: '人工交互',
  192. }[category] || category
  193. }
  194. async function loadDefinitions() {
  195. const { data } = await api.get('/api/automation/workflow-nodes')
  196. nodeDefinitions.value = data.items || []
  197. }
  198. async function loadWorkflow() {
  199. if (!workflowId.value) {
  200. workflowName.value = `新工作流 ${new Date().toLocaleString()}`
  201. workflowKey.value = ''
  202. workflowDescription.value = ''
  203. workflowVariables.value = {}
  204. workflowSettings.value = { max_steps: 100, default_timeout_ms: 30000, on_unhandled_error: 'pause_for_user' }
  205. flowNodes.value = []
  206. flowEdges.value = []
  207. return
  208. }
  209. const { data } = await api.get(`/api/automation/workflows/${workflowId.value}`)
  210. workflowName.value = data.name
  211. workflowKey.value = data.workflow_key || ''
  212. workflowDescription.value = data.description || ''
  213. workflowVariables.value = data.variables || {}
  214. workflowSettings.value = data.settings || {}
  215. flowNodes.value = (data.nodes || []).map(workflowNodeToFlow)
  216. flowEdges.value = (data.edges || []).map(workflowEdgeToFlow)
  217. }
  218. function workflowNodeToFlow(node) {
  219. return {
  220. id: node.id,
  221. type: 'default',
  222. label: node.title || node.type,
  223. position: node.position || { x: 80, y: 80 },
  224. data: {
  225. title: node.title || node.type,
  226. nodeType: node.type,
  227. params: structuredClone(node.params || {}),
  228. inputs: structuredClone(node.inputs || {}),
  229. },
  230. }
  231. }
  232. function workflowEdgeToFlow(edge) {
  233. const kind = edge.kind || 'control'
  234. return {
  235. id: edge.id || `${edge.source}-${edge.target}-${Date.now()}`,
  236. source: edge.source,
  237. target: edge.target,
  238. sourceHandle: edge.source_port || null,
  239. targetHandle: edge.target_port || null,
  240. label: edgeLabel(kind),
  241. data: { kind },
  242. animated: kind === 'control',
  243. style: { stroke: kind === 'data' ? '#10b981' : '#2563eb' },
  244. }
  245. }
  246. function addNode(definition) {
  247. const index = flowNodes.value.length
  248. const node = {
  249. id: `node_${Date.now()}_${index}`,
  250. type: 'default',
  251. label: definition.label,
  252. position: { x: 120 + index * 40, y: 120 + index * 36 },
  253. data: {
  254. title: definition.label,
  255. nodeType: definition.type,
  256. params: defaultValues(definition.params || {}),
  257. inputs: {},
  258. },
  259. }
  260. flowNodes.value.push(node)
  261. selectedNodeId.value = node.id
  262. nextTick(() => fitView())
  263. }
  264. function defaultValues(fields) {
  265. const values = {}
  266. for (const [key, field] of Object.entries(fields)) {
  267. values[key] = structuredClone(field.default ?? defaultValueForType(field.type))
  268. }
  269. return values
  270. }
  271. function defaultValueForType(type) {
  272. if (type === 'number') return 0
  273. if (type === 'boolean') return false
  274. if (type === 'array') return []
  275. return ''
  276. }
  277. function onConnect(connection) {
  278. const kind = 'control'
  279. flowEdges.value.push({
  280. id: `edge_${Date.now()}`,
  281. ...connection,
  282. label: edgeLabel(kind),
  283. data: { kind },
  284. animated: true,
  285. style: { stroke: '#2563eb' },
  286. })
  287. }
  288. function onNodeClick(event) {
  289. selectedNodeId.value = event.node.id
  290. }
  291. function syncNodeLabel() {
  292. if (!selectedNode.value) return
  293. selectedNode.value.label = selectedNode.value.data.title
  294. }
  295. function syncEdgeLabel(edge) {
  296. edge.label = edgeLabel(edge.data.kind)
  297. edge.animated = edge.data.kind === 'control'
  298. edge.style = { stroke: edge.data.kind === 'data' ? '#10b981' : '#2563eb' }
  299. }
  300. function edgeLabel(kind) {
  301. return kind === 'data' ? '数据' : '控制'
  302. }
  303. function fieldComponent(field) {
  304. if (field.type === 'boolean') return 'el-switch'
  305. if (field.type === 'select') return 'el-select'
  306. if (field.type === 'number') return 'el-input-number'
  307. if (field.type === 'textarea') return 'el-input'
  308. return 'el-input'
  309. }
  310. function fieldProps(field) {
  311. if (field.type === 'textarea') return { type: 'textarea', rows: 4 }
  312. if (field.type === 'number') return { min: field.min, max: field.max }
  313. if (field.type === 'array') return { type: 'textarea', rows: 3, placeholder: 'JSON 数组或逗号分隔文本' }
  314. return {}
  315. }
  316. function inputBinding(key) {
  317. if (!selectedNode.value.data.inputs[key]) {
  318. selectedNode.value.data.inputs[key] = { source: 'literal', value: '' }
  319. }
  320. return selectedNode.value.data.inputs[key]
  321. }
  322. function ensureVariableObject(name) {
  323. if (!workflowVariables.value[name] || typeof workflowVariables.value[name] !== 'object') {
  324. workflowVariables.value[name] = {
  325. type: typeof workflowVariables.value[name],
  326. default: workflowVariables.value[name],
  327. description: '',
  328. }
  329. }
  330. workflowVariables.value[name].type ||= 'string'
  331. return workflowVariables.value[name]
  332. }
  333. function variableDefaultComponent(variable) {
  334. if (variable.type === 'number') return 'el-input-number'
  335. if (variable.type === 'boolean') return 'el-switch'
  336. return 'el-input'
  337. }
  338. function variableDefaultProps(variable) {
  339. if (variable.type === 'number') return { controlsPosition: 'right' }
  340. return {}
  341. }
  342. async function addVariable() {
  343. const { value } = await ElMessageBox.prompt('请输入变量名', '新增变量', {
  344. inputPattern: /^[A-Za-z_][A-Za-z0-9_]*$/,
  345. inputErrorMessage: '变量名只能使用字母、数字和下划线,且不能以数字开头',
  346. })
  347. if (workflowVariables.value[value]) {
  348. ElMessage.warning('变量已存在')
  349. return
  350. }
  351. workflowVariables.value[value] = { type: 'string', default: '', description: '' }
  352. }
  353. function deleteVariable(name) {
  354. delete workflowVariables.value[name]
  355. }
  356. function deleteEdge(edgeId) {
  357. flowEdges.value = flowEdges.value.filter((edge) => edge.id !== edgeId)
  358. }
  359. function deleteSelectedNode() {
  360. if (!selectedNodeId.value) return
  361. flowNodes.value = flowNodes.value.filter((node) => node.id !== selectedNodeId.value)
  362. flowEdges.value = flowEdges.value.filter((edge) => edge.source !== selectedNodeId.value && edge.target !== selectedNodeId.value)
  363. selectedNodeId.value = null
  364. }
  365. function buildPayload() {
  366. return {
  367. schema_version: 'workflow/v1',
  368. workflow_key: workflowKey.value.trim() || null,
  369. name: workflowName.value.trim(),
  370. description: workflowDescription.value,
  371. variables: workflowVariables.value,
  372. settings: workflowSettings.value,
  373. nodes: flowNodes.value.map((node) => ({
  374. id: node.id,
  375. type: node.data.nodeType,
  376. title: node.data.title,
  377. position: node.position,
  378. params: node.data.params || {},
  379. inputs: node.data.inputs || {},
  380. })),
  381. edges: flowEdges.value.map((edge) => ({
  382. id: edge.id,
  383. kind: edge.data?.kind || 'control',
  384. source: edge.source,
  385. source_port: edge.sourceHandle || (edge.data?.kind === 'data' ? 'value' : 'success'),
  386. target: edge.target,
  387. target_port: edge.targetHandle || (edge.data?.kind === 'data' ? 'value' : 'run'),
  388. })),
  389. }
  390. }
  391. async function save() {
  392. if (!workflowName.value.trim()) {
  393. ElMessage.warning('请输入工作流名称')
  394. return
  395. }
  396. saving.value = true
  397. try {
  398. const payload = buildPayload()
  399. const { data } = workflowId.value
  400. ? await api.put(`/api/automation/workflows/${workflowId.value}`, payload)
  401. : await api.post('/api/automation/workflows', payload)
  402. workflowId.value = data.id
  403. ElMessage.success('已保存')
  404. emit('saved', data.id)
  405. } finally {
  406. saving.value = false
  407. }
  408. }
  409. async function runWorkflow() {
  410. if (!workflowId.value) return
  411. running.value = true
  412. try {
  413. const { data } = await api.post(`/api/automation/workflows/${workflowId.value}/run`, {})
  414. runOutput.value = JSON.stringify(data, null, 2)
  415. ElMessage[data.status === 'SUCCESS' ? 'success' : 'warning'](data.status === 'SUCCESS' ? '工作流执行完成' : '工作流执行中止')
  416. } catch (error) {
  417. ElMessage.error(error.response?.data?.detail || '执行工作流失败')
  418. } finally {
  419. running.value = false
  420. }
  421. }
  422. onMounted(async () => {
  423. await loadDefinitions()
  424. await loadWorkflow()
  425. })
  426. </script>