AutomationWorkflowEditorPage.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  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. <em>{{ definition.description }}</em>
  25. </button>
  26. </el-collapse-item>
  27. </el-collapse>
  28. </aside>
  29. <main class="workflow-flow-wrap">
  30. <VueFlow
  31. v-model:nodes="flowNodes"
  32. v-model:edges="flowEdges"
  33. class="workflow-flow"
  34. :default-viewport="{ zoom: 0.9 }"
  35. :min-zoom="0.2"
  36. :max-zoom="2"
  37. fit-view-on-init
  38. @connect="onConnect"
  39. @node-click="onNodeClick"
  40. @pane-click="selectedNodeId = null"
  41. >
  42. <Background pattern-color="#cbd5e1" :gap="24" />
  43. <Controls />
  44. <template #node-default="{ id, data, selected }">
  45. <div class="workflow-card-node" :class="{ selected, collapsed: data.collapsed }">
  46. <div class="workflow-card-head">
  47. <div class="workflow-card-title-wrap">
  48. <div class="workflow-card-type">{{ nodeDefinition(data.nodeType)?.label || data.nodeType }}</div>
  49. <div class="workflow-card-title">{{ data.title }}</div>
  50. </div>
  51. <button class="workflow-card-toggle" type="button" @click.stop="toggleNodeCollapsed(id)">
  52. {{ data.collapsed ? '展开' : '折叠' }}
  53. </button>
  54. </div>
  55. <div class="workflow-card-desc">{{ nodeDefinition(data.nodeType)?.description || data.nodeType }}</div>
  56. <div v-if="!data.collapsed" class="workflow-card-body">
  57. <div class="workflow-card-section">
  58. <strong>参数</strong>
  59. <span v-if="!nodeParamEntries(data).length">无</span>
  60. <span v-for="item in nodeParamEntries(data)" :key="item.key" class="workflow-card-chip">
  61. {{ item.label }}={{ shortValue(item.value) }}
  62. </span>
  63. </div>
  64. <div class="workflow-card-section">
  65. <strong>输入</strong>
  66. <span v-if="!nodeInputEntries(data).length">无</span>
  67. <span v-for="item in nodeInputEntries(data)" :key="item.key" class="workflow-card-chip input">
  68. {{ item.label }}←{{ bindingSummary(item.binding) }}
  69. </span>
  70. </div>
  71. <div class="workflow-card-section">
  72. <strong>输出</strong>
  73. <span v-if="!nodeOutputEntries(data.nodeType).length">无</span>
  74. <span v-for="item in nodeOutputEntries(data.nodeType).slice(0, 4)" :key="item.key" class="workflow-card-chip output">
  75. {{ item.key }}:{{ item.type }}
  76. </span>
  77. <span v-if="nodeOutputEntries(data.nodeType).length > 4" class="workflow-card-more">
  78. +{{ nodeOutputEntries(data.nodeType).length - 4 }}
  79. </span>
  80. </div>
  81. </div>
  82. </div>
  83. </template>
  84. </VueFlow>
  85. </main>
  86. <aside class="workflow-inspector-panel">
  87. <div class="section-title">工作流变量</div>
  88. <div class="workflow-variable-list">
  89. <div v-for="(variable, name) in workflowVariables" :key="name" class="workflow-variable-row">
  90. <div class="workflow-variable-head">
  91. <strong>{{ name }}</strong>
  92. <el-button size="small" type="danger" @click="deleteVariable(name)">删除</el-button>
  93. </div>
  94. <div class="workflow-variable-fields">
  95. <el-select v-model="ensureVariableObject(name).type" size="small">
  96. <el-option label="文本" value="string" />
  97. <el-option label="数字" value="number" />
  98. <el-option label="布尔" value="boolean" />
  99. <el-option label="对象" value="object" />
  100. <el-option label="数组" value="array" />
  101. </el-select>
  102. <el-input
  103. v-if="['object', 'array'].includes(ensureVariableObject(name).type)"
  104. :model-value="variableJsonText(ensureVariableObject(name).default)"
  105. type="textarea"
  106. :rows="5"
  107. placeholder="JSON"
  108. @change="updateVariableJson(name, $event)"
  109. />
  110. <component
  111. v-else
  112. :is="variableDefaultComponent(ensureVariableObject(name))"
  113. v-model="ensureVariableObject(name).default"
  114. v-bind="variableDefaultProps(ensureVariableObject(name))"
  115. placeholder="默认值"
  116. />
  117. </div>
  118. <el-input v-model="ensureVariableObject(name).description" size="small" placeholder="说明" />
  119. </div>
  120. <el-button class="add-variable-button" @click="addVariable">新增变量</el-button>
  121. </div>
  122. <template v-if="selectedNode">
  123. <div class="section-title">节点属性</div>
  124. <el-form label-width="86px" class="workflow-inspector-form">
  125. <el-form-item label="标题">
  126. <el-input v-model="selectedNode.data.title" @input="syncNodeLabel" />
  127. </el-form-item>
  128. <el-form-item label="类型">
  129. <el-tag>{{ selectedNode.data.nodeType }}</el-tag>
  130. </el-form-item>
  131. <el-form-item label="说明">
  132. <div class="node-description">{{ selectedDefinition?.description || '暂无说明' }}</div>
  133. </el-form-item>
  134. </el-form>
  135. <div class="section-title">参数</div>
  136. <el-form label-width="92px" class="workflow-inspector-form">
  137. <el-form-item v-for="(field, key) in selectedDefinition?.params || {}" :key="key" :label="field.label || key">
  138. <component
  139. :is="fieldComponent(field)"
  140. v-model="selectedNode.data.params[key]"
  141. v-bind="fieldProps(field)"
  142. class="workflow-field"
  143. >
  144. <el-option v-for="option in field.options || []" :key="option" :label="option" :value="option" />
  145. </component>
  146. </el-form-item>
  147. <div v-if="!Object.keys(selectedDefinition?.params || {}).length" class="muted">此节点没有固定参数。</div>
  148. </el-form>
  149. <div class="section-title">输入</div>
  150. <div v-for="(field, key) in selectedDefinition?.inputs || {}" :key="key" class="input-binding-row">
  151. <div class="input-binding-name">{{ field.label || key }}</div>
  152. <el-select v-model="inputBinding(key).source" class="input-source">
  153. <el-option label="固定值" value="literal" />
  154. <el-option label="变量" value="variable" />
  155. <el-option label="节点输出" value="node_output" />
  156. <el-option label="运行时" value="runtime" />
  157. </el-select>
  158. <el-input v-if="inputBinding(key).source === 'literal'" v-model="inputBinding(key).value" placeholder="值" />
  159. <el-input v-if="inputBinding(key).source === 'variable'" v-model="inputBinding(key).name" placeholder="变量名" />
  160. <template v-if="inputBinding(key).source === 'node_output'">
  161. <el-select v-model="inputBinding(key).node_id" placeholder="节点">
  162. <el-option v-for="node in flowNodes.filter((item) => item.id !== selectedNode.id)" :key="node.id" :label="node.data.title" :value="node.id" />
  163. </el-select>
  164. <el-select v-model="inputBinding(key).output" placeholder="输出">
  165. <el-option v-for="output in outputEntriesForNode(inputBinding(key).node_id)" :key="output.key" :label="`${output.label} (${output.key})`" :value="output.key" />
  166. </el-select>
  167. </template>
  168. <el-input v-if="inputBinding(key).source === 'runtime'" v-model="inputBinding(key).name" placeholder="运行时键名" />
  169. <div v-if="field.description" class="field-help">{{ field.description }}</div>
  170. </div>
  171. <div v-if="!Object.keys(selectedDefinition?.inputs || {}).length" class="muted">此节点不需要输入。</div>
  172. <div class="section-title">输出数据结构</div>
  173. <el-table :data="nodeOutputEntries(selectedNode.data.nodeType)" size="small" border empty-text="此节点没有声明输出">
  174. <el-table-column prop="key" label="字段" width="110" />
  175. <el-table-column prop="label" label="含义" min-width="130" />
  176. <el-table-column prop="type" label="类型" width="90" />
  177. <el-table-column prop="description" label="说明" min-width="150" show-overflow-tooltip />
  178. </el-table>
  179. <div class="section-title">相关连线</div>
  180. <el-table :data="selectedEdges" size="small" border>
  181. <el-table-column label="来源" min-width="150">
  182. <template #default="{ row }">
  183. {{ edgeEndpointLabel(row.source, row.sourceHandle || edgeSourcePort(row)) }}
  184. </template>
  185. </el-table-column>
  186. <el-table-column label="目标" min-width="150">
  187. <template #default="{ row }">
  188. {{ edgeEndpointLabel(row.target, row.targetHandle || edgeTargetPort(row)) }}
  189. </template>
  190. </el-table-column>
  191. <el-table-column label="类型" width="100">
  192. <template #default="{ row }">
  193. <el-select v-model="row.data.kind" size="small" @change="syncEdgeLabel(row)">
  194. <el-option label="控制流" value="control" />
  195. <el-option label="数据流" value="data" />
  196. </el-select>
  197. </template>
  198. </el-table-column>
  199. <el-table-column label="操作" width="74">
  200. <template #default="{ row }">
  201. <el-button size="small" type="danger" @click="deleteEdge(row.id)">删</el-button>
  202. </template>
  203. </el-table-column>
  204. </el-table>
  205. <div class="filters inspector-actions">
  206. <el-button type="danger" @click="deleteSelectedNode">删除节点</el-button>
  207. </div>
  208. </template>
  209. <div v-else class="muted">从左侧添加节点,或点击画布中的节点编辑参数和输入绑定。</div>
  210. <div class="section-title">全部连线</div>
  211. <el-table :data="flowEdges" size="small" border empty-text="暂无连线">
  212. <el-table-column label="连线" min-width="230">
  213. <template #default="{ row }">
  214. {{ readableEdgeLabel(row) }}
  215. </template>
  216. </el-table-column>
  217. <el-table-column label="类型" width="82">
  218. <template #default="{ row }">
  219. <el-tag size="small" :type="row.data?.kind === 'data' ? 'success' : 'primary'">
  220. {{ edgeKindLabel(row.data?.kind) }}
  221. </el-tag>
  222. </template>
  223. </el-table-column>
  224. </el-table>
  225. <div class="section-title">执行结果</div>
  226. <pre class="workflow-run-output">{{ runOutput || '暂无执行结果' }}</pre>
  227. </aside>
  228. </div>
  229. </div>
  230. </template>
  231. <script setup>
  232. import { computed, nextTick, onMounted, ref } from 'vue'
  233. import { VueFlow, useVueFlow } from '@vue-flow/core'
  234. import { Background } from '@vue-flow/background'
  235. import { Controls } from '@vue-flow/controls'
  236. import '@vue-flow/core/dist/style.css'
  237. import '@vue-flow/core/dist/theme-default.css'
  238. import '@vue-flow/controls/dist/style.css'
  239. import { ElMessage, ElMessageBox } from 'element-plus'
  240. import { api } from '../api'
  241. const props = defineProps({
  242. workflowId: { type: Number, default: null },
  243. })
  244. const emit = defineEmits(['back', 'saved', 'task-created'])
  245. const { fitView } = useVueFlow()
  246. const nodeDefinitions = ref([])
  247. const flowNodes = ref([])
  248. const flowEdges = ref([])
  249. const workflowName = ref('')
  250. const workflowKey = ref('')
  251. const workflowDescription = ref('')
  252. const workflowVariables = ref({})
  253. const workflowSettings = ref({ max_steps: 100, default_timeout_ms: 30000, on_unhandled_error: 'pause_for_user' })
  254. const workflowId = ref(props.workflowId)
  255. const selectedNodeId = ref(null)
  256. const openedCategories = ref(['flow', 'browser', 'vision', 'media', 'mouse', 'keyboard'])
  257. const saving = ref(false)
  258. const running = ref(false)
  259. const runOutput = ref('')
  260. const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value))
  261. const selectedDefinition = computed(() => nodeDefinitions.value.find((item) => item.type === selectedNode.value?.data?.nodeType))
  262. const selectedEdges = computed(() => flowEdges.value.filter((edge) => edge.source === selectedNodeId.value || edge.target === selectedNodeId.value))
  263. const groupedNodeDefinitions = computed(() => {
  264. const groups = new Map()
  265. for (const item of nodeDefinitions.value) {
  266. if (!groups.has(item.category)) groups.set(item.category, [])
  267. groups.get(item.category).push(item)
  268. }
  269. return [...groups.entries()].map(([category, items]) => ({ category, items }))
  270. })
  271. function categoryLabel(category) {
  272. return {
  273. browser: '浏览器',
  274. flow: '流程',
  275. human: '人工交互',
  276. keyboard: '键盘',
  277. media: '媒体',
  278. mouse: '鼠标',
  279. program: '程序',
  280. research: '研究',
  281. screen: '屏幕',
  282. text: '文本',
  283. vision: '视觉 AI',
  284. wait: '等待',
  285. }[category] || category
  286. }
  287. function nodeDefinition(nodeType) {
  288. return nodeDefinitions.value.find((item) => item.type === nodeType)
  289. }
  290. async function loadDefinitions() {
  291. const { data } = await api.get('/api/automation/workflow-nodes')
  292. nodeDefinitions.value = data.items || []
  293. }
  294. async function loadWorkflow() {
  295. if (!workflowId.value) {
  296. workflowName.value = `新工作流 ${new Date().toLocaleString()}`
  297. workflowKey.value = ''
  298. workflowDescription.value = ''
  299. workflowVariables.value = {}
  300. workflowSettings.value = { max_steps: 100, default_timeout_ms: 30000, on_unhandled_error: 'pause_for_user' }
  301. flowNodes.value = []
  302. flowEdges.value = []
  303. return
  304. }
  305. const { data } = await api.get(`/api/automation/workflows/${workflowId.value}`)
  306. workflowName.value = data.name
  307. workflowKey.value = data.workflow_key || ''
  308. workflowDescription.value = data.description || ''
  309. workflowVariables.value = data.variables || {}
  310. workflowSettings.value = data.settings || {}
  311. flowNodes.value = (data.nodes || []).map(workflowNodeToFlow)
  312. flowEdges.value = (data.edges || []).map(workflowEdgeToFlow)
  313. refreshEdgeLabels()
  314. }
  315. function workflowNodeToFlow(node) {
  316. return {
  317. id: node.id,
  318. type: 'default',
  319. label: node.title || node.type,
  320. position: node.position || { x: 80, y: 80 },
  321. data: {
  322. title: node.title || node.type,
  323. nodeType: node.type,
  324. params: structuredClone(node.params || {}),
  325. inputs: structuredClone(node.inputs || {}),
  326. collapsed: false,
  327. },
  328. }
  329. }
  330. function workflowEdgeToFlow(edge) {
  331. const kind = edge.kind || 'control'
  332. return {
  333. id: edge.id || `${edge.source}-${edge.target}-${Date.now()}`,
  334. source: edge.source,
  335. target: edge.target,
  336. sourceHandle: edge.source_port || null,
  337. targetHandle: edge.target_port || null,
  338. label: '',
  339. data: { kind },
  340. animated: kind === 'control',
  341. style: { stroke: kind === 'data' ? '#10b981' : '#2563eb' },
  342. }
  343. }
  344. function addNode(definition) {
  345. const index = flowNodes.value.length
  346. const node = {
  347. id: `node_${Date.now()}_${index}`,
  348. type: 'default',
  349. label: definition.label,
  350. position: { x: 120 + index * 40, y: 120 + index * 36 },
  351. data: {
  352. title: definition.label,
  353. nodeType: definition.type,
  354. params: defaultValues(definition.params || {}),
  355. inputs: {},
  356. collapsed: false,
  357. },
  358. }
  359. flowNodes.value.push(node)
  360. selectedNodeId.value = node.id
  361. nextTick(() => fitView())
  362. }
  363. function defaultValues(fields) {
  364. const values = {}
  365. for (const [key, field] of Object.entries(fields)) {
  366. values[key] = structuredClone(field.default ?? defaultValueForType(field.type))
  367. }
  368. return values
  369. }
  370. function defaultValueForType(type) {
  371. if (type === 'number') return 0
  372. if (type === 'boolean') return false
  373. if (type === 'array') return []
  374. return ''
  375. }
  376. function onConnect(connection) {
  377. const kind = 'control'
  378. flowEdges.value.push({
  379. id: `edge_${Date.now()}`,
  380. ...connection,
  381. label: '',
  382. data: { kind },
  383. animated: true,
  384. style: { stroke: '#2563eb' },
  385. })
  386. refreshEdgeLabels()
  387. }
  388. function onNodeClick(event) {
  389. selectedNodeId.value = event.node.id
  390. }
  391. function syncNodeLabel() {
  392. if (!selectedNode.value) return
  393. selectedNode.value.label = selectedNode.value.data.title
  394. refreshEdgeLabels()
  395. }
  396. function syncEdgeLabel(edge) {
  397. edge.label = readableEdgeLabel(edge)
  398. edge.animated = edge.data.kind === 'control'
  399. edge.style = { stroke: edge.data.kind === 'data' ? '#10b981' : '#2563eb' }
  400. }
  401. function edgeLabel(kind) {
  402. return kind === 'data' ? '数据' : '控制'
  403. }
  404. function edgeKindLabel(kind) {
  405. return kind === 'data' ? '数据' : '控制'
  406. }
  407. function edgeSourcePort(edge) {
  408. return edge.sourceHandle || (edge.data?.kind === 'data' ? 'value' : 'success')
  409. }
  410. function edgeTargetPort(edge) {
  411. return edge.targetHandle || (edge.data?.kind === 'data' ? 'value' : 'run')
  412. }
  413. function edgeEndpointLabel(nodeId, port) {
  414. const node = flowNodes.value.find((item) => item.id === nodeId)
  415. const title = node?.data?.title || nodeId || '未知节点'
  416. return port ? `${title}.${port}` : title
  417. }
  418. function readableEdgeLabel(edge) {
  419. return `${edgeEndpointLabel(edge.source, edgeSourcePort(edge))} → ${edgeEndpointLabel(edge.target, edgeTargetPort(edge))}`
  420. }
  421. function refreshEdgeLabels() {
  422. flowEdges.value.forEach((edge) => {
  423. edge.label = readableEdgeLabel(edge)
  424. })
  425. }
  426. function toggleNodeCollapsed(nodeId) {
  427. const node = flowNodes.value.find((item) => item.id === nodeId)
  428. if (node) node.data.collapsed = !node.data.collapsed
  429. }
  430. function fieldEntries(fields = {}) {
  431. return Object.entries(fields).map(([key, field]) => ({
  432. key,
  433. label: field.label || key,
  434. type: field.type || 'any',
  435. description: field.description || '',
  436. }))
  437. }
  438. function nodeOutputEntries(nodeType) {
  439. return fieldEntries(nodeDefinition(nodeType)?.outputs || {})
  440. }
  441. function outputEntriesForNode(nodeId) {
  442. const node = flowNodes.value.find((item) => item.id === nodeId)
  443. return node ? nodeOutputEntries(node.data.nodeType) : []
  444. }
  445. function nodeParamEntries(data) {
  446. const definition = nodeDefinition(data.nodeType)
  447. const params = data.params || {}
  448. return fieldEntries(definition?.params || {})
  449. .filter((item) => params[item.key] !== undefined && params[item.key] !== '')
  450. .map((item) => ({ ...item, value: params[item.key] }))
  451. }
  452. function nodeInputEntries(data) {
  453. const definition = nodeDefinition(data.nodeType)
  454. const inputs = data.inputs || {}
  455. return fieldEntries(definition?.inputs || {})
  456. .filter((item) => inputs[item.key])
  457. .map((item) => ({ ...item, binding: inputs[item.key] }))
  458. }
  459. function shortValue(value) {
  460. if (value === null || value === undefined || value === '') return '空'
  461. if (typeof value === 'boolean') return value ? '是' : '否'
  462. if (typeof value === 'object') {
  463. const text = JSON.stringify(value)
  464. return text.length > 28 ? `${text.slice(0, 28)}...` : text
  465. }
  466. const text = String(value)
  467. return text.length > 28 ? `${text.slice(0, 28)}...` : text
  468. }
  469. function bindingSummary(binding) {
  470. if (!binding) return '未设置'
  471. if (binding.source === 'variable') return `变量:${binding.name || '-'}`
  472. if (binding.source === 'node_output') return `${edgeEndpointLabel(binding.node_id, binding.output || '-')}`
  473. if (binding.source === 'runtime') return `运行时:${binding.name || '-'}`
  474. return shortValue(binding.value)
  475. }
  476. function fieldComponent(field) {
  477. if (field.type === 'boolean') return 'el-switch'
  478. if (field.type === 'select') return 'el-select'
  479. if (field.type === 'number') return 'el-input-number'
  480. if (field.type === 'textarea') return 'el-input'
  481. return 'el-input'
  482. }
  483. function fieldProps(field) {
  484. if (field.type === 'textarea') return { type: 'textarea', rows: 4 }
  485. if (field.type === 'number') return { min: field.min, max: field.max }
  486. if (field.type === 'array') return { type: 'textarea', rows: 3, placeholder: 'JSON 数组或逗号分隔文本' }
  487. return {}
  488. }
  489. function inputBinding(key) {
  490. if (!selectedNode.value.data.inputs[key]) {
  491. selectedNode.value.data.inputs[key] = { source: 'literal', value: '' }
  492. }
  493. return selectedNode.value.data.inputs[key]
  494. }
  495. function ensureVariableObject(name) {
  496. if (!workflowVariables.value[name] || typeof workflowVariables.value[name] !== 'object') {
  497. workflowVariables.value[name] = {
  498. type: typeof workflowVariables.value[name],
  499. default: workflowVariables.value[name],
  500. description: '',
  501. }
  502. }
  503. workflowVariables.value[name].type ||= 'string'
  504. return workflowVariables.value[name]
  505. }
  506. function variableDefaultComponent(variable) {
  507. if (variable.type === 'number') return 'el-input-number'
  508. if (variable.type === 'boolean') return 'el-switch'
  509. return 'el-input'
  510. }
  511. function variableDefaultProps(variable) {
  512. if (variable.type === 'number') return { controlsPosition: 'right' }
  513. return {}
  514. }
  515. function variableJsonText(value) {
  516. return JSON.stringify(value ?? {}, null, 2)
  517. }
  518. function updateVariableJson(name, value) {
  519. try {
  520. ensureVariableObject(name).default = JSON.parse(value)
  521. } catch {
  522. ElMessage.error(`变量 ${name} 的默认值不是有效 JSON`)
  523. }
  524. }
  525. async function addVariable() {
  526. const { value } = await ElMessageBox.prompt('请输入变量名', '新增变量', {
  527. inputPattern: /^[A-Za-z_][A-Za-z0-9_]*$/,
  528. inputErrorMessage: '变量名只能使用字母、数字和下划线,且不能以数字开头',
  529. })
  530. if (workflowVariables.value[value]) {
  531. ElMessage.warning('变量已存在')
  532. return
  533. }
  534. workflowVariables.value[value] = { type: 'string', default: '', description: '' }
  535. }
  536. function deleteVariable(name) {
  537. delete workflowVariables.value[name]
  538. }
  539. function deleteEdge(edgeId) {
  540. flowEdges.value = flowEdges.value.filter((edge) => edge.id !== edgeId)
  541. }
  542. function deleteSelectedNode() {
  543. if (!selectedNodeId.value) return
  544. flowNodes.value = flowNodes.value.filter((node) => node.id !== selectedNodeId.value)
  545. flowEdges.value = flowEdges.value.filter((edge) => edge.source !== selectedNodeId.value && edge.target !== selectedNodeId.value)
  546. selectedNodeId.value = null
  547. }
  548. function buildPayload() {
  549. return {
  550. schema_version: 'workflow/v1',
  551. workflow_key: workflowKey.value.trim() || null,
  552. name: workflowName.value.trim(),
  553. description: workflowDescription.value,
  554. variables: workflowVariables.value,
  555. settings: workflowSettings.value,
  556. nodes: flowNodes.value.map((node) => ({
  557. id: node.id,
  558. type: node.data.nodeType,
  559. title: node.data.title,
  560. position: node.position,
  561. params: node.data.params || {},
  562. inputs: node.data.inputs || {},
  563. })),
  564. edges: flowEdges.value.map((edge) => ({
  565. id: edge.id,
  566. kind: edge.data?.kind || 'control',
  567. source: edge.source,
  568. source_port: edge.sourceHandle || (edge.data?.kind === 'data' ? 'value' : 'success'),
  569. target: edge.target,
  570. target_port: edge.targetHandle || (edge.data?.kind === 'data' ? 'value' : 'run'),
  571. })),
  572. }
  573. }
  574. async function save() {
  575. if (!workflowName.value.trim()) {
  576. ElMessage.warning('请输入工作流名称')
  577. return
  578. }
  579. saving.value = true
  580. try {
  581. const payload = buildPayload()
  582. const { data } = workflowId.value
  583. ? await api.put(`/api/automation/workflows/${workflowId.value}`, payload)
  584. : await api.post('/api/automation/workflows', payload)
  585. workflowId.value = data.id
  586. ElMessage.success('已保存')
  587. emit('saved', data.id)
  588. } finally {
  589. saving.value = false
  590. }
  591. }
  592. async function runWorkflow() {
  593. if (!workflowId.value || !workflowKey.value.trim()) {
  594. ElMessage.warning('请先保存并设置 workflow key')
  595. return
  596. }
  597. running.value = true
  598. try {
  599. const { data } = await api.post(`/api/automation/workflows/by-key/${encodeURIComponent(workflowKey.value.trim())}/run`, {})
  600. runOutput.value = JSON.stringify(data, null, 2)
  601. ElMessage.success('任务已加入执行队列')
  602. emit('task-created', data)
  603. } catch (error) {
  604. ElMessage.error(error.response?.data?.detail || '执行工作流失败')
  605. } finally {
  606. running.value = false
  607. }
  608. }
  609. onMounted(async () => {
  610. await loadDefinitions()
  611. await loadWorkflow()
  612. })
  613. </script>