RuleWorkflow.vue 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437
  1. <script setup>
  2. import { ref, computed, watch, onMounted, onUnmounted, nextTick, markRaw } from 'vue'
  3. import { VueFlow, useVueFlow } from '@vue-flow/core'
  4. import { Background } from '@vue-flow/background'
  5. import { Controls } from '@vue-flow/controls'
  6. import { MiniMap } from '@vue-flow/minimap'
  7. import { ElMessage, ElMessageBox } from 'element-plus'
  8. import '@vue-flow/core/dist/style.css'
  9. import '@vue-flow/core/dist/theme-default.css'
  10. import '@vue-flow/controls/dist/style.css'
  11. import '@vue-flow/minimap/dist/style.css'
  12. import SourceNode from './nodes/SourceNode.vue'
  13. import ActionNode from './nodes/ActionNode.vue'
  14. import ElementNode from './nodes/ElementNode.vue'
  15. import NodePanel from './panels/NodePanel.vue'
  16. import PropertyPanel from './panels/PropertyPanel.vue'
  17. const props = defineProps({
  18. projectId: { type: Number, required: true },
  19. attachments: { type: Array, default: () => [] },
  20. elements: { type: Array, default: () => [] },
  21. rules: { type: Array, default: () => [] },
  22. targetRule: { type: Object, default: null }, // 编辑的目标规则
  23. targetElement: { type: Object, default: null } // 目标要素(单要素模式)
  24. })
  25. const emit = defineEmits(['save', 'close'])
  26. // 使用 markRaw 避免组件被响应式化
  27. const nodeTypes = {
  28. source: markRaw(SourceNode),
  29. action: markRaw(ActionNode),
  30. element: markRaw(ElementNode)
  31. }
  32. const nodes = ref([])
  33. const edges = ref([])
  34. const selectedNode = ref(null)
  35. const selectedEdge = ref(null)
  36. const selectedNodes = ref([])
  37. // 撤销重做
  38. const historyStack = ref([])
  39. const historyIndex = ref(-1)
  40. const maxHistory = 50
  41. // 右键菜单
  42. const contextMenu = ref({ visible: false, x: 0, y: 0, type: '', target: null })
  43. // 工作流验证
  44. const validationErrors = ref([])
  45. const showValidation = ref(false)
  46. // 复制粘贴
  47. const clipboard = ref(null)
  48. // 规则预览
  49. const showPreview = ref(false)
  50. const previewRules = ref([])
  51. const expandedRuleIdx = ref(null)
  52. const {
  53. onConnect,
  54. addEdges,
  55. onNodesChange,
  56. onEdgesChange,
  57. onNodeClick,
  58. onEdgeClick,
  59. onPaneClick,
  60. onNodeContextMenu,
  61. onEdgeContextMenu,
  62. onPaneContextMenu,
  63. project,
  64. fitView,
  65. getSelectedNodes,
  66. removeNodes,
  67. removeEdges
  68. } = useVueFlow()
  69. // 历史记录
  70. function saveHistory() {
  71. const state = {
  72. nodes: JSON.parse(JSON.stringify(nodes.value)),
  73. edges: JSON.parse(JSON.stringify(edges.value))
  74. }
  75. if (historyIndex.value < historyStack.value.length - 1) {
  76. historyStack.value = historyStack.value.slice(0, historyIndex.value + 1)
  77. }
  78. historyStack.value.push(state)
  79. if (historyStack.value.length > maxHistory) {
  80. historyStack.value.shift()
  81. } else {
  82. historyIndex.value++
  83. }
  84. }
  85. function undo() {
  86. if (historyIndex.value > 0) {
  87. historyIndex.value--
  88. const state = historyStack.value[historyIndex.value]
  89. nodes.value = JSON.parse(JSON.stringify(state.nodes))
  90. edges.value = JSON.parse(JSON.stringify(state.edges))
  91. selectedNode.value = null
  92. selectedEdge.value = null
  93. ElMessage.info('已撤销')
  94. }
  95. }
  96. function redo() {
  97. if (historyIndex.value < historyStack.value.length - 1) {
  98. historyIndex.value++
  99. const state = historyStack.value[historyIndex.value]
  100. nodes.value = JSON.parse(JSON.stringify(state.nodes))
  101. edges.value = JSON.parse(JSON.stringify(state.edges))
  102. selectedNode.value = null
  103. selectedEdge.value = null
  104. ElMessage.info('已重做')
  105. }
  106. }
  107. const canUndo = computed(() => historyIndex.value > 0)
  108. const canRedo = computed(() => historyIndex.value < historyStack.value.length - 1)
  109. // 连接验证
  110. onConnect((params) => {
  111. if (validateConnection(params)) {
  112. saveHistory()
  113. addEdges([{
  114. ...params,
  115. id: `edge-${Date.now()}`,
  116. animated: true,
  117. style: { stroke: '#409eff', strokeWidth: 2 }
  118. }])
  119. } else {
  120. ElMessage.warning('无效的连接:请检查节点类型')
  121. }
  122. })
  123. onNodeClick(({ node }) => {
  124. selectedNode.value = node
  125. selectedEdge.value = null
  126. hideContextMenu()
  127. })
  128. onEdgeClick(({ edge }) => {
  129. selectedEdge.value = edge
  130. selectedNode.value = null
  131. hideContextMenu()
  132. })
  133. onPaneClick(() => {
  134. selectedNode.value = null
  135. selectedEdge.value = null
  136. hideContextMenu()
  137. })
  138. // 右键菜单
  139. onNodeContextMenu(({ event, node }) => {
  140. event.preventDefault()
  141. selectedNode.value = node
  142. showContextMenu(event.clientX, event.clientY, 'node', node)
  143. })
  144. onEdgeContextMenu(({ event, edge }) => {
  145. event.preventDefault()
  146. selectedEdge.value = edge
  147. showContextMenu(event.clientX, event.clientY, 'edge', edge)
  148. })
  149. onPaneContextMenu(({ event }) => {
  150. event.preventDefault()
  151. showContextMenu(event.clientX, event.clientY, 'pane', null)
  152. })
  153. function showContextMenu(x, y, type, target) {
  154. contextMenu.value = { visible: true, x, y, type, target }
  155. }
  156. function hideContextMenu() {
  157. contextMenu.value.visible = false
  158. }
  159. function handleContextMenuAction(action) {
  160. const { type, target } = contextMenu.value
  161. hideContextMenu()
  162. switch (action) {
  163. case 'delete':
  164. if (type === 'node') handleDeleteNode(target.id)
  165. else if (type === 'edge') handleDeleteEdge(target.id)
  166. break
  167. case 'copy':
  168. if (type === 'node') copyNode(target)
  169. break
  170. case 'paste':
  171. pasteNode()
  172. break
  173. case 'duplicate':
  174. if (type === 'node') duplicateNode(target)
  175. break
  176. }
  177. }
  178. function validateConnection(params) {
  179. const sourceNode = nodes.value.find(n => n.id === params.source)
  180. const targetNode = nodes.value.find(n => n.id === params.target)
  181. if (!sourceNode || !targetNode) return false
  182. if (params.source === params.target) return false
  183. // 检查是否已存在连接
  184. const existingEdge = edges.value.find(e =>
  185. e.source === params.source && e.target === params.target
  186. )
  187. if (existingEdge) return false
  188. // 验证连接规则
  189. if (sourceNode.type === 'source' && targetNode.type === 'element') return true
  190. if (sourceNode.type === 'source' && targetNode.type === 'action') return true
  191. if (sourceNode.type === 'action' && targetNode.type === 'element') return true
  192. if (sourceNode.type === 'action' && targetNode.type === 'action') return true
  193. return false
  194. }
  195. function onDragOver(event) {
  196. event.preventDefault()
  197. event.dataTransfer.dropEffect = 'move'
  198. }
  199. function onDrop(event) {
  200. event.preventDefault()
  201. const dataStr = event.dataTransfer.getData('application/vueflow')
  202. if (!dataStr) return
  203. saveHistory()
  204. const data = JSON.parse(dataStr)
  205. const position = project({ x: event.clientX - 220, y: event.clientY - 60 })
  206. const newNode = {
  207. id: `node-${Date.now()}`,
  208. type: data.nodeType,
  209. position,
  210. data: {
  211. ...data,
  212. label: data.label || data.nodeType
  213. }
  214. }
  215. nodes.value.push(newNode)
  216. setTimeout(() => {
  217. selectedNode.value = newNode
  218. }, 50)
  219. }
  220. function handleNodeUpdate(nodeId, newData) {
  221. saveHistory()
  222. const node = nodes.value.find(n => n.id === nodeId)
  223. if (node) {
  224. node.data = { ...node.data, ...newData }
  225. }
  226. }
  227. function handleDeleteNode(nodeId) {
  228. saveHistory()
  229. nodes.value = nodes.value.filter(n => n.id !== nodeId)
  230. edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId)
  231. selectedNode.value = null
  232. }
  233. function handleDeleteEdge(edgeId) {
  234. saveHistory()
  235. edges.value = edges.value.filter(e => e.id !== edgeId)
  236. selectedEdge.value = null
  237. }
  238. // 复制粘贴
  239. function copyNode(node) {
  240. clipboard.value = JSON.parse(JSON.stringify(node))
  241. ElMessage.success('已复制节点')
  242. }
  243. function pasteNode() {
  244. if (!clipboard.value) {
  245. ElMessage.warning('剪贴板为空')
  246. return
  247. }
  248. saveHistory()
  249. const newNode = {
  250. ...clipboard.value,
  251. id: `node-${Date.now()}`,
  252. position: {
  253. x: clipboard.value.position.x + 50,
  254. y: clipboard.value.position.y + 50
  255. }
  256. }
  257. nodes.value.push(newNode)
  258. selectedNode.value = newNode
  259. ElMessage.success('已粘贴节点')
  260. }
  261. function duplicateNode(node) {
  262. saveHistory()
  263. const newNode = {
  264. ...JSON.parse(JSON.stringify(node)),
  265. id: `node-${Date.now()}`,
  266. position: {
  267. x: node.position.x + 50,
  268. y: node.position.y + 50
  269. }
  270. }
  271. nodes.value.push(newNode)
  272. selectedNode.value = newNode
  273. }
  274. // 工作流验证
  275. function validateWorkflow() {
  276. const errors = []
  277. // 检查孤立节点
  278. const connectedNodeIds = new Set()
  279. edges.value.forEach(e => {
  280. connectedNodeIds.add(e.source)
  281. connectedNodeIds.add(e.target)
  282. })
  283. nodes.value.forEach(node => {
  284. if (!connectedNodeIds.has(node.id)) {
  285. errors.push({ type: 'warning', nodeId: node.id, message: `节点 "${node.data.label}" 未连接` })
  286. }
  287. })
  288. // 检查来源节点配置
  289. nodes.value.filter(n => n.type === 'source').forEach(node => {
  290. if (node.data.subType === 'attachment' && !node.data.sourceNodeId) {
  291. errors.push({ type: 'error', nodeId: node.id, message: `来源节点 "${node.data.label}" 未选择附件` })
  292. }
  293. })
  294. // 检查输出节点配置
  295. nodes.value.filter(n => n.type === 'element').forEach(node => {
  296. if (!node.data.elementKey) {
  297. errors.push({ type: 'error', nodeId: node.id, message: `输出节点 "${node.data.label}" 未选择要素` })
  298. }
  299. })
  300. // 检查动作节点配置
  301. nodes.value.filter(n => n.type === 'action').forEach(node => {
  302. const actionType = node.data.actionType || node.data.subType
  303. if (['summary', 'ai_extract'].includes(actionType) && !node.data.prompt) {
  304. errors.push({ type: 'warning', nodeId: node.id, message: `动作节点 "${node.data.label}" 建议配置提示词` })
  305. }
  306. })
  307. // 检查完整的数据流
  308. const elementNodes = nodes.value.filter(n => n.type === 'element')
  309. elementNodes.forEach(elemNode => {
  310. const hasInput = edges.value.some(e => e.target === elemNode.id)
  311. if (!hasInput) {
  312. errors.push({ type: 'error', nodeId: elemNode.id, message: `输出节点 "${elemNode.data.label}" 没有输入连接` })
  313. }
  314. })
  315. validationErrors.value = errors
  316. showValidation.value = true
  317. if (errors.length === 0) {
  318. ElMessage.success('工作流验证通过')
  319. } else {
  320. const errorCount = errors.filter(e => e.type === 'error').length
  321. const warningCount = errors.filter(e => e.type === 'warning').length
  322. ElMessage.warning(`发现 ${errorCount} 个错误,${warningCount} 个警告`)
  323. }
  324. return errors.filter(e => e.type === 'error').length === 0
  325. }
  326. function highlightNode(nodeId) {
  327. const node = nodes.value.find(n => n.id === nodeId)
  328. if (node) {
  329. selectedNode.value = node
  330. // 滚动到节点位置
  331. }
  332. }
  333. function handleSave() {
  334. if (!validateWorkflow()) {
  335. ElMessageBox.confirm('工作流存在错误,是否仍要保存?', '验证警告', {
  336. confirmButtonText: '继续保存',
  337. cancelButtonText: '返回修改',
  338. type: 'warning'
  339. }).then(() => {
  340. showRulePreview()
  341. }).catch(() => {})
  342. } else {
  343. showRulePreview()
  344. }
  345. }
  346. function showRulePreview() {
  347. const rules = generateRulesFromWorkflow()
  348. if (rules.length === 0) {
  349. ElMessage.warning('没有可保存的规则,请确保有完整的数据流(来源 → 输出)')
  350. return
  351. }
  352. previewRules.value = rules
  353. showPreview.value = true
  354. }
  355. function generateRulesFromWorkflow() {
  356. const rules = []
  357. const elementNodes = nodes.value.filter(n => n.type === 'element')
  358. function traceDataFlow(nodeId, visited = new Set()) {
  359. if (visited.has(nodeId)) return { sources: [], actions: [] }
  360. visited.add(nodeId)
  361. const node = nodes.value.find(n => n.id === nodeId)
  362. if (!node) return { sources: [], actions: [] }
  363. if (node.type === 'source') {
  364. return { sources: [node], actions: [] }
  365. }
  366. if (node.type === 'action') {
  367. const inEdges = edges.value.filter(e => e.target === nodeId)
  368. let allSources = []
  369. let allActions = [node]
  370. for (const edge of inEdges) {
  371. const upstream = traceDataFlow(edge.source, visited)
  372. allSources = [...allSources, ...upstream.sources]
  373. allActions = [...allActions, ...upstream.actions]
  374. }
  375. return { sources: allSources, actions: allActions }
  376. }
  377. return { sources: [], actions: [] }
  378. }
  379. for (const elementNode of elementNodes) {
  380. if (!elementNode.data.elementKey) continue
  381. const incomingEdges = edges.value.filter(e => e.target === elementNode.id)
  382. if (incomingEdges.length === 0) continue
  383. let allSources = []
  384. let allActions = []
  385. for (const edge of incomingEdges) {
  386. const { sources, actions } = traceDataFlow(edge.source)
  387. allSources = [...allSources, ...sources]
  388. allActions = [...allActions, ...actions]
  389. }
  390. const uniqueSources = [...new Map(allSources.map(s => [s.id, s])).values()]
  391. const uniqueActions = [...new Map(allActions.map(a => [a.id, a])).values()]
  392. const directInputNode = nodes.value.find(n => n.id === incomingEdges[0].source)
  393. let primaryAction = directInputNode?.type === 'action' ? directInputNode : uniqueActions[0]
  394. const actionType = primaryAction?.data?.actionType || primaryAction?.data?.subType || 'quote'
  395. rules.push({
  396. elementKey: elementNode.data.elementKey,
  397. elementName: elementNode.data.elementName || elementNode.data.label,
  398. actionType: actionType,
  399. actionLabel: getActionLabel(actionType),
  400. prompt: primaryAction?.data?.prompt || '',
  401. sources: uniqueSources.map(s => ({
  402. name: s.data.sourceName || s.data.label,
  403. type: s.data.subType,
  404. locatorType: s.data.locatorType
  405. })),
  406. actionsCount: uniqueActions.length
  407. })
  408. }
  409. return rules
  410. }
  411. function confirmSave() {
  412. showPreview.value = false
  413. doSave()
  414. }
  415. function doSave() {
  416. const workflowData = {
  417. nodes: nodes.value,
  418. edges: edges.value
  419. }
  420. emit('save', workflowData)
  421. }
  422. function handleClear() {
  423. ElMessageBox.confirm('确定要清空画布吗?此操作不可撤销。', '确认清空', {
  424. confirmButtonText: '确定',
  425. cancelButtonText: '取消',
  426. type: 'warning'
  427. }).then(() => {
  428. saveHistory()
  429. nodes.value = []
  430. edges.value = []
  431. selectedNode.value = null
  432. selectedEdge.value = null
  433. ElMessage.success('画布已清空')
  434. }).catch(() => {})
  435. }
  436. function handleFitView() {
  437. fitView({ padding: 0.2 })
  438. }
  439. // 快捷键
  440. function handleKeydown(event) {
  441. // 忽略输入框中的快捷键
  442. if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) return
  443. const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
  444. const ctrlKey = isMac ? event.metaKey : event.ctrlKey
  445. switch (event.key) {
  446. case 'Delete':
  447. case 'Backspace':
  448. if (selectedNode.value) {
  449. handleDeleteNode(selectedNode.value.id)
  450. } else if (selectedEdge.value) {
  451. handleDeleteEdge(selectedEdge.value.id)
  452. }
  453. event.preventDefault()
  454. break
  455. case 'z':
  456. if (ctrlKey && event.shiftKey) {
  457. redo()
  458. event.preventDefault()
  459. } else if (ctrlKey) {
  460. undo()
  461. event.preventDefault()
  462. }
  463. break
  464. case 'y':
  465. if (ctrlKey) {
  466. redo()
  467. event.preventDefault()
  468. }
  469. break
  470. case 'c':
  471. if (ctrlKey && selectedNode.value) {
  472. copyNode(selectedNode.value)
  473. event.preventDefault()
  474. }
  475. break
  476. case 'v':
  477. if (ctrlKey) {
  478. pasteNode()
  479. event.preventDefault()
  480. }
  481. break
  482. case 'd':
  483. if (ctrlKey && selectedNode.value) {
  484. duplicateNode(selectedNode.value)
  485. event.preventDefault()
  486. }
  487. break
  488. case 's':
  489. if (ctrlKey) {
  490. handleSave()
  491. event.preventDefault()
  492. }
  493. break
  494. case 'Escape':
  495. selectedNode.value = null
  496. selectedEdge.value = null
  497. hideContextMenu()
  498. break
  499. case 'a':
  500. if (ctrlKey) {
  501. // 全选节点
  502. event.preventDefault()
  503. }
  504. break
  505. }
  506. }
  507. // VueFlow 初始化完成后调用
  508. const flowInitialized = ref(false)
  509. function onFlowInit() {
  510. console.log('VueFlow initialized, nodes:', nodes.value.length)
  511. flowInitialized.value = true
  512. // 延迟 fitView 确保节点已渲染
  513. setTimeout(() => {
  514. console.log('Calling fitView with nodes:', nodes.value.length)
  515. if (nodes.value.length > 0) {
  516. fitView({ padding: 0.2, maxZoom: 1, includeHiddenNodes: true })
  517. }
  518. }, 200)
  519. }
  520. onMounted(() => {
  521. // 如果有目标规则,加载该规则的工作流
  522. if (props.targetRule) {
  523. loadSingleRuleAsWorkflow(props.targetRule)
  524. } else if (props.targetElement) {
  525. // 新建规则模式:预置目标要素节点
  526. initWithTargetElement(props.targetElement)
  527. }
  528. // 新建模式(无 targetRule 和 targetElement):空白画布,不加载任何规则
  529. // 保存初始状态
  530. saveHistory()
  531. // 注册快捷键
  532. window.addEventListener('keydown', handleKeydown)
  533. })
  534. onUnmounted(() => {
  535. window.removeEventListener('keydown', handleKeydown)
  536. })
  537. // 加载单个规则为工作流(编辑模式)
  538. function loadSingleRuleAsWorkflow(rule) {
  539. console.log('loadSingleRuleAsWorkflow:', rule)
  540. const newNodes = []
  541. const newEdges = []
  542. const y = 150
  543. const elementId = `element-${rule.id}`
  544. const sourceId = `source-${rule.id}`
  545. const actionId = `action-${rule.id}`
  546. let lastNodeId = null
  547. let xPos = 50
  548. const nodeSpacing = 250 // 节点间距
  549. // 1. 添加来源节点
  550. // 对于 use_entity_value(人工录入)类型,添加一个"人工录入"来源节点
  551. if (rule.actionType === 'use_entity_value') {
  552. newNodes.push({
  553. id: sourceId,
  554. type: 'source',
  555. position: { x: xPos, y },
  556. data: {
  557. nodeType: 'source',
  558. subType: 'manual',
  559. label: '人工录入',
  560. sourceName: '人工录入',
  561. sourceText: '用户手工输入的值'
  562. }
  563. })
  564. lastNodeId = sourceId
  565. xPos += nodeSpacing
  566. } else if (rule.inputs && rule.inputs.length > 0) {
  567. // 其他类型:从 inputs 获取来源
  568. const input = rule.inputs[0]
  569. newNodes.push({
  570. id: sourceId,
  571. type: 'source',
  572. position: { x: xPos, y },
  573. data: {
  574. nodeType: 'source',
  575. subType: input.inputType || 'attachment',
  576. label: input.sourceName || input.inputName || '来源',
  577. sourceNodeId: input.sourceNodeId,
  578. sourceName: input.sourceName || input.inputName,
  579. sourceText: input.sourceText
  580. }
  581. })
  582. lastNodeId = sourceId
  583. xPos += nodeSpacing
  584. }
  585. // 2. 添加动作节点(如果不是 quote 和 use_entity_value 类型)
  586. if (rule.actionType && rule.actionType !== 'quote' && rule.actionType !== 'use_entity_value') {
  587. let prompt = ''
  588. try {
  589. prompt = rule.actionConfig ? JSON.parse(rule.actionConfig).prompt : ''
  590. } catch (e) {}
  591. newNodes.push({
  592. id: actionId,
  593. type: 'action',
  594. position: { x: xPos, y },
  595. data: {
  596. nodeType: 'action',
  597. subType: rule.actionType,
  598. label: getActionLabel(rule.actionType),
  599. actionType: rule.actionType,
  600. prompt: prompt
  601. }
  602. })
  603. // 连接来源到动作
  604. if (lastNodeId) {
  605. newEdges.push({
  606. id: `edge-${lastNodeId}-${actionId}`,
  607. source: lastNodeId,
  608. target: actionId,
  609. animated: true,
  610. style: { stroke: '#409eff', strokeWidth: 2 }
  611. })
  612. }
  613. lastNodeId = actionId
  614. xPos += nodeSpacing
  615. }
  616. // 3. 添加输出节点(目标要素)
  617. newNodes.push({
  618. id: elementId,
  619. type: 'element',
  620. position: { x: xPos, y },
  621. data: {
  622. nodeType: 'element',
  623. label: rule.elementKey,
  624. elementKey: rule.elementKey,
  625. elementName: getElementName(rule.elementKey)
  626. }
  627. })
  628. // 连接到输出节点
  629. if (lastNodeId) {
  630. newEdges.push({
  631. id: `edge-${lastNodeId}-${elementId}`,
  632. source: lastNodeId,
  633. target: elementId,
  634. animated: true,
  635. style: { stroke: '#67c23a', strokeWidth: 2 }
  636. })
  637. }
  638. console.log('Setting nodes:', newNodes.length, 'edges:', newEdges.length)
  639. nodes.value = newNodes
  640. edges.value = newEdges
  641. console.log('After set - nodes:', nodes.value.length, 'edges:', edges.value.length)
  642. }
  643. // 新建规则模式:预置目标要素节点
  644. function initWithTargetElement(element) {
  645. const elementId = `element-new-${Date.now()}`
  646. nodes.value = [{
  647. id: elementId,
  648. type: 'element',
  649. position: { x: 400, y: 150 },
  650. data: {
  651. nodeType: 'element',
  652. label: element.elementName || element.elementKey,
  653. elementKey: element.elementKey,
  654. elementName: element.elementName || element.elementKey
  655. }
  656. }]
  657. edges.value = []
  658. setTimeout(() => fitView({ padding: 0.3 }), 100)
  659. }
  660. function loadRulesAsWorkflow(rules) {
  661. const newNodes = []
  662. const newEdges = []
  663. let xOffset = 100
  664. let yOffset = 100
  665. rules.forEach((rule, index) => {
  666. const y = yOffset + index * 150
  667. if (rule.inputs && rule.inputs.length > 0) {
  668. const input = rule.inputs[0]
  669. const sourceId = `source-${rule.id}-${index}`
  670. newNodes.push({
  671. id: sourceId,
  672. type: 'source',
  673. position: { x: xOffset, y },
  674. data: {
  675. nodeType: 'source',
  676. subType: input.inputType || 'attachment',
  677. label: input.sourceName || input.inputName || '来源',
  678. sourceNodeId: input.sourceNodeId,
  679. sourceName: input.sourceName || input.inputName,
  680. sourceText: input.sourceText
  681. }
  682. })
  683. if (rule.actionType && rule.actionType !== 'quote') {
  684. const actionId = `action-${rule.id}-${index}`
  685. newNodes.push({
  686. id: actionId,
  687. type: 'action',
  688. position: { x: xOffset + 200, y },
  689. data: {
  690. nodeType: 'action',
  691. subType: rule.actionType,
  692. label: getActionLabel(rule.actionType),
  693. actionType: rule.actionType,
  694. prompt: rule.actionConfig ? JSON.parse(rule.actionConfig).prompt : ''
  695. }
  696. })
  697. newEdges.push({
  698. id: `edge-${sourceId}-${actionId}`,
  699. source: sourceId,
  700. target: actionId,
  701. animated: true,
  702. style: { stroke: '#409eff', strokeWidth: 2 }
  703. })
  704. const elementId = `element-${rule.id}-${index}`
  705. newNodes.push({
  706. id: elementId,
  707. type: 'element',
  708. position: { x: xOffset + 400, y },
  709. data: {
  710. nodeType: 'element',
  711. label: rule.elementKey,
  712. elementKey: rule.elementKey,
  713. elementName: getElementName(rule.elementKey)
  714. }
  715. })
  716. newEdges.push({
  717. id: `edge-${actionId}-${elementId}`,
  718. source: actionId,
  719. target: elementId,
  720. animated: true,
  721. style: { stroke: '#409eff', strokeWidth: 2 }
  722. })
  723. } else {
  724. const elementId = `element-${rule.id}-${index}`
  725. newNodes.push({
  726. id: elementId,
  727. type: 'element',
  728. position: { x: xOffset + 250, y },
  729. data: {
  730. nodeType: 'element',
  731. label: rule.elementKey,
  732. elementKey: rule.elementKey,
  733. elementName: getElementName(rule.elementKey)
  734. }
  735. })
  736. newEdges.push({
  737. id: `edge-${sourceId}-${elementId}`,
  738. source: sourceId,
  739. target: elementId,
  740. animated: true,
  741. style: { stroke: '#67c23a', strokeWidth: 2 }
  742. })
  743. }
  744. }
  745. })
  746. nodes.value = newNodes
  747. edges.value = newEdges
  748. setTimeout(() => fitView({ padding: 0.2 }), 100)
  749. }
  750. function getActionLabel(actionType) {
  751. const labels = {
  752. quote: '引用',
  753. summary: 'AI 总结',
  754. ai_extract: 'AI 提取',
  755. table_extract: '表格提取'
  756. }
  757. return labels[actionType] || actionType
  758. }
  759. function getElementName(elementKey) {
  760. const elem = props.elements.find(e => e.elementKey === elementKey)
  761. return elem ? elem.elementName : elementKey
  762. }
  763. function getActionTagType(actionType) {
  764. const types = {
  765. quote: 'success',
  766. summary: 'warning',
  767. ai_extract: '',
  768. table_extract: 'info'
  769. }
  770. return types[actionType] || 'info'
  771. }
  772. defineExpose({
  773. handleSave,
  774. handleClear,
  775. handleFitView,
  776. undo,
  777. redo,
  778. validateWorkflow
  779. })
  780. </script>
  781. <template>
  782. <div class="rule-workflow" @click="hideContextMenu">
  783. <div class="workflow-toolbar">
  784. <div class="toolbar-left">
  785. <el-button-group>
  786. <el-button size="small" :disabled="!canUndo" @click="undo" title="撤销 (Ctrl+Z)">
  787. ↩️ 撤销
  788. </el-button>
  789. <el-button size="small" :disabled="!canRedo" @click="redo" title="重做 (Ctrl+Y)">
  790. ↪️ 重做
  791. </el-button>
  792. </el-button-group>
  793. <el-divider direction="vertical" />
  794. <el-button size="small" @click="handleFitView" title="适应视图">📐 适应</el-button>
  795. <el-button size="small" @click="validateWorkflow" title="验证工作流">✅ 验证</el-button>
  796. <el-button size="small" type="danger" plain @click="handleClear">🗑️ 清空</el-button>
  797. <el-divider direction="vertical" />
  798. <el-button type="primary" size="small" @click="handleSave" title="保存 (Ctrl+S)">
  799. 💾 保存规则
  800. </el-button>
  801. </div>
  802. <div class="toolbar-right">
  803. <span class="workflow-stats">
  804. 节点: {{ nodes.length }} | 连线: {{ edges.length }}
  805. </span>
  806. <el-tag v-if="validationErrors.length > 0" type="warning" size="small">
  807. {{ validationErrors.filter(e => e.type === 'error').length }} 错误
  808. </el-tag>
  809. </div>
  810. </div>
  811. <!-- 验证结果面板 -->
  812. <div class="validation-panel" v-if="showValidation && validationErrors.length > 0">
  813. <div class="validation-header">
  814. <span>验证结果</span>
  815. <el-button text size="small" @click="showValidation = false">✕</el-button>
  816. </div>
  817. <div class="validation-list">
  818. <div
  819. v-for="(err, idx) in validationErrors"
  820. :key="idx"
  821. class="validation-item"
  822. :class="err.type"
  823. @click="highlightNode(err.nodeId)"
  824. >
  825. <span class="validation-icon">{{ err.type === 'error' ? '❌' : '⚠️' }}</span>
  826. <span class="validation-msg">{{ err.message }}</span>
  827. </div>
  828. </div>
  829. </div>
  830. <div class="workflow-container">
  831. <NodePanel
  832. :attachments="attachments"
  833. :elements="elements"
  834. class="workflow-node-panel"
  835. />
  836. <div class="workflow-canvas" @dragover="onDragOver" @drop="onDrop">
  837. <VueFlow
  838. v-model:nodes="nodes"
  839. v-model:edges="edges"
  840. :node-types="nodeTypes"
  841. :default-viewport="{ zoom: 1, x: 0, y: 0 }"
  842. :min-zoom="0.2"
  843. :max-zoom="2"
  844. class="vue-flow-wrapper"
  845. @init="onFlowInit"
  846. >
  847. <Background pattern-color="#aaa" :gap="16" />
  848. <Controls position="bottom-left" />
  849. <MiniMap position="bottom-right" />
  850. </VueFlow>
  851. <!-- 右键菜单 -->
  852. <div
  853. v-if="contextMenu.visible"
  854. class="context-menu"
  855. :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
  856. @click.stop
  857. >
  858. <template v-if="contextMenu.type === 'node'">
  859. <div class="context-menu-item" @click="handleContextMenuAction('copy')">
  860. 📋 复制 <span class="shortcut">Ctrl+C</span>
  861. </div>
  862. <div class="context-menu-item" @click="handleContextMenuAction('duplicate')">
  863. 📑 复制节点 <span class="shortcut">Ctrl+D</span>
  864. </div>
  865. <div class="context-menu-divider"></div>
  866. <div class="context-menu-item danger" @click="handleContextMenuAction('delete')">
  867. 🗑️ 删除 <span class="shortcut">Delete</span>
  868. </div>
  869. </template>
  870. <template v-else-if="contextMenu.type === 'edge'">
  871. <div class="context-menu-item danger" @click="handleContextMenuAction('delete')">
  872. 🗑️ 删除连线 <span class="shortcut">Delete</span>
  873. </div>
  874. </template>
  875. <template v-else>
  876. <div class="context-menu-item" @click="handleContextMenuAction('paste')" :class="{ disabled: !clipboard }">
  877. 📋 粘贴 <span class="shortcut">Ctrl+V</span>
  878. </div>
  879. <div class="context-menu-divider"></div>
  880. <div class="context-menu-item" @click="handleFitView">
  881. 📐 适应视图
  882. </div>
  883. </template>
  884. </div>
  885. </div>
  886. <PropertyPanel
  887. :selected-node="selectedNode"
  888. :selected-edge="selectedEdge"
  889. :attachments="attachments"
  890. :elements="elements"
  891. class="workflow-property-panel"
  892. @update-node="handleNodeUpdate"
  893. @delete-node="handleDeleteNode"
  894. @delete-edge="handleDeleteEdge"
  895. />
  896. </div>
  897. <!-- 快捷键提示 -->
  898. <div class="shortcuts-hint">
  899. <span>快捷键: Ctrl+S 保存 | Ctrl+Z 撤销 | Ctrl+Y 重做 | Delete 删除 | Ctrl+C/V 复制粘贴</span>
  900. </div>
  901. <!-- 规则预览弹窗 -->
  902. <el-dialog
  903. v-model="showPreview"
  904. title="📋 规则预览"
  905. width="700"
  906. :close-on-click-modal="false"
  907. >
  908. <div class="preview-content">
  909. <p class="preview-desc">将创建以下 <strong>{{ previewRules.length }}</strong> 条规则:</p>
  910. <div class="preview-list">
  911. <div
  912. v-for="(rule, idx) in previewRules"
  913. :key="idx"
  914. class="preview-item"
  915. :class="{ expanded: expandedRuleIdx === idx }"
  916. >
  917. <div class="preview-header" @click="expandedRuleIdx = expandedRuleIdx === idx ? null : idx">
  918. <span class="preview-index">{{ idx + 1 }}</span>
  919. <span class="preview-element">{{ rule.elementName }}</span>
  920. <el-tag size="small" :type="getActionTagType(rule.actionType)">
  921. {{ rule.actionLabel }}
  922. </el-tag>
  923. <span class="preview-sources-count" v-if="rule.sources.length > 0">
  924. 📎 {{ rule.sources.length }}
  925. </span>
  926. <span class="preview-expand-icon">{{ expandedRuleIdx === idx ? '▼' : '▶' }}</span>
  927. </div>
  928. <div class="preview-body" v-show="expandedRuleIdx === idx">
  929. <div class="preview-row">
  930. <span class="preview-label">要素标识:</span>
  931. <code class="preview-value">{{ rule.elementKey }}</code>
  932. </div>
  933. <div class="preview-row" v-if="rule.sources.length > 0">
  934. <span class="preview-label">数据来源:</span>
  935. <span class="preview-value">
  936. <el-tag v-for="(src, i) in rule.sources" :key="i" size="small" type="info" class="source-tag">
  937. 📎 {{ src.name }}
  938. </el-tag>
  939. </span>
  940. </div>
  941. <div class="preview-row" v-if="rule.prompt">
  942. <span class="preview-label">提示词:</span>
  943. <span class="preview-value preview-prompt">{{ rule.prompt }}</span>
  944. </div>
  945. </div>
  946. </div>
  947. </div>
  948. </div>
  949. <template #footer>
  950. <el-button @click="showPreview = false">取消</el-button>
  951. <el-button type="primary" @click="confirmSave">
  952. 确认保存 ({{ previewRules.length }} 条规则)
  953. </el-button>
  954. </template>
  955. </el-dialog>
  956. </div>
  957. </template>
  958. <style scoped>
  959. .rule-workflow {
  960. display: flex;
  961. flex-direction: column;
  962. height: calc(100vh - 54px); /* 弹窗高度减去 header */
  963. background: #f5f7fa;
  964. }
  965. .workflow-toolbar {
  966. display: flex;
  967. justify-content: space-between;
  968. align-items: center;
  969. padding: 12px 16px;
  970. background: white;
  971. border-bottom: 1px solid #e4e7ed;
  972. }
  973. .toolbar-left {
  974. display: flex;
  975. gap: 8px;
  976. }
  977. .toolbar-right {
  978. display: flex;
  979. align-items: center;
  980. gap: 16px;
  981. }
  982. .workflow-stats {
  983. font-size: 13px;
  984. color: #909399;
  985. }
  986. .workflow-container {
  987. display: flex;
  988. flex: 1;
  989. overflow: hidden;
  990. }
  991. .workflow-node-panel {
  992. width: 280px;
  993. flex-shrink: 0;
  994. background: white;
  995. border-right: 1px solid #e4e7ed;
  996. overflow-y: auto;
  997. }
  998. .workflow-canvas {
  999. flex: 1;
  1000. position: relative;
  1001. }
  1002. .vue-flow-wrapper {
  1003. width: 100%;
  1004. height: 100%;
  1005. }
  1006. .workflow-property-panel {
  1007. width: 300px;
  1008. flex-shrink: 0;
  1009. background: white;
  1010. border-left: 1px solid #e4e7ed;
  1011. overflow-y: auto;
  1012. }
  1013. :deep(.vue-flow__node) {
  1014. cursor: grab;
  1015. }
  1016. :deep(.vue-flow__node:active) {
  1017. cursor: grabbing;
  1018. }
  1019. :deep(.vue-flow__edge-path) {
  1020. stroke-width: 2;
  1021. }
  1022. :deep(.vue-flow__handle) {
  1023. width: 12px;
  1024. height: 12px;
  1025. border-radius: 50%;
  1026. background: #409eff;
  1027. border: 2px solid white;
  1028. }
  1029. :deep(.vue-flow__handle-left) {
  1030. left: -6px;
  1031. }
  1032. :deep(.vue-flow__handle-right) {
  1033. right: -6px;
  1034. }
  1035. /* 验证面板 */
  1036. .validation-panel {
  1037. background: #fef0f0;
  1038. border-bottom: 1px solid #fbc4c4;
  1039. padding: 8px 16px;
  1040. }
  1041. .validation-header {
  1042. display: flex;
  1043. justify-content: space-between;
  1044. align-items: center;
  1045. font-size: 13px;
  1046. font-weight: 500;
  1047. color: #f56c6c;
  1048. margin-bottom: 8px;
  1049. }
  1050. .validation-list {
  1051. display: flex;
  1052. flex-wrap: wrap;
  1053. gap: 8px;
  1054. }
  1055. .validation-item {
  1056. display: flex;
  1057. align-items: center;
  1058. gap: 4px;
  1059. padding: 4px 10px;
  1060. border-radius: 4px;
  1061. font-size: 12px;
  1062. cursor: pointer;
  1063. transition: all 0.2s;
  1064. }
  1065. .validation-item.error {
  1066. background: #fef0f0;
  1067. color: #f56c6c;
  1068. border: 1px solid #fbc4c4;
  1069. }
  1070. .validation-item.warning {
  1071. background: #fdf6ec;
  1072. color: #e6a23c;
  1073. border: 1px solid #f5dab1;
  1074. }
  1075. .validation-item:hover {
  1076. transform: translateY(-1px);
  1077. box-shadow: 0 2px 6px rgba(0,0,0,0.1);
  1078. }
  1079. .validation-icon {
  1080. font-size: 12px;
  1081. }
  1082. .validation-msg {
  1083. max-width: 300px;
  1084. white-space: nowrap;
  1085. overflow: hidden;
  1086. text-overflow: ellipsis;
  1087. }
  1088. /* 右键菜单 */
  1089. .context-menu {
  1090. position: fixed;
  1091. background: white;
  1092. border-radius: 8px;
  1093. box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  1094. min-width: 180px;
  1095. padding: 6px 0;
  1096. z-index: 1000;
  1097. }
  1098. .context-menu-item {
  1099. display: flex;
  1100. align-items: center;
  1101. justify-content: space-between;
  1102. padding: 10px 16px;
  1103. font-size: 13px;
  1104. color: #303133;
  1105. cursor: pointer;
  1106. transition: background 0.15s;
  1107. }
  1108. .context-menu-item:hover {
  1109. background: #f5f7fa;
  1110. }
  1111. .context-menu-item.danger {
  1112. color: #f56c6c;
  1113. }
  1114. .context-menu-item.danger:hover {
  1115. background: #fef0f0;
  1116. }
  1117. .context-menu-item.disabled {
  1118. color: #c0c4cc;
  1119. cursor: not-allowed;
  1120. }
  1121. .context-menu-item.disabled:hover {
  1122. background: transparent;
  1123. }
  1124. .context-menu-item .shortcut {
  1125. font-size: 11px;
  1126. color: #909399;
  1127. margin-left: 20px;
  1128. }
  1129. .context-menu-divider {
  1130. height: 1px;
  1131. background: #e4e7ed;
  1132. margin: 6px 0;
  1133. }
  1134. /* 快捷键提示 */
  1135. .shortcuts-hint {
  1136. padding: 8px 16px;
  1137. background: #f5f7fa;
  1138. border-top: 1px solid #e4e7ed;
  1139. font-size: 11px;
  1140. color: #909399;
  1141. text-align: center;
  1142. }
  1143. /* 工具栏分隔线 */
  1144. .toolbar-left :deep(.el-divider--vertical) {
  1145. height: 20px;
  1146. margin: 0 8px;
  1147. }
  1148. /* 规则预览弹窗 */
  1149. .preview-content {
  1150. max-height: 60vh;
  1151. overflow-y: auto;
  1152. }
  1153. .preview-desc {
  1154. margin-bottom: 16px;
  1155. color: #606266;
  1156. font-size: 14px;
  1157. }
  1158. .preview-list {
  1159. display: flex;
  1160. flex-direction: column;
  1161. gap: 12px;
  1162. }
  1163. .preview-item {
  1164. border: 1px solid #e4e7ed;
  1165. border-radius: 8px;
  1166. overflow: hidden;
  1167. }
  1168. .preview-header {
  1169. display: flex;
  1170. align-items: center;
  1171. gap: 10px;
  1172. padding: 12px 16px;
  1173. background: #f5f7fa;
  1174. cursor: pointer;
  1175. transition: background 0.2s;
  1176. }
  1177. .preview-header:hover {
  1178. background: #ebeef5;
  1179. }
  1180. .preview-item.expanded .preview-header {
  1181. border-bottom: 1px solid #e4e7ed;
  1182. }
  1183. .preview-sources-count {
  1184. font-size: 12px;
  1185. color: #909399;
  1186. }
  1187. .preview-expand-icon {
  1188. margin-left: auto;
  1189. font-size: 10px;
  1190. color: #909399;
  1191. }
  1192. .preview-index {
  1193. width: 24px;
  1194. height: 24px;
  1195. display: flex;
  1196. align-items: center;
  1197. justify-content: center;
  1198. background: #409eff;
  1199. color: white;
  1200. border-radius: 50%;
  1201. font-size: 12px;
  1202. font-weight: 500;
  1203. }
  1204. .preview-element {
  1205. flex: 1;
  1206. font-size: 14px;
  1207. font-weight: 500;
  1208. color: #303133;
  1209. }
  1210. .preview-body {
  1211. padding: 12px 16px;
  1212. }
  1213. .preview-row {
  1214. display: flex;
  1215. align-items: flex-start;
  1216. gap: 12px;
  1217. margin-bottom: 8px;
  1218. }
  1219. .preview-row:last-child {
  1220. margin-bottom: 0;
  1221. }
  1222. .preview-label {
  1223. flex-shrink: 0;
  1224. width: 70px;
  1225. font-size: 12px;
  1226. color: #909399;
  1227. }
  1228. .preview-value {
  1229. flex: 1;
  1230. font-size: 13px;
  1231. color: #303133;
  1232. }
  1233. .preview-value code {
  1234. background: #f5f7fa;
  1235. padding: 2px 6px;
  1236. border-radius: 4px;
  1237. font-family: monospace;
  1238. font-size: 12px;
  1239. }
  1240. .preview-prompt {
  1241. background: #fdf6ec;
  1242. padding: 6px 10px;
  1243. border-radius: 4px;
  1244. font-size: 12px;
  1245. line-height: 1.5;
  1246. color: #e6a23c;
  1247. }
  1248. .source-tag {
  1249. margin-right: 6px;
  1250. margin-bottom: 4px;
  1251. }
  1252. </style>