You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
486 lines
22 KiB
486 lines
22 KiB
<template>
|
|
<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" />
|
|
<el-input v-model="flowDesc" placeholder="描述" style="width: 300px; margin-left: 12px" />
|
|
<el-select v-model="flowMode" style="width: 140px; margin-left: 12px" :disabled="isEdit">
|
|
<el-option label="对话型 (Chatflow)" value="chatflow" />
|
|
<el-option label="工作流型 (Workflow)" value="workflow" />
|
|
</el-select>
|
|
<el-button type="primary" @click="saveFlow" :loading="saving">保存</el-button>
|
|
<el-button @click="testFlow">验证</el-button>
|
|
<el-button v-if="isEdit" type="success" @click="publishFlow">上架到企微</el-button>
|
|
<el-button v-if="isEdit" type="primary" @click="publishToWeb">上架到网页</el-button>
|
|
<el-button v-if="isEdit" @click="showVersionHistory">版本历史</el-button>
|
|
</div>
|
|
</el-card>
|
|
|
|
<div v-if="!initError" class="editor-body">
|
|
<div class="node-panel">
|
|
<div class="panel-title">节点面板</div>
|
|
<div
|
|
v-for="node in nodeTypes"
|
|
:key="node.type"
|
|
class="node-item"
|
|
draggable="true"
|
|
@dragstart="onDragStart($event, node)"
|
|
>
|
|
<el-icon :size="18"><component :is="node.icon" /></el-icon>
|
|
<span>{{ node.label }}</span>
|
|
</div>
|
|
<el-divider />
|
|
<div class="panel-title">操作提示</div>
|
|
<div class="panel-hint">
|
|
<p>拖拽节点到画布</p>
|
|
<p>从绿色圆点拖线(true)</p>
|
|
<p>从红色圆点拖线(false)</p>
|
|
<p>循环: 青色(循环体)/灰色(完成)</p>
|
|
<p>选中连线/节点按 Delete 删除</p>
|
|
<p>右键点击连线可删除</p>
|
|
<p>点击空白处取消选中</p>
|
|
<p>滚轮缩放画布</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="canvas-wrapper">
|
|
<div class="canvas-header">
|
|
<span>设计画布</span>
|
|
<el-button size="small" @click="clearCanvas">清空</el-button>
|
|
</div>
|
|
<FlowCanvas
|
|
ref="canvasRef"
|
|
:nodes="nodes"
|
|
:edges="edges"
|
|
:node-color="getMiniMapColor"
|
|
@node-click="onNodeClick"
|
|
@pane-click="onPaneClick"
|
|
@drop="onDrop"
|
|
@node-delete="removeNode"
|
|
@update:nodes="(v: any) => nodes = v"
|
|
@update:edges="(v: any) => edges = v"
|
|
/>
|
|
</div>
|
|
|
|
<div class="config-panel" v-if="selectedNodeId">
|
|
<div class="panel-title">节点配置</div>
|
|
<div class="config-actions">
|
|
<el-button size="small" type="danger" @click="removeNode(selectedNodeId)">删除节点</el-button>
|
|
</div>
|
|
<el-form label-width="100px" size="small" style="margin-top:12px">
|
|
<el-form-item label="类型">
|
|
<el-tag>{{ selectedNode?.data?.typeDesc || selectedNode?.type }}</el-tag>
|
|
</el-form-item>
|
|
<el-form-item label="名称">
|
|
<el-input v-model="selectedNodeData.label" @change="onConfigLabelChange" />
|
|
</el-form-item>
|
|
|
|
<component
|
|
v-if="selectedNode?.data?.type"
|
|
:is="getConfigComponent(selectedNode.data.type)"
|
|
v-model="selectedNodeData.config"
|
|
:model-list="modelList"
|
|
:mcp-servers="mcpServers"
|
|
@change="onConfigChange"
|
|
/>
|
|
</el-form>
|
|
</div>
|
|
</div>
|
|
|
|
<el-dialog v-model="versionDialogVisible" title="版本历史" width="700px">
|
|
<el-table :data="versions" v-loading="loadingVersions" max-height="400">
|
|
<el-table-column prop="version" label="版本" width="80" />
|
|
<el-table-column prop="changelog" label="变更说明" min-width="200">
|
|
<template #default="{ row }">
|
|
<span v-if="row.changelog">{{ row.changelog }}</span>
|
|
<span v-else style="color:#999">-</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="时间" width="170">
|
|
<template #default="{ row }">
|
|
{{ formatTime(row.created_at) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="120">
|
|
<template #default="{ row }">
|
|
<el-button size="small" type="primary" link @click="rollbackVersion(row.id)">
|
|
回滚到此版本
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<div v-if="!versions.length && !loadingVersions" style="text-align:center;padding:24px;color:#999">
|
|
暂无版本记录
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { MarkerType } from '@vue-flow/core'
|
|
import { Promotion, ChatDotRound, Tools, Connection, Bell, DataAnalysis, Search, RefreshRight, Document, Link, Operation, Edit, Sunny } from '@element-plus/icons-vue'
|
|
import FlowCanvas from './FlowCanvas.vue'
|
|
import TriggerConfig from './node-configs/TriggerConfig.vue'
|
|
import LlmConfig from './node-configs/LlmConfig.vue'
|
|
import ToolConfig from './node-configs/ToolConfig.vue'
|
|
import McpConfig from './node-configs/McpConfig.vue'
|
|
import NotifyConfig from './node-configs/WecomNotifyConfig.vue'
|
|
import ConditionConfig from './node-configs/ConditionConfig.vue'
|
|
import RagConfig from './node-configs/RagConfig.vue'
|
|
import OutputConfig from './node-configs/OutputConfig.vue'
|
|
import LoopConfig from './node-configs/LoopConfig.vue'
|
|
import CodeConfig from './node-configs/CodeConfig.vue'
|
|
import HttpRequestConfig from './node-configs/HttpRequestConfig.vue'
|
|
import QuestionClassifierConfig from './node-configs/QuestionClassifierConfig.vue'
|
|
import VariableAssignerConfig from './node-configs/VariableAssignerConfig.vue'
|
|
import TemplateTransformConfig from './node-configs/TemplateTransformConfig.vue'
|
|
import IterationConfig from './node-configs/IterationConfig.vue'
|
|
import QuestionOptimiserConfig from './node-configs/QuestionOptimiserConfig.vue'
|
|
import MergeConfig from './node-configs/MergeConfig.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('')
|
|
const flowMode = ref('chatflow')
|
|
const saving = ref(false)
|
|
const versionDialogVisible = ref(false)
|
|
const versions = ref<any[]>([])
|
|
const loadingVersions = ref(false)
|
|
const selectedNodeId = ref('')
|
|
const selectedNodeData = ref<any>({})
|
|
const mcpServers = ref<any[]>([])
|
|
const modelList = 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处理' },
|
|
{ type: 'tool', label: '工具调用', icon: Tools, typeDesc: '工具调用' },
|
|
{ type: 'mcp', label: 'MCP服务', icon: Connection, typeDesc: '外部MCP' },
|
|
{ type: 'notify', label: '通知', icon: Bell, typeDesc: '消息通知' },
|
|
{ type: 'condition', label: '条件判断', icon: DataAnalysis, typeDesc: '条件分支' },
|
|
{ type: 'rag', label: 'RAG检索', icon: Search, typeDesc: '知识库检索' },
|
|
{ type: 'loop', label: '循环', icon: RefreshRight, typeDesc: '循环迭代' },
|
|
{ type: 'merge', label: '变量聚合', icon: Connection, typeDesc: '并行汇聚' },
|
|
{ type: 'code', label: '代码执行', icon: Document, typeDesc: '代码执行' },
|
|
{ type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出' },
|
|
{ type: 'http_request', label: 'HTTP请求', icon: Link, typeDesc: '外部API调用' },
|
|
{ type: 'question_classifier', label: '问题分类', icon: Operation, typeDesc: '意图分类路由' },
|
|
{ type: 'variable_assigner', label: '变量赋值', icon: Edit, typeDesc: '变量操作' },
|
|
{ type: 'template_transform', label: '模板转换', icon: Document, typeDesc: '格式转换' },
|
|
{ type: 'iteration', label: '迭代处理', icon: RefreshRight, typeDesc: '数组遍历' },
|
|
{ type: 'question_optimiser', label: '问题优化', icon: Sunny, typeDesc: '查询优化' },
|
|
]
|
|
|
|
const configComponentMap: Record<string, any> = {
|
|
trigger: TriggerConfig,
|
|
llm: LlmConfig,
|
|
tool: ToolConfig,
|
|
mcp: McpConfig,
|
|
notify: NotifyConfig,
|
|
wecom_notify: NotifyConfig,
|
|
condition: ConditionConfig,
|
|
rag: RagConfig,
|
|
output: OutputConfig,
|
|
loop: LoopConfig,
|
|
code: CodeConfig,
|
|
merge: MergeConfig,
|
|
http_request: HttpRequestConfig,
|
|
question_classifier: QuestionClassifierConfig,
|
|
variable_assigner: VariableAssignerConfig,
|
|
template_transform: TemplateTransformConfig,
|
|
iteration: IterationConfig,
|
|
question_optimiser: QuestionOptimiserConfig,
|
|
}
|
|
|
|
function getConfigComponent(type: string) {
|
|
return configComponentMap[type] || null
|
|
}
|
|
|
|
const colorMap: Record<string, string> = {
|
|
trigger: '#722ed1',
|
|
llm: '#409EFF',
|
|
tool: '#67C23A',
|
|
mcp: '#E6A23C',
|
|
notify: '#F56C6C',
|
|
wecom_notify: '#F56C6C',
|
|
condition: '#909399',
|
|
rag: '#337ecc',
|
|
loop: '#13c2c2',
|
|
code: '#eb2f96',
|
|
output: '#722ed1',
|
|
http_request: '#2d8cf0',
|
|
question_classifier: '#ff9900',
|
|
variable_assigner: '#19be6b',
|
|
template_transform: '#9c27b0',
|
|
iteration: '#ff5722',
|
|
question_optimiser: '#e6a23c',
|
|
}
|
|
|
|
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: any) {
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.setData('application/vueflow', JSON.stringify(node))
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
}
|
|
}
|
|
|
|
function onDrop(event: DragEvent) {
|
|
const dataStr = event.dataTransfer?.getData('application/vueflow')
|
|
if (!dataStr) return
|
|
const nodeData = JSON.parse(dataStr)
|
|
const id = `node_${++nodeCounter}`
|
|
nodes.value.push({
|
|
id,
|
|
type: 'custom',
|
|
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: getDefaultConfig(nodeData.type),
|
|
},
|
|
draggable: true,
|
|
connectable: true,
|
|
})
|
|
}
|
|
|
|
function getDefaultConfig(type: string) {
|
|
const defaults: Record<string, any> = {
|
|
trigger: { event_type: 'text_message', channels: ['wecom'], callback_url: '' },
|
|
llm: { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '', max_tokens: 2000, context_length: 5, memory_mode: 'short_term', stream: true, tool_call: false },
|
|
tool: { tool_type: '', tool_name: '', param_mapping: '{}', tool_params: {}, timeout: 30, retry_count: 0, error_handling: 'throw' },
|
|
mcp: { mcp_server: '', tool_name: '', input_params: {}, timeout: 30, response_parser: 'json', error_handling: 'throw' },
|
|
notify: { channels: { wecom: true, web: false }, message_template: '', web_template: '', target: '', message_type: 'text', async_send: false, error_handling: 'throw' },
|
|
wecom_notify: { channels: { wecom: true, web: false }, message_template: '', web_template: '', target: '', message_type: 'text', async_send: false, error_handling: 'throw' },
|
|
condition: { condition_type: 'expression', condition: '', true_label: '是', false_label: '否', default_branch: 'false' },
|
|
rag: { knowledge_base: '', top_k: 5, search_mode: 'hybrid', similarity_threshold: 0.7, result_sort: 'similarity', include_metadata: true },
|
|
loop: { loop_type: 'fixed', count: 3, iterator_variable: 'item', max_iterations: 10, items: [] },
|
|
merge: { merge_type: 'concat', expected_branches: 2 },
|
|
code: { language: 'python', code: '', timeout: 30, sandbox: true },
|
|
output: { format: 'text', output_template: '', indent: 2, encoding: 'utf-8', truncate: false, max_length: 2000 },
|
|
http_request: { method: 'GET', url: '', headers: '{}', body: '', auth_type: 'none', auth_config: {}, timeout: 30, retry_count: 0 },
|
|
question_classifier: { categories: [], instruction: '', model: '', temperature: 0.3 },
|
|
variable_assigner: { assignments: [] },
|
|
template_transform: { template: '', output_type: 'string' },
|
|
iteration: { input_array_source: '', max_iterations: 20 },
|
|
question_optimiser: { optimization_type: 'rewrite', model: '' },
|
|
}
|
|
return defaults[type] || {}
|
|
}
|
|
|
|
function onNodeClick({ node }: any) {
|
|
selectedNodeId.value = node.id
|
|
const d = node.data || {}
|
|
selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', config: { ...(d.config || {}) } }
|
|
}
|
|
|
|
function onPaneClick() { selectedNodeId.value = '' }
|
|
|
|
function onConfigLabelChange() {
|
|
const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
|
|
if (idx !== -1) {
|
|
const updated = { ...nodes.value[idx] }
|
|
updated.data = { ...updated.data, label: selectedNodeData.value.label }
|
|
nodes.value = nodes.value.map((n: any, i: number) => i === idx ? updated : n)
|
|
}
|
|
}
|
|
|
|
function onConfigChange() {
|
|
const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
|
|
if (idx === -1) return
|
|
const found = nodes.value[idx]
|
|
const updated = { ...found }
|
|
updated.data = { ...found.data, config: { ...selectedNodeData.value.config } }
|
|
nodes.value = nodes.value.map((n: any, i: number) => i === idx ? updated : n)
|
|
}
|
|
|
|
function removeNode(id: string) {
|
|
nodes.value = nodes.value.filter((n: any) => n.id !== id)
|
|
edges.value = edges.value.filter((e: any) => e.source !== id && e.target !== id)
|
|
if (selectedNodeId.value === id) selectedNodeId.value = ''
|
|
}
|
|
|
|
function clearCanvas() { nodes.value = []; edges.value = []; nodeCounter = 0; selectedNodeId.value = '' }
|
|
|
|
function getEdgeStyle(sourceHandle: string | undefined) {
|
|
if (sourceHandle === 'false') return { stroke: '#E53935', strokeWidth: 3 }
|
|
if (sourceHandle === 'loop_body') return { stroke: '#13c2c2', strokeWidth: 3, strokeDasharray: '6,3' }
|
|
if (sourceHandle === 'loop_done') return { stroke: '#909399', strokeWidth: 3 }
|
|
return { stroke: '#1976D2', strokeWidth: 3 }
|
|
}
|
|
|
|
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 || ''
|
|
flowMode.value = flow.flow_mode || 'chatflow'
|
|
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',
|
|
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,
|
|
})
|
|
}
|
|
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
|
|
loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: cond === 'loop_body', markerEnd: MarkerType.ArrowClosed, style: getEdgeStyle(cond) })
|
|
}
|
|
nodes.value = loadedNodes
|
|
edges.value = loadedEdges
|
|
nodeCounter = defNodes.length
|
|
} catch (e: any) {
|
|
console.error('loadFlow error:', e)
|
|
}
|
|
}
|
|
|
|
async function loadMcpServers() {
|
|
try { const { mcpApi } = await import('@/api'); const res: any = await mcpApi.getServers(); mcpServers.value = Array.isArray(res) ? res : (res?.data || []) } catch {}
|
|
}
|
|
|
|
async function loadModels() {
|
|
try { const { modelProviderApi } = await import('@/api'); const res: any = await modelProviderApi.getAllModels(); modelList.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 { flowApi } = await import('@/api')
|
|
const snapshot = canvasRef.value?.getSnapshot() || { nodes: [], edges: [] }
|
|
const serializedNodes = snapshot.nodes.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 = snapshot.edges.map((e: any) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source' }))
|
|
const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {}, flow_mode: flowMode.value }
|
|
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 {}
|
|
}
|
|
|
|
async function publishToWeb() {
|
|
if (!isEdit.value) { ElMessage.warning('请先保存'); return }
|
|
try { const { flowApi } = await import('@/api'); await flowApi.publishToWeb(flowId.value); ElMessage.success('流已上架到网页'); await loadFlow() } catch {}
|
|
}
|
|
|
|
async function showVersionHistory() {
|
|
versionDialogVisible.value = true
|
|
await loadVersions()
|
|
}
|
|
|
|
async function loadVersions() {
|
|
loadingVersions.value = true
|
|
try {
|
|
const { flowApi } = await import('@/api')
|
|
const res: any = await flowApi.getVersions(flowId.value)
|
|
versions.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
|
|
} catch { versions.value = [] }
|
|
finally { loadingVersions.value = false }
|
|
}
|
|
|
|
async function rollbackVersion(versionId: string) {
|
|
try {
|
|
await ElMessageBox.confirm('回滚将覆盖当前定义,确定继续?', '确认回滚', { type: 'warning' })
|
|
const { flowApi } = await import('@/api')
|
|
await flowApi.rollbackVersion(flowId.value, versionId)
|
|
ElMessage.success('已回滚到该版本')
|
|
versionDialogVisible.value = false
|
|
await loadFlow()
|
|
} catch {}
|
|
}
|
|
|
|
function formatTime(ts: string) {
|
|
if (!ts) return '-'
|
|
try { return new Date(ts).toLocaleString() } catch { return ts }
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
if (isEdit.value) { await loadFlow() }
|
|
await Promise.allSettled([loadMcpServers(), loadModels()])
|
|
startAutoSave()
|
|
} catch (e: any) {
|
|
console.error('FlowEditor init error:', e)
|
|
initError.value = '初始化失败: ' + (e?.message || '未知错误')
|
|
}
|
|
})
|
|
|
|
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
function startAutoSave() {
|
|
if (autoSaveTimer) clearInterval(autoSaveTimer)
|
|
autoSaveTimer = setInterval(() => {
|
|
if (flowName.value && isEdit.value) {
|
|
saveFlow().catch(() => {})
|
|
}
|
|
}, 30000)
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (autoSaveTimer) clearInterval(autoSaveTimer)
|
|
})
|
|
</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; }
|
|
.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>
|
|
|