Browse Source

画布页面加载成功

master
MSI-7950X\刘泽明 6 days ago
parent
commit
dd80461ac6
  1. 12
      backend/modules/rag/knowledge.py
  2. 3
      frontend/src/api/index.ts
  3. 106
      frontend/src/views/flow/FlowCanvas.vue
  4. 437
      frontend/src/views/flow/FlowEditor.vue

12
backend/modules/rag/knowledge.py

@ -1,4 +1,5 @@
import os
import asyncio
import logging
from agentscope.embedding import OpenAITextEmbedding
from agentscope.rag import SimpleKnowledge, QdrantStore, TextReader, PDFReader, WordReader, ExcelReader
@ -98,7 +99,13 @@ async def add_text(text: str, source: str = "manual") -> str:
async def search(query: str, limit: int = 5, score_threshold: float = 0.3) -> list[dict]:
try:
kb = get_knowledge_base()
docs = await kb.retrieve(query=query, limit=limit, score_threshold=score_threshold)
if not kb or not hasattr(kb, 'retrieve'):
logger.warning("知识库未初始化或不可用")
return []
docs = await asyncio.wait_for(
kb.retrieve(query=query, limit=limit, score_threshold=score_threshold),
timeout=10.0
)
return [
{
"id": doc.id,
@ -108,6 +115,9 @@ async def search(query: str, limit: int = 5, score_threshold: float = 0.3) -> li
}
for doc in docs
]
except asyncio.TimeoutError:
logger.warning(f"知识检索超时 (query={query[:50]})")
return []
except Exception as e:
logger.error(f"知识检索失败: {e}")
return []

3
frontend/src/api/index.ts

@ -20,6 +20,9 @@ api.interceptors.response.use(
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
return Promise.reject(error)
}
const msg = error.response?.data?.message || error.message || '请求失败'

106
frontend/src/views/flow/FlowCanvas.vue

@ -0,0 +1,106 @@
<template>
<VueFlow
:nodes="localNodes"
:edges="localEdges"
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
@connect="handleConnect"
@node-click="$emit('node-click', $event)"
@pane-click="$emit('pane-click')"
@drop="$emit('drop', $event)"
@dragover.prevent
>
<Background :gap="20" :size="1" />
<Controls position="bottom-right" />
<MiniMap
position="bottom-left"
:node-stroke-color="'#409EFF'"
:node-color="nodeColor"
pannable
zoomable
/>
<template #node-custom="nodeProps">
<FlowNode
:id="nodeProps.id"
:data="nodeProps.data"
:selected="nodeProps.selected"
@delete="$emit('node-delete', nodeProps.id)"
/>
</template>
</VueFlow>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { MarkerType } from '@vue-flow/core'
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 FlowNode from './FlowNode.vue'
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'
const props = defineProps<{
nodes: any[]
edges: any[]
nodeColor: (node: any) => string
}>()
const emit = defineEmits([
'connect', 'node-click', 'pane-click', 'drop', 'node-delete',
'update:nodes', 'update:edges',
])
const localNodes = ref<any[]>([])
const localEdges = ref<any[]>([])
watch(() => props.nodes, (val) => { localNodes.value = val || [] }, { immediate: true })
watch(() => props.edges, (val) => { localEdges.value = val || [] }, { immediate: true })
function onNodesChange() {
emit('update:nodes', [...localNodes.value])
}
function onEdgesChange() {
emit('update:edges', [...localEdges.value])
}
const store = useVueFlow()
function handleConnect(connection: any) {
const sourceHandle = connection.sourceHandle || 'source'
const edge: any = {
id: `edge_${connection.source}_${connection.target}_${sourceHandle}`,
source: connection.source,
target: connection.target,
sourceHandle,
targetHandle: connection.targetHandle,
type: 'smoothstep',
animated: true,
markerEnd: MarkerType.ArrowClosed,
style: sourceHandle === 'false' ? { stroke: '#F56C6C', strokeWidth: 2 } : { stroke: '#409EFF', strokeWidth: 2 },
}
localEdges.value.push(edge)
emit('update:edges', [...localEdges.value])
emit('connect', connection)
}
defineExpose({
undo: () => (store as any).undo?.(),
redo: () => (store as any).redo?.(),
get canUndo() { return !!(store as any).canUndo },
get canRedo() { return !!(store as any).canRedo },
addNodes: (n: any[]) => { localNodes.value = [...localNodes.value, ...n]; emit('update:nodes', [...localNodes.value]) },
addEdges: (e: any[]) => { localEdges.value = [...localEdges.value, ...e]; emit('update:edges', [...localEdges.value]) },
removeNodes: (ids: string[]) => {
localNodes.value = localNodes.value.filter((n: any) => !ids.includes(n.id))
localEdges.value = localEdges.value.filter((e: any) => !ids.includes(e.source) && !ids.includes(e.target))
emit('update:nodes', [...localNodes.value])
emit('update:edges', [...localEdges.value])
},
})
</script>

