|
|
@@ -0,0 +1,1280 @@
|
|
|
+<script setup>
|
|
|
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
+import { VueFlow, useVueFlow } from '@vue-flow/core'
|
|
|
+import { Background } from '@vue-flow/background'
|
|
|
+import { Controls } from '@vue-flow/controls'
|
|
|
+import { MiniMap } from '@vue-flow/minimap'
|
|
|
+import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
+import '@vue-flow/core/dist/style.css'
|
|
|
+import '@vue-flow/core/dist/theme-default.css'
|
|
|
+import '@vue-flow/controls/dist/style.css'
|
|
|
+import '@vue-flow/minimap/dist/style.css'
|
|
|
+
|
|
|
+import SourceNode from './nodes/SourceNode.vue'
|
|
|
+import ActionNode from './nodes/ActionNode.vue'
|
|
|
+import ElementNode from './nodes/ElementNode.vue'
|
|
|
+import NodePanel from './panels/NodePanel.vue'
|
|
|
+import PropertyPanel from './panels/PropertyPanel.vue'
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ projectId: { type: Number, required: true },
|
|
|
+ attachments: { type: Array, default: () => [] },
|
|
|
+ elements: { type: Array, default: () => [] },
|
|
|
+ rules: { type: Array, default: () => [] }
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits(['save', 'close'])
|
|
|
+
|
|
|
+const nodeTypes = {
|
|
|
+ source: SourceNode,
|
|
|
+ action: ActionNode,
|
|
|
+ element: ElementNode
|
|
|
+}
|
|
|
+
|
|
|
+const nodes = ref([])
|
|
|
+const edges = ref([])
|
|
|
+const selectedNode = ref(null)
|
|
|
+const selectedEdge = ref(null)
|
|
|
+const selectedNodes = ref([])
|
|
|
+
|
|
|
+// 撤销重做
|
|
|
+const historyStack = ref([])
|
|
|
+const historyIndex = ref(-1)
|
|
|
+const maxHistory = 50
|
|
|
+
|
|
|
+// 右键菜单
|
|
|
+const contextMenu = ref({ visible: false, x: 0, y: 0, type: '', target: null })
|
|
|
+
|
|
|
+// 工作流验证
|
|
|
+const validationErrors = ref([])
|
|
|
+const showValidation = ref(false)
|
|
|
+
|
|
|
+// 复制粘贴
|
|
|
+const clipboard = ref(null)
|
|
|
+
|
|
|
+// 规则预览
|
|
|
+const showPreview = ref(false)
|
|
|
+const previewRules = ref([])
|
|
|
+const expandedRuleIdx = ref(null)
|
|
|
+
|
|
|
+const {
|
|
|
+ onConnect,
|
|
|
+ addEdges,
|
|
|
+ onNodesChange,
|
|
|
+ onEdgesChange,
|
|
|
+ onNodeClick,
|
|
|
+ onEdgeClick,
|
|
|
+ onPaneClick,
|
|
|
+ onNodeContextMenu,
|
|
|
+ onEdgeContextMenu,
|
|
|
+ onPaneContextMenu,
|
|
|
+ project,
|
|
|
+ fitView,
|
|
|
+ getSelectedNodes,
|
|
|
+ removeNodes,
|
|
|
+ removeEdges
|
|
|
+} = useVueFlow()
|
|
|
+
|
|
|
+// 历史记录
|
|
|
+function saveHistory() {
|
|
|
+ const state = {
|
|
|
+ nodes: JSON.parse(JSON.stringify(nodes.value)),
|
|
|
+ edges: JSON.parse(JSON.stringify(edges.value))
|
|
|
+ }
|
|
|
+
|
|
|
+ if (historyIndex.value < historyStack.value.length - 1) {
|
|
|
+ historyStack.value = historyStack.value.slice(0, historyIndex.value + 1)
|
|
|
+ }
|
|
|
+
|
|
|
+ historyStack.value.push(state)
|
|
|
+ if (historyStack.value.length > maxHistory) {
|
|
|
+ historyStack.value.shift()
|
|
|
+ } else {
|
|
|
+ historyIndex.value++
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function undo() {
|
|
|
+ if (historyIndex.value > 0) {
|
|
|
+ historyIndex.value--
|
|
|
+ const state = historyStack.value[historyIndex.value]
|
|
|
+ nodes.value = JSON.parse(JSON.stringify(state.nodes))
|
|
|
+ edges.value = JSON.parse(JSON.stringify(state.edges))
|
|
|
+ selectedNode.value = null
|
|
|
+ selectedEdge.value = null
|
|
|
+ ElMessage.info('已撤销')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function redo() {
|
|
|
+ if (historyIndex.value < historyStack.value.length - 1) {
|
|
|
+ historyIndex.value++
|
|
|
+ const state = historyStack.value[historyIndex.value]
|
|
|
+ nodes.value = JSON.parse(JSON.stringify(state.nodes))
|
|
|
+ edges.value = JSON.parse(JSON.stringify(state.edges))
|
|
|
+ selectedNode.value = null
|
|
|
+ selectedEdge.value = null
|
|
|
+ ElMessage.info('已重做')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const canUndo = computed(() => historyIndex.value > 0)
|
|
|
+const canRedo = computed(() => historyIndex.value < historyStack.value.length - 1)
|
|
|
+
|
|
|
+// 连接验证
|
|
|
+onConnect((params) => {
|
|
|
+ if (validateConnection(params)) {
|
|
|
+ saveHistory()
|
|
|
+ addEdges([{
|
|
|
+ ...params,
|
|
|
+ id: `edge-${Date.now()}`,
|
|
|
+ animated: true,
|
|
|
+ style: { stroke: '#409eff', strokeWidth: 2 }
|
|
|
+ }])
|
|
|
+ } else {
|
|
|
+ ElMessage.warning('无效的连接:请检查节点类型')
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+onNodeClick(({ node }) => {
|
|
|
+ selectedNode.value = node
|
|
|
+ selectedEdge.value = null
|
|
|
+ hideContextMenu()
|
|
|
+})
|
|
|
+
|
|
|
+onEdgeClick(({ edge }) => {
|
|
|
+ selectedEdge.value = edge
|
|
|
+ selectedNode.value = null
|
|
|
+ hideContextMenu()
|
|
|
+})
|
|
|
+
|
|
|
+onPaneClick(() => {
|
|
|
+ selectedNode.value = null
|
|
|
+ selectedEdge.value = null
|
|
|
+ hideContextMenu()
|
|
|
+})
|
|
|
+
|
|
|
+// 右键菜单
|
|
|
+onNodeContextMenu(({ event, node }) => {
|
|
|
+ event.preventDefault()
|
|
|
+ selectedNode.value = node
|
|
|
+ showContextMenu(event.clientX, event.clientY, 'node', node)
|
|
|
+})
|
|
|
+
|
|
|
+onEdgeContextMenu(({ event, edge }) => {
|
|
|
+ event.preventDefault()
|
|
|
+ selectedEdge.value = edge
|
|
|
+ showContextMenu(event.clientX, event.clientY, 'edge', edge)
|
|
|
+})
|
|
|
+
|
|
|
+onPaneContextMenu(({ event }) => {
|
|
|
+ event.preventDefault()
|
|
|
+ showContextMenu(event.clientX, event.clientY, 'pane', null)
|
|
|
+})
|
|
|
+
|
|
|
+function showContextMenu(x, y, type, target) {
|
|
|
+ contextMenu.value = { visible: true, x, y, type, target }
|
|
|
+}
|
|
|
+
|
|
|
+function hideContextMenu() {
|
|
|
+ contextMenu.value.visible = false
|
|
|
+}
|
|
|
+
|
|
|
+function handleContextMenuAction(action) {
|
|
|
+ const { type, target } = contextMenu.value
|
|
|
+ hideContextMenu()
|
|
|
+
|
|
|
+ switch (action) {
|
|
|
+ case 'delete':
|
|
|
+ if (type === 'node') handleDeleteNode(target.id)
|
|
|
+ else if (type === 'edge') handleDeleteEdge(target.id)
|
|
|
+ break
|
|
|
+ case 'copy':
|
|
|
+ if (type === 'node') copyNode(target)
|
|
|
+ break
|
|
|
+ case 'paste':
|
|
|
+ pasteNode()
|
|
|
+ break
|
|
|
+ case 'duplicate':
|
|
|
+ if (type === 'node') duplicateNode(target)
|
|
|
+ break
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function validateConnection(params) {
|
|
|
+ const sourceNode = nodes.value.find(n => n.id === params.source)
|
|
|
+ const targetNode = nodes.value.find(n => n.id === params.target)
|
|
|
+
|
|
|
+ if (!sourceNode || !targetNode) return false
|
|
|
+ if (params.source === params.target) return false
|
|
|
+
|
|
|
+ // 检查是否已存在连接
|
|
|
+ const existingEdge = edges.value.find(e =>
|
|
|
+ e.source === params.source && e.target === params.target
|
|
|
+ )
|
|
|
+ if (existingEdge) return false
|
|
|
+
|
|
|
+ // 验证连接规则
|
|
|
+ if (sourceNode.type === 'source' && targetNode.type === 'element') return true
|
|
|
+ if (sourceNode.type === 'source' && targetNode.type === 'action') return true
|
|
|
+ if (sourceNode.type === 'action' && targetNode.type === 'element') return true
|
|
|
+ if (sourceNode.type === 'action' && targetNode.type === 'action') return true
|
|
|
+
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+function onDragOver(event) {
|
|
|
+ event.preventDefault()
|
|
|
+ event.dataTransfer.dropEffect = 'move'
|
|
|
+}
|
|
|
+
|
|
|
+function onDrop(event) {
|
|
|
+ event.preventDefault()
|
|
|
+
|
|
|
+ const dataStr = event.dataTransfer.getData('application/vueflow')
|
|
|
+ if (!dataStr) return
|
|
|
+
|
|
|
+ saveHistory()
|
|
|
+
|
|
|
+ const data = JSON.parse(dataStr)
|
|
|
+ const position = project({ x: event.clientX - 220, y: event.clientY - 60 })
|
|
|
+
|
|
|
+ const newNode = {
|
|
|
+ id: `node-${Date.now()}`,
|
|
|
+ type: data.nodeType,
|
|
|
+ position,
|
|
|
+ data: {
|
|
|
+ ...data,
|
|
|
+ label: data.label || data.nodeType
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ nodes.value.push(newNode)
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ selectedNode.value = newNode
|
|
|
+ }, 50)
|
|
|
+}
|
|
|
+
|
|
|
+function handleNodeUpdate(nodeId, newData) {
|
|
|
+ saveHistory()
|
|
|
+ const node = nodes.value.find(n => n.id === nodeId)
|
|
|
+ if (node) {
|
|
|
+ node.data = { ...node.data, ...newData }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleDeleteNode(nodeId) {
|
|
|
+ saveHistory()
|
|
|
+ nodes.value = nodes.value.filter(n => n.id !== nodeId)
|
|
|
+ edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId)
|
|
|
+ selectedNode.value = null
|
|
|
+}
|
|
|
+
|
|
|
+function handleDeleteEdge(edgeId) {
|
|
|
+ saveHistory()
|
|
|
+ edges.value = edges.value.filter(e => e.id !== edgeId)
|
|
|
+ selectedEdge.value = null
|
|
|
+}
|
|
|
+
|
|
|
+// 复制粘贴
|
|
|
+function copyNode(node) {
|
|
|
+ clipboard.value = JSON.parse(JSON.stringify(node))
|
|
|
+ ElMessage.success('已复制节点')
|
|
|
+}
|
|
|
+
|
|
|
+function pasteNode() {
|
|
|
+ if (!clipboard.value) {
|
|
|
+ ElMessage.warning('剪贴板为空')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ saveHistory()
|
|
|
+ const newNode = {
|
|
|
+ ...clipboard.value,
|
|
|
+ id: `node-${Date.now()}`,
|
|
|
+ position: {
|
|
|
+ x: clipboard.value.position.x + 50,
|
|
|
+ y: clipboard.value.position.y + 50
|
|
|
+ }
|
|
|
+ }
|
|
|
+ nodes.value.push(newNode)
|
|
|
+ selectedNode.value = newNode
|
|
|
+ ElMessage.success('已粘贴节点')
|
|
|
+}
|
|
|
+
|
|
|
+function duplicateNode(node) {
|
|
|
+ saveHistory()
|
|
|
+ const newNode = {
|
|
|
+ ...JSON.parse(JSON.stringify(node)),
|
|
|
+ id: `node-${Date.now()}`,
|
|
|
+ position: {
|
|
|
+ x: node.position.x + 50,
|
|
|
+ y: node.position.y + 50
|
|
|
+ }
|
|
|
+ }
|
|
|
+ nodes.value.push(newNode)
|
|
|
+ selectedNode.value = newNode
|
|
|
+}
|
|
|
+
|
|
|
+// 工作流验证
|
|
|
+function validateWorkflow() {
|
|
|
+ const errors = []
|
|
|
+
|
|
|
+ // 检查孤立节点
|
|
|
+ const connectedNodeIds = new Set()
|
|
|
+ edges.value.forEach(e => {
|
|
|
+ connectedNodeIds.add(e.source)
|
|
|
+ connectedNodeIds.add(e.target)
|
|
|
+ })
|
|
|
+
|
|
|
+ nodes.value.forEach(node => {
|
|
|
+ if (!connectedNodeIds.has(node.id)) {
|
|
|
+ errors.push({ type: 'warning', nodeId: node.id, message: `节点 "${node.data.label}" 未连接` })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 检查来源节点配置
|
|
|
+ nodes.value.filter(n => n.type === 'source').forEach(node => {
|
|
|
+ if (node.data.subType === 'attachment' && !node.data.sourceNodeId) {
|
|
|
+ errors.push({ type: 'error', nodeId: node.id, message: `来源节点 "${node.data.label}" 未选择附件` })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 检查输出节点配置
|
|
|
+ nodes.value.filter(n => n.type === 'element').forEach(node => {
|
|
|
+ if (!node.data.elementKey) {
|
|
|
+ errors.push({ type: 'error', nodeId: node.id, message: `输出节点 "${node.data.label}" 未选择要素` })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 检查动作节点配置
|
|
|
+ nodes.value.filter(n => n.type === 'action').forEach(node => {
|
|
|
+ const actionType = node.data.actionType || node.data.subType
|
|
|
+ if (['summary', 'ai_extract'].includes(actionType) && !node.data.prompt) {
|
|
|
+ errors.push({ type: 'warning', nodeId: node.id, message: `动作节点 "${node.data.label}" 建议配置提示词` })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 检查完整的数据流
|
|
|
+ const elementNodes = nodes.value.filter(n => n.type === 'element')
|
|
|
+ elementNodes.forEach(elemNode => {
|
|
|
+ const hasInput = edges.value.some(e => e.target === elemNode.id)
|
|
|
+ if (!hasInput) {
|
|
|
+ errors.push({ type: 'error', nodeId: elemNode.id, message: `输出节点 "${elemNode.data.label}" 没有输入连接` })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ validationErrors.value = errors
|
|
|
+ showValidation.value = true
|
|
|
+
|
|
|
+ if (errors.length === 0) {
|
|
|
+ ElMessage.success('工作流验证通过')
|
|
|
+ } else {
|
|
|
+ const errorCount = errors.filter(e => e.type === 'error').length
|
|
|
+ const warningCount = errors.filter(e => e.type === 'warning').length
|
|
|
+ ElMessage.warning(`发现 ${errorCount} 个错误,${warningCount} 个警告`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return errors.filter(e => e.type === 'error').length === 0
|
|
|
+}
|
|
|
+
|
|
|
+function highlightNode(nodeId) {
|
|
|
+ const node = nodes.value.find(n => n.id === nodeId)
|
|
|
+ if (node) {
|
|
|
+ selectedNode.value = node
|
|
|
+ // 滚动到节点位置
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleSave() {
|
|
|
+ if (!validateWorkflow()) {
|
|
|
+ ElMessageBox.confirm('工作流存在错误,是否仍要保存?', '验证警告', {
|
|
|
+ confirmButtonText: '继续保存',
|
|
|
+ cancelButtonText: '返回修改',
|
|
|
+ type: 'warning'
|
|
|
+ }).then(() => {
|
|
|
+ showRulePreview()
|
|
|
+ }).catch(() => {})
|
|
|
+ } else {
|
|
|
+ showRulePreview()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function showRulePreview() {
|
|
|
+ const rules = generateRulesFromWorkflow()
|
|
|
+ if (rules.length === 0) {
|
|
|
+ ElMessage.warning('没有可保存的规则,请确保有完整的数据流(来源 → 输出)')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ previewRules.value = rules
|
|
|
+ showPreview.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function generateRulesFromWorkflow() {
|
|
|
+ const rules = []
|
|
|
+ const elementNodes = nodes.value.filter(n => n.type === 'element')
|
|
|
+
|
|
|
+ function traceDataFlow(nodeId, visited = new Set()) {
|
|
|
+ if (visited.has(nodeId)) return { sources: [], actions: [] }
|
|
|
+ visited.add(nodeId)
|
|
|
+
|
|
|
+ const node = nodes.value.find(n => n.id === nodeId)
|
|
|
+ if (!node) return { sources: [], actions: [] }
|
|
|
+
|
|
|
+ if (node.type === 'source') {
|
|
|
+ return { sources: [node], actions: [] }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (node.type === 'action') {
|
|
|
+ const inEdges = edges.value.filter(e => e.target === nodeId)
|
|
|
+ let allSources = []
|
|
|
+ let allActions = [node]
|
|
|
+
|
|
|
+ for (const edge of inEdges) {
|
|
|
+ const upstream = traceDataFlow(edge.source, visited)
|
|
|
+ allSources = [...allSources, ...upstream.sources]
|
|
|
+ allActions = [...allActions, ...upstream.actions]
|
|
|
+ }
|
|
|
+
|
|
|
+ return { sources: allSources, actions: allActions }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { sources: [], actions: [] }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const elementNode of elementNodes) {
|
|
|
+ if (!elementNode.data.elementKey) continue
|
|
|
+
|
|
|
+ const incomingEdges = edges.value.filter(e => e.target === elementNode.id)
|
|
|
+ if (incomingEdges.length === 0) continue
|
|
|
+
|
|
|
+ let allSources = []
|
|
|
+ let allActions = []
|
|
|
+
|
|
|
+ for (const edge of incomingEdges) {
|
|
|
+ const { sources, actions } = traceDataFlow(edge.source)
|
|
|
+ allSources = [...allSources, ...sources]
|
|
|
+ allActions = [...allActions, ...actions]
|
|
|
+ }
|
|
|
+
|
|
|
+ const uniqueSources = [...new Map(allSources.map(s => [s.id, s])).values()]
|
|
|
+ const uniqueActions = [...new Map(allActions.map(a => [a.id, a])).values()]
|
|
|
+
|
|
|
+ const directInputNode = nodes.value.find(n => n.id === incomingEdges[0].source)
|
|
|
+ let primaryAction = directInputNode?.type === 'action' ? directInputNode : uniqueActions[0]
|
|
|
+
|
|
|
+ const actionType = primaryAction?.data?.actionType || primaryAction?.data?.subType || 'quote'
|
|
|
+
|
|
|
+ rules.push({
|
|
|
+ elementKey: elementNode.data.elementKey,
|
|
|
+ elementName: elementNode.data.elementName || elementNode.data.label,
|
|
|
+ actionType: actionType,
|
|
|
+ actionLabel: getActionLabel(actionType),
|
|
|
+ prompt: primaryAction?.data?.prompt || '',
|
|
|
+ sources: uniqueSources.map(s => ({
|
|
|
+ name: s.data.sourceName || s.data.label,
|
|
|
+ type: s.data.subType,
|
|
|
+ locatorType: s.data.locatorType
|
|
|
+ })),
|
|
|
+ actionsCount: uniqueActions.length
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return rules
|
|
|
+}
|
|
|
+
|
|
|
+function confirmSave() {
|
|
|
+ showPreview.value = false
|
|
|
+ doSave()
|
|
|
+}
|
|
|
+
|
|
|
+function doSave() {
|
|
|
+ const workflowData = {
|
|
|
+ nodes: nodes.value,
|
|
|
+ edges: edges.value
|
|
|
+ }
|
|
|
+ emit('save', workflowData)
|
|
|
+}
|
|
|
+
|
|
|
+function handleClear() {
|
|
|
+ ElMessageBox.confirm('确定要清空画布吗?此操作不可撤销。', '确认清空', {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }).then(() => {
|
|
|
+ saveHistory()
|
|
|
+ nodes.value = []
|
|
|
+ edges.value = []
|
|
|
+ selectedNode.value = null
|
|
|
+ selectedEdge.value = null
|
|
|
+ ElMessage.success('画布已清空')
|
|
|
+ }).catch(() => {})
|
|
|
+}
|
|
|
+
|
|
|
+function handleFitView() {
|
|
|
+ fitView({ padding: 0.2 })
|
|
|
+}
|
|
|
+
|
|
|
+// 快捷键
|
|
|
+function handleKeydown(event) {
|
|
|
+ // 忽略输入框中的快捷键
|
|
|
+ if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) return
|
|
|
+
|
|
|
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
|
|
+ const ctrlKey = isMac ? event.metaKey : event.ctrlKey
|
|
|
+
|
|
|
+ switch (event.key) {
|
|
|
+ case 'Delete':
|
|
|
+ case 'Backspace':
|
|
|
+ if (selectedNode.value) {
|
|
|
+ handleDeleteNode(selectedNode.value.id)
|
|
|
+ } else if (selectedEdge.value) {
|
|
|
+ handleDeleteEdge(selectedEdge.value.id)
|
|
|
+ }
|
|
|
+ event.preventDefault()
|
|
|
+ break
|
|
|
+ case 'z':
|
|
|
+ if (ctrlKey && event.shiftKey) {
|
|
|
+ redo()
|
|
|
+ event.preventDefault()
|
|
|
+ } else if (ctrlKey) {
|
|
|
+ undo()
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 'y':
|
|
|
+ if (ctrlKey) {
|
|
|
+ redo()
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 'c':
|
|
|
+ if (ctrlKey && selectedNode.value) {
|
|
|
+ copyNode(selectedNode.value)
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 'v':
|
|
|
+ if (ctrlKey) {
|
|
|
+ pasteNode()
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 'd':
|
|
|
+ if (ctrlKey && selectedNode.value) {
|
|
|
+ duplicateNode(selectedNode.value)
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 's':
|
|
|
+ if (ctrlKey) {
|
|
|
+ handleSave()
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 'Escape':
|
|
|
+ selectedNode.value = null
|
|
|
+ selectedEdge.value = null
|
|
|
+ hideContextMenu()
|
|
|
+ break
|
|
|
+ case 'a':
|
|
|
+ if (ctrlKey) {
|
|
|
+ // 全选节点
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ if (props.rules && props.rules.length > 0) {
|
|
|
+ loadRulesAsWorkflow(props.rules)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存初始状态
|
|
|
+ saveHistory()
|
|
|
+
|
|
|
+ // 注册快捷键
|
|
|
+ window.addEventListener('keydown', handleKeydown)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('keydown', handleKeydown)
|
|
|
+})
|
|
|
+
|
|
|
+function loadRulesAsWorkflow(rules) {
|
|
|
+ const newNodes = []
|
|
|
+ const newEdges = []
|
|
|
+ let xOffset = 100
|
|
|
+ let yOffset = 100
|
|
|
+
|
|
|
+ rules.forEach((rule, index) => {
|
|
|
+ const y = yOffset + index * 150
|
|
|
+
|
|
|
+ if (rule.inputs && rule.inputs.length > 0) {
|
|
|
+ const input = rule.inputs[0]
|
|
|
+ const sourceId = `source-${rule.id}-${index}`
|
|
|
+ newNodes.push({
|
|
|
+ id: sourceId,
|
|
|
+ type: 'source',
|
|
|
+ position: { x: xOffset, y },
|
|
|
+ data: {
|
|
|
+ nodeType: 'source',
|
|
|
+ subType: input.inputType || 'attachment',
|
|
|
+ label: input.sourceName || input.inputName || '来源',
|
|
|
+ sourceNodeId: input.sourceNodeId,
|
|
|
+ sourceName: input.sourceName || input.inputName,
|
|
|
+ sourceText: input.sourceText
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ if (rule.actionType && rule.actionType !== 'quote') {
|
|
|
+ const actionId = `action-${rule.id}-${index}`
|
|
|
+ newNodes.push({
|
|
|
+ id: actionId,
|
|
|
+ type: 'action',
|
|
|
+ position: { x: xOffset + 200, y },
|
|
|
+ data: {
|
|
|
+ nodeType: 'action',
|
|
|
+ subType: rule.actionType,
|
|
|
+ label: getActionLabel(rule.actionType),
|
|
|
+ actionType: rule.actionType,
|
|
|
+ prompt: rule.actionConfig ? JSON.parse(rule.actionConfig).prompt : ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ newEdges.push({
|
|
|
+ id: `edge-${sourceId}-${actionId}`,
|
|
|
+ source: sourceId,
|
|
|
+ target: actionId,
|
|
|
+ animated: true,
|
|
|
+ style: { stroke: '#409eff', strokeWidth: 2 }
|
|
|
+ })
|
|
|
+
|
|
|
+ const elementId = `element-${rule.id}-${index}`
|
|
|
+ newNodes.push({
|
|
|
+ id: elementId,
|
|
|
+ type: 'element',
|
|
|
+ position: { x: xOffset + 400, y },
|
|
|
+ data: {
|
|
|
+ nodeType: 'element',
|
|
|
+ label: rule.elementKey,
|
|
|
+ elementKey: rule.elementKey,
|
|
|
+ elementName: getElementName(rule.elementKey)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ newEdges.push({
|
|
|
+ id: `edge-${actionId}-${elementId}`,
|
|
|
+ source: actionId,
|
|
|
+ target: elementId,
|
|
|
+ animated: true,
|
|
|
+ style: { stroke: '#409eff', strokeWidth: 2 }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ const elementId = `element-${rule.id}-${index}`
|
|
|
+ newNodes.push({
|
|
|
+ id: elementId,
|
|
|
+ type: 'element',
|
|
|
+ position: { x: xOffset + 250, y },
|
|
|
+ data: {
|
|
|
+ nodeType: 'element',
|
|
|
+ label: rule.elementKey,
|
|
|
+ elementKey: rule.elementKey,
|
|
|
+ elementName: getElementName(rule.elementKey)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ newEdges.push({
|
|
|
+ id: `edge-${sourceId}-${elementId}`,
|
|
|
+ source: sourceId,
|
|
|
+ target: elementId,
|
|
|
+ animated: true,
|
|
|
+ style: { stroke: '#67c23a', strokeWidth: 2 }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ nodes.value = newNodes
|
|
|
+ edges.value = newEdges
|
|
|
+
|
|
|
+ setTimeout(() => fitView({ padding: 0.2 }), 100)
|
|
|
+}
|
|
|
+
|
|
|
+function getActionLabel(actionType) {
|
|
|
+ const labels = {
|
|
|
+ quote: '引用',
|
|
|
+ summary: 'AI 总结',
|
|
|
+ ai_extract: 'AI 提取',
|
|
|
+ table_extract: '表格提取'
|
|
|
+ }
|
|
|
+ return labels[actionType] || actionType
|
|
|
+}
|
|
|
+
|
|
|
+function getElementName(elementKey) {
|
|
|
+ const elem = props.elements.find(e => e.elementKey === elementKey)
|
|
|
+ return elem ? elem.elementName : elementKey
|
|
|
+}
|
|
|
+
|
|
|
+function getActionTagType(actionType) {
|
|
|
+ const types = {
|
|
|
+ quote: 'success',
|
|
|
+ summary: 'warning',
|
|
|
+ ai_extract: '',
|
|
|
+ table_extract: 'info'
|
|
|
+ }
|
|
|
+ return types[actionType] || 'info'
|
|
|
+}
|
|
|
+
|
|
|
+defineExpose({
|
|
|
+ handleSave,
|
|
|
+ handleClear,
|
|
|
+ handleFitView,
|
|
|
+ undo,
|
|
|
+ redo,
|
|
|
+ validateWorkflow
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="rule-workflow" @click="hideContextMenu">
|
|
|
+ <div class="workflow-toolbar">
|
|
|
+ <div class="toolbar-left">
|
|
|
+ <el-button-group>
|
|
|
+ <el-button size="small" :disabled="!canUndo" @click="undo" title="撤销 (Ctrl+Z)">
|
|
|
+ ↩️ 撤销
|
|
|
+ </el-button>
|
|
|
+ <el-button size="small" :disabled="!canRedo" @click="redo" title="重做 (Ctrl+Y)">
|
|
|
+ ↪️ 重做
|
|
|
+ </el-button>
|
|
|
+ </el-button-group>
|
|
|
+
|
|
|
+ <el-divider direction="vertical" />
|
|
|
+
|
|
|
+ <el-button size="small" @click="handleFitView" title="适应视图">📐 适应</el-button>
|
|
|
+ <el-button size="small" @click="validateWorkflow" title="验证工作流">✅ 验证</el-button>
|
|
|
+ <el-button size="small" type="danger" plain @click="handleClear">🗑️ 清空</el-button>
|
|
|
+
|
|
|
+ <el-divider direction="vertical" />
|
|
|
+
|
|
|
+ <el-button type="primary" size="small" @click="handleSave" title="保存 (Ctrl+S)">
|
|
|
+ 💾 保存规则
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="toolbar-right">
|
|
|
+ <span class="workflow-stats">
|
|
|
+ 节点: {{ nodes.length }} | 连线: {{ edges.length }}
|
|
|
+ </span>
|
|
|
+ <el-tag v-if="validationErrors.length > 0" type="warning" size="small">
|
|
|
+ {{ validationErrors.filter(e => e.type === 'error').length }} 错误
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 验证结果面板 -->
|
|
|
+ <div class="validation-panel" v-if="showValidation && validationErrors.length > 0">
|
|
|
+ <div class="validation-header">
|
|
|
+ <span>验证结果</span>
|
|
|
+ <el-button text size="small" @click="showValidation = false">✕</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="validation-list">
|
|
|
+ <div
|
|
|
+ v-for="(err, idx) in validationErrors"
|
|
|
+ :key="idx"
|
|
|
+ class="validation-item"
|
|
|
+ :class="err.type"
|
|
|
+ @click="highlightNode(err.nodeId)"
|
|
|
+ >
|
|
|
+ <span class="validation-icon">{{ err.type === 'error' ? '❌' : '⚠️' }}</span>
|
|
|
+ <span class="validation-msg">{{ err.message }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="workflow-container">
|
|
|
+ <NodePanel
|
|
|
+ :attachments="attachments"
|
|
|
+ :elements="elements"
|
|
|
+ class="workflow-node-panel"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="workflow-canvas" @dragover="onDragOver" @drop="onDrop">
|
|
|
+ <VueFlow
|
|
|
+ v-model:nodes="nodes"
|
|
|
+ v-model:edges="edges"
|
|
|
+ :node-types="nodeTypes"
|
|
|
+ :default-viewport="{ zoom: 1, x: 0, y: 0 }"
|
|
|
+ :min-zoom="0.2"
|
|
|
+ :max-zoom="2"
|
|
|
+ fit-view-on-init
|
|
|
+ class="vue-flow-wrapper"
|
|
|
+ >
|
|
|
+ <Background pattern-color="#aaa" :gap="16" />
|
|
|
+ <Controls position="bottom-left" />
|
|
|
+ <MiniMap position="bottom-right" />
|
|
|
+ </VueFlow>
|
|
|
+
|
|
|
+ <!-- 右键菜单 -->
|
|
|
+ <div
|
|
|
+ v-if="contextMenu.visible"
|
|
|
+ class="context-menu"
|
|
|
+ :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
|
|
+ @click.stop
|
|
|
+ >
|
|
|
+ <template v-if="contextMenu.type === 'node'">
|
|
|
+ <div class="context-menu-item" @click="handleContextMenuAction('copy')">
|
|
|
+ 📋 复制 <span class="shortcut">Ctrl+C</span>
|
|
|
+ </div>
|
|
|
+ <div class="context-menu-item" @click="handleContextMenuAction('duplicate')">
|
|
|
+ 📑 复制节点 <span class="shortcut">Ctrl+D</span>
|
|
|
+ </div>
|
|
|
+ <div class="context-menu-divider"></div>
|
|
|
+ <div class="context-menu-item danger" @click="handleContextMenuAction('delete')">
|
|
|
+ 🗑️ 删除 <span class="shortcut">Delete</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="contextMenu.type === 'edge'">
|
|
|
+ <div class="context-menu-item danger" @click="handleContextMenuAction('delete')">
|
|
|
+ 🗑️ 删除连线 <span class="shortcut">Delete</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <div class="context-menu-item" @click="handleContextMenuAction('paste')" :class="{ disabled: !clipboard }">
|
|
|
+ 📋 粘贴 <span class="shortcut">Ctrl+V</span>
|
|
|
+ </div>
|
|
|
+ <div class="context-menu-divider"></div>
|
|
|
+ <div class="context-menu-item" @click="handleFitView">
|
|
|
+ 📐 适应视图
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <PropertyPanel
|
|
|
+ :selected-node="selectedNode"
|
|
|
+ :selected-edge="selectedEdge"
|
|
|
+ :attachments="attachments"
|
|
|
+ :elements="elements"
|
|
|
+ class="workflow-property-panel"
|
|
|
+ @update-node="handleNodeUpdate"
|
|
|
+ @delete-node="handleDeleteNode"
|
|
|
+ @delete-edge="handleDeleteEdge"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 快捷键提示 -->
|
|
|
+ <div class="shortcuts-hint">
|
|
|
+ <span>快捷键: Ctrl+S 保存 | Ctrl+Z 撤销 | Ctrl+Y 重做 | Delete 删除 | Ctrl+C/V 复制粘贴</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 规则预览弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="showPreview"
|
|
|
+ title="📋 规则预览"
|
|
|
+ width="700"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ >
|
|
|
+ <div class="preview-content">
|
|
|
+ <p class="preview-desc">将创建以下 <strong>{{ previewRules.length }}</strong> 条规则:</p>
|
|
|
+
|
|
|
+ <div class="preview-list">
|
|
|
+ <div
|
|
|
+ v-for="(rule, idx) in previewRules"
|
|
|
+ :key="idx"
|
|
|
+ class="preview-item"
|
|
|
+ :class="{ expanded: expandedRuleIdx === idx }"
|
|
|
+ >
|
|
|
+ <div class="preview-header" @click="expandedRuleIdx = expandedRuleIdx === idx ? null : idx">
|
|
|
+ <span class="preview-index">{{ idx + 1 }}</span>
|
|
|
+ <span class="preview-element">{{ rule.elementName }}</span>
|
|
|
+ <el-tag size="small" :type="getActionTagType(rule.actionType)">
|
|
|
+ {{ rule.actionLabel }}
|
|
|
+ </el-tag>
|
|
|
+ <span class="preview-sources-count" v-if="rule.sources.length > 0">
|
|
|
+ 📎 {{ rule.sources.length }}
|
|
|
+ </span>
|
|
|
+ <span class="preview-expand-icon">{{ expandedRuleIdx === idx ? '▼' : '▶' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="preview-body" v-show="expandedRuleIdx === idx">
|
|
|
+ <div class="preview-row">
|
|
|
+ <span class="preview-label">要素标识:</span>
|
|
|
+ <code class="preview-value">{{ rule.elementKey }}</code>
|
|
|
+ </div>
|
|
|
+ <div class="preview-row" v-if="rule.sources.length > 0">
|
|
|
+ <span class="preview-label">数据来源:</span>
|
|
|
+ <span class="preview-value">
|
|
|
+ <el-tag v-for="(src, i) in rule.sources" :key="i" size="small" type="info" class="source-tag">
|
|
|
+ 📎 {{ src.name }}
|
|
|
+ </el-tag>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="preview-row" v-if="rule.prompt">
|
|
|
+ <span class="preview-label">提示词:</span>
|
|
|
+ <span class="preview-value preview-prompt">{{ rule.prompt }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="showPreview = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="confirmSave">
|
|
|
+ 确认保存 ({{ previewRules.length }} 条规则)
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.rule-workflow {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-toolbar {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 16px;
|
|
|
+ background: white;
|
|
|
+ border-bottom: 1px solid #e4e7ed;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-left {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-right {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-stats {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-container {
|
|
|
+ display: flex;
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-node-panel {
|
|
|
+ width: 220px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ background: white;
|
|
|
+ border-right: 1px solid #e4e7ed;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-canvas {
|
|
|
+ flex: 1;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.vue-flow-wrapper {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-property-panel {
|
|
|
+ width: 300px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ background: white;
|
|
|
+ border-left: 1px solid #e4e7ed;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.vue-flow__node) {
|
|
|
+ cursor: grab;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.vue-flow__node:active) {
|
|
|
+ cursor: grabbing;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.vue-flow__edge-path) {
|
|
|
+ stroke-width: 2;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.vue-flow__handle) {
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #409eff;
|
|
|
+ border: 2px solid white;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.vue-flow__handle-left) {
|
|
|
+ left: -6px;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.vue-flow__handle-right) {
|
|
|
+ right: -6px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 验证面板 */
|
|
|
+.validation-panel {
|
|
|
+ background: #fef0f0;
|
|
|
+ border-bottom: 1px solid #fbc4c4;
|
|
|
+ padding: 8px 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.validation-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #f56c6c;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.validation-list {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.validation-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.validation-item.error {
|
|
|
+ background: #fef0f0;
|
|
|
+ color: #f56c6c;
|
|
|
+ border: 1px solid #fbc4c4;
|
|
|
+}
|
|
|
+
|
|
|
+.validation-item.warning {
|
|
|
+ background: #fdf6ec;
|
|
|
+ color: #e6a23c;
|
|
|
+ border: 1px solid #f5dab1;
|
|
|
+}
|
|
|
+
|
|
|
+.validation-item:hover {
|
|
|
+ transform: translateY(-1px);
|
|
|
+ box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.validation-icon {
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.validation-msg {
|
|
|
+ max-width: 300px;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+}
|
|
|
+
|
|
|
+/* 右键菜单 */
|
|
|
+.context-menu {
|
|
|
+ position: fixed;
|
|
|
+ background: white;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
|
|
+ min-width: 180px;
|
|
|
+ padding: 6px 0;
|
|
|
+ z-index: 1000;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 10px 16px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.15s;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-item:hover {
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-item.danger {
|
|
|
+ color: #f56c6c;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-item.danger:hover {
|
|
|
+ background: #fef0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-item.disabled {
|
|
|
+ color: #c0c4cc;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-item.disabled:hover {
|
|
|
+ background: transparent;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-item .shortcut {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #909399;
|
|
|
+ margin-left: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.context-menu-divider {
|
|
|
+ height: 1px;
|
|
|
+ background: #e4e7ed;
|
|
|
+ margin: 6px 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 快捷键提示 */
|
|
|
+.shortcuts-hint {
|
|
|
+ padding: 8px 16px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border-top: 1px solid #e4e7ed;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #909399;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+/* 工具栏分隔线 */
|
|
|
+.toolbar-left :deep(.el-divider--vertical) {
|
|
|
+ height: 20px;
|
|
|
+ margin: 0 8px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 规则预览弹窗 */
|
|
|
+.preview-content {
|
|
|
+ max-height: 60vh;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-desc {
|
|
|
+ margin-bottom: 16px;
|
|
|
+ color: #606266;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-item {
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-header:hover {
|
|
|
+ background: #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-item.expanded .preview-header {
|
|
|
+ border-bottom: 1px solid #e4e7ed;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-sources-count {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-expand-icon {
|
|
|
+ margin-left: auto;
|
|
|
+ font-size: 10px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-index {
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #409eff;
|
|
|
+ color: white;
|
|
|
+ border-radius: 50%;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-element {
|
|
|
+ flex: 1;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-body {
|
|
|
+ padding: 12px 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-row:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-label {
|
|
|
+ flex-shrink: 0;
|
|
|
+ width: 70px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-value {
|
|
|
+ flex: 1;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-value code {
|
|
|
+ background: #f5f7fa;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-family: monospace;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-prompt {
|
|
|
+ background: #fdf6ec;
|
|
|
+ padding: 6px 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+ color: #e6a23c;
|
|
|
+}
|
|
|
+
|
|
|
+.source-tag {
|
|
|
+ margin-right: 6px;
|
|
|
+ margin-bottom: 4px;
|
|
|
+}
|
|
|
+</style>
|