437
frontend/src/views/flow/FlowEditor.vue

@ -2,6 +2,8 @@
<div class="flow-editor-page">
<el-page-header @back="$router.back()" :content="isEdit ? '编辑: ' + flowName : '创建新流'" />
<el-alert v-if="initError" type="error" :title="initError" show-icon closable @close="initError = ''" style="margin-top: 12px" />
<el-card style="margin-top: 20px">
<div class="editor-toolbar">
<el-input v-model="flowName" placeholder="流名称" style="width: 200px" />
@ -14,7 +16,7 @@
</div>
</el-card>
<div class="editor-body">
<div v-if="!initError" class="editor-body">
<div class="node-panel">
<div class="panel-title">节点面板</div>
<div
@ -43,51 +45,16 @@
<span>设计画布</span>
<el-button size="small" @click="clearCanvas">清空</el-button>
</div>
<VueFlow
ref="vueFlowRef"
<FlowCanvas
ref="canvasRef"
v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 1, x: 0, y: 0 }"
:min-zoom="0.2"
:max-zoom="4"
:snap-to-grid="true"
:snap-grid="[20, 20]"
:undo-redo="true"
:connection-line-style="{ stroke: '#409EFF', strokeWidth: 2 }"
:default-edge-options="{
type: 'smoothstep',
animated: true,
markerEnd: MarkerType.ArrowClosed,
style: { stroke: '#409EFF', strokeWidth: 2 },
}"
fit-view-on-init
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
@connect="onConnect"
:node-color="getMiniMapColor"
@node-click="onNodeClick"
@pane-click="onPaneClick"
@drop="onDrop"
@dragover.prevent
>
<Background :gap="20" :size="1" />
<Controls position="bottom-right" />
<MiniMap
position="bottom-left"
:node-stroke-color="'#409EFF'"
:node-color="getMiniMapColor"
pannable
zoomable
/>
<template #node-custom="nodeProps">
<FlowNode
:id="nodeProps.id"
:data="nodeProps.data"
:selected="nodeProps.selected"
@delete="removeNode(nodeProps.id)"
/>
</template>
</VueFlow>
@node-delete="removeNode"
/>
</div>
<div class="config-panel" v-if="selectedNodeId">
@ -209,26 +176,16 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
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 { flowApi, mcpApi, agentApi } from '@/api'
import {
Promotion, ChatDotRound, Tools, Connection, Bell,
DataAnalysis, Search,
} from '@element-plus/icons-vue'
import FlowNode from './FlowNode.vue'
import { Promotion, ChatDotRound, Tools, Connection, Bell, DataAnalysis, Search } from '@element-plus/icons-vue'
import FlowCanvas from './FlowCanvas.vue'
const route = useRoute()
const router = useRouter()
const flowId = computed(() => route.params.id as string)
const isEdit = computed(() => !!flowId.value)
const initError = ref('')
const flowName = ref('新工作流')
const flowDesc = ref('')
const flowStatus = ref('')
@ -238,6 +195,12 @@ const selectedNodeData = ref<any>({})
const mcpServers = ref<any[]>([])
const agentList = ref<any[]>([])
const nodes = ref<any[]>([])
const edges = ref<any[]>([])
const canvasRef = ref<any>(null)
let nodeCounter = 0
const nodeTypes = [
{ type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '企微触发' },
{ type: 'llm', label: 'LLM处理', icon: ChatDotRound, typeDesc: 'AI处理' },
@ -260,24 +223,18 @@ const colorMap: Record<string, string> = {
output: '#722ed1',
}
const vueFlowStore = useVueFlow()
const { nodes, edges, addNodes, addEdges, removeNodes, removeEdges, toObject } = vueFlowStore
const storeAny = vueFlowStore as any
const undo = () => storeAny.undo?.()
const redo = () => storeAny.redo?.()
const canUndo = computed(() => !!(storeAny.canUndo ?? false))
const canRedo = computed(() => !!(storeAny.canRedo ?? false))
let nodeCounter = 0
function undo() { canvasRef.value?.undo() }
function redo() { canvasRef.value?.redo() }
const canUndo = computed(() => canvasRef.value?.canUndo ?? false)
const canRedo = computed(() => canvasRef.value?.canRedo ?? false)
const selectedNode = computed(() => {
return nodes.value.find((n: any) => n.id === selectedNodeId.value) || null
})
const selectedNode = computed(() => nodes.value.find((n: any) => n.id === selectedNodeId.value) || null)
function getMiniMapColor(node: any) {
return colorMap[node?.data?.type || node?.type] || '#409EFF'
}
function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) {
function onDragStart(event: DragEvent, node: any) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', JSON.stringify(node))
event.dataTransfer.effectAllowed = 'move'
@ -287,74 +244,31 @@ function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) {
function onDrop(event: DragEvent) {
const dataStr = event.dataTransfer?.getData('application/vueflow')
if (!dataStr) return
const nodeData = JSON.parse(dataStr)
let position = { x: 100, y: 100 }
try {
const bounds = (event.currentTarget as HTMLElement)?.getBoundingClientRect()
if (bounds) {
position = {
x: event.clientX - bounds.left - 80,
y: event.clientY - bounds.top - 20,
}
}
} catch { /* fallback */ }
const id = `node_${++nodeCounter}`
const newNode = {
canvasRef.value?.addNodes?.([{
id,
type: 'custom',
position,
position: { x: 100 + nodeCounter * 50, y: 100 + nodeCounter * 30 },
data: {
label: nodeData.label,
type: nodeData.type,
typeDesc: nodeData.typeDesc,
color: colorMap[nodeData.type] || '#409EFF',
config: nodeData.type === 'llm'
? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '' }
: {},
config: nodeData.type === 'llm' ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '' } : {},
},
draggable: true,
connectable: true,
}
addNodes([newNode])
}
function onConnect(connection: any) {
const sourceHandle = connection.sourceHandle || 'source'
const edgeId = `edge_${connection.source}_${connection.target}_${sourceHandle}`
const isConditionFalse = sourceHandle === 'false'
const newEdge = {
id: edgeId,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
type: 'smoothstep',
animated: true,
markerEnd: MarkerType.ArrowClosed,
style: isConditionFalse
? { stroke: '#F56C6C', strokeWidth: 2 }
: { stroke: '#409EFF', strokeWidth: 2 },
}
addEdges([newEdge])
}])
}
function onNodeClick({ node }: any) {
selectedNodeId.value = node.id
const d = node.data || {}
selectedNodeData.value = {
label: d.label || '',
typeDesc: d.typeDesc || '',
...(d.config || {}),
}
selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', ...(d.config || {}) }
}
function onPaneClick() {
selectedNodeId.value = ''
}
function onPaneClick() { selectedNodeId.value = '' }
function onConfigLabelChange() {
const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
@ -379,288 +293,121 @@ function onAgentSelect(val: string) {
function onConfigChange() {
const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
if (idx === -1) return
const found = nodes.value[idx]
const nodeType = found.data.type
const cfg: any = {}
if (nodeType === 'trigger') {
cfg.event_type = selectedNodeData.value.event_type
} else if (nodeType === 'llm') {
cfg.system_prompt = selectedNodeData.value.system_prompt
cfg.model = selectedNodeData.value.model
cfg.temperature = selectedNodeData.value.temperature
cfg.agent_id = selectedNodeData.value.agent_id
} else if (nodeType === 'tool') {
cfg.tool_name = selectedNodeData.value.tool_name
} else if (nodeType === 'mcp') {
cfg.mcp_server = selectedNodeData.value.mcp_server
cfg.tool_name = selectedNodeData.value.tool_name
} else if (nodeType === 'wecom_notify') {
cfg.message_template = selectedNodeData.value.message_template
cfg.target = selectedNodeData.value.target
} else if (nodeType === 'condition') {
cfg.condition = selectedNodeData.value.condition
} else if (nodeType === 'rag') {
cfg.knowledge_base = selectedNodeData.value.knowledge_base
cfg.top_k = selectedNodeData.value.top_k
} else if (nodeType === 'output') {
cfg.format = selectedNodeData.value.format
}
if (nodeType === 'trigger') cfg.event_type = selectedNodeData.value.event_type
else if (nodeType === 'llm') { cfg.system_prompt = selectedNodeData.value.system_prompt; cfg.model = selectedNodeData.value.model; cfg.temperature = selectedNodeData.value.temperature; cfg.agent_id = selectedNodeData.value.agent_id }
else if (nodeType === 'tool') cfg.tool_name = selectedNodeData.value.tool_name
else if (nodeType === 'mcp') { cfg.mcp_server = selectedNodeData.value.mcp_server; cfg.tool_name = selectedNodeData.value.tool_name }
else if (nodeType === 'wecom_notify') { cfg.message_template = selectedNodeData.value.message_template; cfg.target = selectedNodeData.value.target }
else if (nodeType === 'condition') cfg.condition = selectedNodeData.value.condition
else if (nodeType === 'rag') { cfg.knowledge_base = selectedNodeData.value.knowledge_base; cfg.top_k = selectedNodeData.value.top_k }
else if (nodeType === 'output') cfg.format = selectedNodeData.value.format
const updated = { ...found }
updated.data = { ...found.data, config: cfg }
nodes.value[idx] = updated
}
function removeNode(id: string) {
removeNodes([id])
edges.value = edges.value.filter((e: any) => e.source !== id && e.target !== id)
canvasRef.value?.removeNodes?.([id])
if (selectedNodeId.value === id) selectedNodeId.value = ''
}
function clearCanvas() {
nodes.value = []
edges.value = []
nodeCounter = 0
selectedNodeId.value = ''
}
function onNodesChange() {}
function onEdgesChange() {}
async function saveFlow() {
if (!flowName.value) { ElMessage.warning('请输入流名称'); return }
saving.value = true
try {
const serializedNodes = nodes.value.map((n: any) => ({
id: n.id,
type: n.data?.type || n.type,
label: n.data?.label || n.id,
config: n.data?.config || {},
position: n.position,
}))
const serializedEdges = edges.value.map((e: any) => ({
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle || 'source',
condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined),
}))
const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} }
if (isEdit.value) {
await flowApi.updateFlow(flowId.value, payload)
ElMessage.success('保存成功')
} else {
const res: any = await flowApi.createFlow(payload)
const data = res?.data || res || {}
if (data.id) {
router.replace(`/admin/flow/editor/${data.id}`)
ElMessage.success('创建成功,请继续编辑')
}
}
} finally {
saving.value = false
}
}
async function testFlow() {
if (!isEdit.value) { ElMessage.info('请先保存再进行验证'); return }
try {
const res: any = await flowApi.testFlow(flowId.value)
const data = res?.data || res || {}
if (data.valid) {
ElMessage.success(`验证通过: ${data.node_count}个节点, ${data.edge_count}条边`)
} else {
ElMessage.warning(`验证问题: ${(data.issues || []).join(', ')}`)
}
} catch { /**/ }
}
async function publishFlow() {
if (!isEdit.value) { ElMessage.warning('请先保存'); return }
await flowApi.publishFlow(flowId.value)
ElMessage.success('流已上架到企微')
await loadFlow()
}
function clearCanvas() { nodes.value = []; edges.value = []; nodeCounter = 0; selectedNodeId.value = '' }
async function loadFlow() {
if (!isEdit.value) return
try {
const { flowApi } = await import('@/api')
const res: any = await flowApi.getFlow(flowId.value)
const flow = res?.data || res || {}
flowName.value = flow.name || ''
flowDesc.value = flow.description || ''
flowStatus.value = flow.status || ''
const definition = flow.definition_json || {}
const loadedNodes: any[] = []
const loadedEdges: any[] = []
const defNodes = definition.nodes || []
for (let i = 0; i < defNodes.length; i++) {
const n = defNodes[i]
const nt = nodeTypes.find(t => t.type === n.type)
loadedNodes.push({
id: n.id || `node_${i}`,
type: 'custom',
id: n.id || `node_${i}`, type: 'custom',
position: n.position || { x: 100 + (i % 4) * 250, y: 60 + Math.floor(i / 4) * 150 },
data: {
label: n.label || n.id || `节点${i}`,
type: n.type,
typeDesc: nt?.typeDesc || n.type,
color: colorMap[n.type] || '#409EFF',
config: n.config || {},
},
draggable: true,
connectable: true,
data: { label: n.label || n.id || `节点${i}`, type: n.type, typeDesc: nt?.typeDesc || n.type, color: colorMap[n.type] || '#409EFF', config: n.config || {} },
draggable: true, connectable: true,
})
}
const defEdges = definition.edges || []
for (const e of defEdges) {
const source = e.source || e.from
const target = e.target || e.to
const cond = e.condition || e.sourceHandle
const isFalse = cond === 'false'
loadedEdges.push({
id: e.id || `edge_${source}_${target}`,
source,
target,
sourceHandle: cond || 'source',
type: 'smoothstep',
animated: true,
markerEnd: MarkerType.ArrowClosed,
style: isFalse
? { stroke: '#F56C6C', strokeWidth: 2 }
: { stroke: '#409EFF', strokeWidth: 2 },
})
loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: true, style: cond === 'false' ? { stroke: '#F56C6C', strokeWidth: 2 } : { stroke: '#409EFF', strokeWidth: 2 } })
}
nodes.value = loadedNodes
edges.value = loadedEdges
nodeCounter = defNodes.length
} catch { /**/ }
} catch (e: any) {
console.error('loadFlow error:', e)
}
}
async function loadMcpServers() {
try {
const res: any = await mcpApi.getServers()
mcpServers.value = Array.isArray(res) ? res : (res?.data || [])
} catch { /**/ }
try { const { mcpApi } = await import('@/api'); const res: any = await mcpApi.getServers(); mcpServers.value = Array.isArray(res) ? res : (res?.data || []) } catch {}
}
async function loadAgents() {
try { const { agentApi } = await import('@/api'); const res: any = await agentApi.getList(); agentList.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []) } catch {}
}
async function saveFlow() {
if (!flowName.value) { ElMessage.warning('请输入流名称'); return }
saving.value = true
try {
const res: any = await agentApi.getList()
agentList.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
} catch { /**/ }
const { flowApi } = await import('@/api')
const serializedNodes = nodes.value.map((n: any) => ({ id: n.id, type: n.data?.type || n.type, label: n.data?.label || n.id, config: n.data?.config || {}, position: n.position }))
const serializedEdges = edges.value.map((e: any) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source', condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined) }))
const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} }
if (isEdit.value) { await flowApi.updateFlow(flowId.value, payload); ElMessage.success('保存成功') }
else { const res: any = await flowApi.createFlow(payload); const data = res?.data || res || {}; if (data.id) { router.replace(`/admin/flow/editor/${data.id}`); ElMessage.success('创建成功') } }
} finally { saving.value = false }
}
async function testFlow() {
if (!isEdit.value) { ElMessage.info('请先保存再进行验证'); return }
try { const { flowApi } = await import('@/api'); const res: any = await flowApi.testFlow(flowId.value); const data = res?.data || res || {}; if (data.valid) { ElMessage.success(`验证通过: ${data.node_count}个节点, ${data.edge_count}条边`) } else { ElMessage.warning(`验证问题: ${(data.issues || []).join(', ')}`) } } catch {}
}
async function publishFlow() {
if (!isEdit.value) { ElMessage.warning('请先保存'); return }
try { const { flowApi } = await import('@/api'); await flowApi.publishFlow(flowId.value); ElMessage.success('流已上架到企微'); await loadFlow() } catch {}
}
onMounted(async () => {
if (isEdit.value) {
await loadFlow()
try {
if (isEdit.value) { await loadFlow() }
await Promise.allSettled([loadMcpServers(), loadAgents()])
} catch (e: any) {
console.error('FlowEditor init error:', e)
initError.value = '初始化失败: ' + (e?.message || '未知错误')
}
await loadMcpServers()
await loadAgents()
})
</script>
<style scoped>
.flow-editor-page {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.editor-body {
display: flex;
gap: 12px;
margin-top: 12px;
flex: 1;
min-height: 0;
}
.node-panel {
width: 160px;
background: #fff;
border-radius: 4px;
padding: 12px;
border: 1px solid #ebeef5;
overflow-y: auto;
flex-shrink: 0;
}
.panel-title {
font-weight: bold;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.node-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
margin-bottom: 8px;
border: 1px solid #e4e7ed;
border-radius: 4px;
cursor: grab;
font-size: 13px;
background: #f5f7fa;
transition: all 0.2s;
user-select: none;
}
.node-item:hover {
border-color: #409EFF;
background: #ecf5ff;
}
.panel-hint {
font-size: 12px;
color: #909399;
line-height: 1.8;
}
.panel-hint p::before {
content: '• ';
color: #409EFF;
}
.canvas-wrapper {
flex: 1;
background: #fff;
border-radius: 4px;
border: 1px solid #ebeef5;
display: flex;
flex-direction: column;
min-width: 0;
}
.canvas-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid #ebeef5;
font-weight: bold;
flex-shrink: 0;
}
.canvas-wrapper :deep(.vue-flow) {
flex: 1;
min-height: 0;
}
.config-panel {
width: 280px;
background: #fff;
border-radius: 4px;
padding: 12px;
border: 1px solid #ebeef5;
overflow-y: auto;
flex-shrink: 0;
}
.config-actions {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #ebeef5;
}
</style>
.flow-editor-page { height: 100%; display: flex; flex-direction: column; }
.editor-toolbar { display: flex; align-items: center; gap: 8px; }
.editor-body { display: flex; gap: 12px; margin-top: 12px; flex: 1; min-height: 0; }
.node-panel { width: 160px; background: #fff; border-radius: 4px; padding: 12px; border: 1px solid #ebeef5; overflow-y: auto; flex-shrink: 0; }
.panel-title { font-weight: bold; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; }
.node-item { display: flex; align-items: center; gap: 8px; padding: 8px; margin-bottom: 8px; border: 1px solid #e4e7ed; border-radius: 4px; cursor: grab; font-size: 13px; background: #f5f7fa; transition: all 0.2s; user-select: none; }
.node-item:hover { border-color: #409EFF; background: #ecf5ff; }
.panel-hint { font-size: 12px; color: #909399; line-height: 1.8; }
.panel-hint p::before { content: '• '; color: #409EFF; }
.canvas-wrapper { flex: 1; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; display: flex; flex-direction: column; min-width: 0; }
.canvas-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; border-bottom: 1px solid #ebeef5; font-weight: bold; flex-shrink: 0; }
.config-panel { width: 280px; background: #fff; border-radius: 4px; padding: 12px; border: 1px solid #ebeef5; overflow-y: auto; flex-shrink: 0; }
.config-actions { margin-top: 8px; padding-top: 8px; border-top: 1px solid #ebeef5; }
</style>

Loading…
Cancel
Save