This commit is contained in:
yinsx
2025-12-13 15:40:01 +08:00
commit 39c0f7e708
104 changed files with 6460 additions and 0 deletions

View File

@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(taskkill:*)",
"Bash(cat:*)",
"Bash(npm install:*)",
"Bash(npx vue-tsc:*)",
"Bash(xargs cat:*)",
"Bash(find:*)",
"Bash(npm run dev:*)",
"Bash(timeout 10 npm run dev:*)"
]
}
}

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

2
backend/.env.example Normal file
View File

@ -0,0 +1,2 @@
OPENAI_API_KEY=your_api_key_here
OPENAI_BASE_URL=

197
backend/bun.lock Normal file

File diff suppressed because one or more lines are too long

BIN
backend/flow.db Normal file

Binary file not shown.

12
backend/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "flow-backend",
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts"
},
"dependencies": {
"@langchain/openai": "^0.0.14",
"langchain": "^0.1.0"
}
}

25
backend/src/db.ts Normal file
View File

@ -0,0 +1,25 @@
import { Database } from "bun:sqlite";
const db = new Database("flow.db");
db.run(`
CREATE TABLE IF NOT EXISTS flows (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
schema_json TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS executions (
id TEXT PRIMARY KEY,
flow_id TEXT NOT NULL,
status TEXT DEFAULT 'pending',
result TEXT,
start_time TEXT DEFAULT CURRENT_TIMESTAMP,
end_time TEXT
)
`);
export default db;

View File

@ -0,0 +1,7 @@
export interface NodeConfig {
[key: string]: any;
}
export interface BaseExecutor {
execute(config: NodeConfig, input: Record<string, any>): Promise<Record<string, any>>;
}

View File

@ -0,0 +1,14 @@
import type { BaseExecutor } from "./base";
import { llmExecutor } from "./llm";
const executors: Record<string, BaseExecutor> = {
llmAgentNode: llmExecutor,
toolAgentNode: {
async execute(config, input) {
// 简单的搜索模拟,可扩展接入真实工具
return { output: `Tool ${config.tool_name} result for: ${input.output || input.input}` };
},
},
};
export const getExecutor = (type: string) => executors[type];

View File

@ -0,0 +1,20 @@
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import type { BaseExecutor, NodeConfig } from "./base";
export const llmExecutor: BaseExecutor = {
async execute(config: NodeConfig, input: Record<string, any>) {
const llm = new ChatOpenAI({
modelName: config.model || "gpt-4o",
openAIApiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_BASE_URL || undefined },
});
const template = config.prompt_template || "{input}";
const prompt = PromptTemplate.fromTemplate(template);
const chain = prompt.pipe(llm);
const result = await chain.invoke(input.output ? { input: input.output } : input);
return { output: result.content };
},
};

101
backend/src/index.ts Normal file
View File

@ -0,0 +1,101 @@
import db from "./db";
import { socketManager } from "./services/socket";
import { executeFlow } from "./services/executor";
const server = Bun.serve({
port: 8000,
async fetch(req, server) {
const url = new URL(req.url);
// CORS
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// WebSocket upgrade
if (url.pathname.startsWith("/ws/flows/")) {
const executionId = url.pathname.split("/").pop()!;
if (server.upgrade(req, { data: { executionId } })) return;
return new Response("Upgrade failed", { status: 500 });
}
// API routes
if (url.pathname === "/api/flows" && req.method === "POST") {
const { name, schema_json } = await req.json();
const id = crypto.randomUUID();
db.run("INSERT INTO flows (id, name, schema_json) VALUES (?, ?, ?)", [id, name, JSON.stringify(schema_json)]);
return json({ id, name });
}
if (url.pathname.match(/^\/api\/flows\/[\w-]+$/) && req.method === "PUT") {
const id = url.pathname.split("/").pop()!;
const { name, schema_json } = await req.json();
db.run("UPDATE flows SET name = ?, schema_json = ? WHERE id = ?", [name, JSON.stringify(schema_json), id]);
return json({ id, name });
}
if (url.pathname.match(/^\/api\/flows\/[\w-]+$/) && req.method === "GET") {
const id = url.pathname.split("/").pop()!;
const row = db.query("SELECT * FROM flows WHERE id = ?").get(id) as any;
if (!row) return json({ error: "Not found" }, 404);
return json({ id: row.id, name: row.name, schema_json: JSON.parse(row.schema_json) });
}
if (url.pathname.match(/^\/api\/flows\/[\w-]+\/execute$/) && req.method === "POST") {
const flowId = url.pathname.split("/")[3];
const { runtime_data } = await req.json().catch(() => ({}));
const row = db.query("SELECT * FROM flows WHERE id = ?").get(flowId) as any;
if (!row) return json({ error: "Not found" }, 404);
const executionId = crypto.randomUUID();
db.run("INSERT INTO executions (id, flow_id, status) VALUES (?, ?, 'running')", [executionId, flowId]);
// 异步执行
(async () => {
try {
const result = await executeFlow(executionId, JSON.parse(row.schema_json), runtime_data);
db.run("UPDATE executions SET status = 'success', result = ?, end_time = CURRENT_TIMESTAMP WHERE id = ?", [JSON.stringify(result), executionId]);
} catch (err: any) {
db.run("UPDATE executions SET status = 'error', result = ?, end_time = CURRENT_TIMESTAMP WHERE id = ?", [JSON.stringify({ error: err.message }), executionId]);
}
})();
return json({ execution_id: executionId });
}
if (url.pathname.match(/^\/api\/flows\/executions\/[\w-]+$/) && req.method === "GET") {
const id = url.pathname.split("/").pop()!;
const row = db.query("SELECT * FROM executions WHERE id = ?").get(id) as any;
if (!row) return json({ error: "Not found" }, 404);
return json({ ...row, result: row.result ? JSON.parse(row.result) : null });
}
if (url.pathname === "/health") return json({ status: "ok" });
return json({ error: "Not found" }, 404);
},
websocket: {
open(ws) {
socketManager.add(ws.data.executionId, ws);
},
close(ws) {
socketManager.remove(ws.data.executionId);
},
message() {},
},
});
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
function json(data: any, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
console.log(`Server running at http://localhost:${server.port}`);

View File

@ -0,0 +1,73 @@
import { getExecutor } from "../executors";
import { socketManager } from "./socket";
interface FlowSchema {
nodes: Array<{ id: string; type: string; data: any }>;
edges: Array<{ source: string; target: string }>;
}
export async function executeFlow(executionId: string, schema: FlowSchema, runtimeData: Record<string, any> = {}) {
const nodes = Object.fromEntries(schema.nodes.map((n) => [n.id, n]));
const adj: Record<string, string[]> = {};
const inDegree: Record<string, number> = {};
for (const n of schema.nodes) {
adj[n.id] = [];
inDegree[n.id] = 0;
}
for (const e of schema.edges) {
adj[e.source].push(e.target);
inDegree[e.target]++;
}
// 拓扑排序
const queue = Object.keys(inDegree).filter((id) => inDegree[id] === 0);
const sorted: string[] = [];
while (queue.length) {
const id = queue.shift()!;
sorted.push(id);
for (const next of adj[id]) {
if (--inDegree[next] === 0) queue.push(next);
}
}
const context: Record<string, any> = { ...runtimeData };
for (const nodeId of sorted) {
const node = nodes[nodeId];
socketManager.send(executionId, nodeId, "running", "开始执行");
try {
let result: Record<string, any>;
if (node.type === "startNode") {
result = { output: context[node.data?.input_key] || "" };
} else if (node.type === "outputNode") {
const prev = schema.edges.find((e) => e.target === nodeId)?.source;
result = { output: prev ? context[prev]?.output : "" };
} else if (node.type === "conditionalNode") {
const prev = schema.edges.find((e) => e.target === nodeId)?.source;
const output = prev ? context[prev] : {};
try {
result = { output: eval(node.data?.check_expression || "true"), condition_result: true };
} catch {
result = { output: false, condition_result: false };
}
} else {
const executor = getExecutor(node.type);
if (!executor) throw new Error(`No executor for ${node.type}`);
const prev = schema.edges.find((e) => e.target === nodeId)?.source;
const input = prev ? context[prev] : context;
result = await executor.execute(node.data || {}, input);
}
context[nodeId] = result;
socketManager.send(executionId, nodeId, "success", "执行完成", String(result.output || ""));
} catch (err: any) {
socketManager.send(executionId, nodeId, "error", err.message);
throw err;
}
}
return context;
}

View File

@ -0,0 +1,25 @@
import type { ServerWebSocket } from "bun";
const connections = new Map<string, ServerWebSocket<{ executionId: string }>>();
export const socketManager = {
add(executionId: string, ws: ServerWebSocket<{ executionId: string }>) {
connections.set(executionId, ws);
},
remove(executionId: string) {
connections.delete(executionId);
},
send(executionId: string, nodeId: string, status: string, message = "", outputPreview = "") {
const ws = connections.get(executionId);
if (ws) {
ws.send(JSON.stringify({
eventType: "status_update",
executionId,
nodeId,
status,
timestamp: new Date().toISOString(),
payload: { log_message: message, output_preview: outputPreview.slice(0, 200) },
}));
}
},
};

40
flow.md Normal file
View File

@ -0,0 +1,40 @@
1. 项目概览与架构设计1.1 核心目标提供基于 Vue Flow 的可视化流程编排界面。后端采用 Executor 模式 和 LangChain确保 Agent 逻辑高度模块化和可扩展。通过 WebSocket 实现流程执行状态的实时监控。1.2 技术栈 (Technology Stack)领域技术/框架职责前端 (FE)Vue 3, Vue Flow, Axios流程图渲染、节点配置、API 通信。后端 (BE)FastAPI高性能 API 服务路由WebSocket 管理。Agent 核心LangChainAgent 定义、工具调用、Prompt 模板。数据库PostgreSQL/SQLite存储流程 Schema、Agent 配置和执行历史。1.3 核心架构前端 (Vue Flow): 用户设计流程,提交 Schema。FastAPI (API/WS Router): 接收请求,管理 WebSocket 连接。Flow Parser: 将 Vue Flow Schema 转换为内部 DAG (有向无环图) 结构。Flow Executor: 遍历 DAG调度 Agent并向 Socket Manager 推送状态。Agent Executors: LangChain 驱动的模块化 Agent 实例。2. 前端设计与实现 (Vue Flow)2.1 节点类型与配置组件所有自定义节点Custom Nodes必须包含一个用于配置其后端参数的组件。节点 Type ID描述核心配置字段 (Node Data)startNode流程的输入/触发器。{"input_key": "user_query"}llmAgentNode基础 LLM 任务。{"model": "gpt-4o", "prompt_template": "...", "input_map": {...}}toolAgentNode调用外部工具。{"tool_name": "Search", "tool_config": {...}}conditionalNode逻辑路由判断。{"check_expression": "output.type == 'sales'"} outputNode流程结果输出。{"output_key": "final_result"}2.2 数据契约Schema 到后端前端将 Vue Flow toObject() 结果发送给后端。JSON{
"flowId": "uuid_flow_1",
"nodes": [
// ... Node Definition (包含 type 和 data)
],
"edges": [
// ... Edge Definition (包含 source, target, label/condition)
],
// 增加运行时数据,方便流程重启或监控
"runtime_data": {}
}
2.3 状态监控逻辑 (WebSocket Client)连接建立: 流程执行 API 成功调用后,前端立即使用返回的 execution_id 建立 WS 连接ws://backend_url/ws/flows/{execution_id}。状态映射: 监听 WebSocket 消息,根据消息中的 nodeId 和 status 实时更新 Vue Flow 节点的外观样式 (CSS Class)。running $\rightarrow$ 节点边框闪烁/黄色success $\rightarrow$ 绿色边框/实心error $\rightarrow$ 红色边框/错误图标3. 后端设计与实现 (FastAPI + LangChain)3.1 核心数据模型 (Pydantic/ORM)模型/类描述关键字段FlowSchema存储 Vue Flow 结构。id, name, schema_json, created_atExecution存储流程运行实例。id, flow_id, status, start_time, end_timeExecutionLog存储运行时日志。execution_id, node_id, status, message, timestamp3.2 API 接口规范接口方法路径描述创建/更新流程POST / PUT/api/flows存储前端设计的流程 Schema。流程启动POST/api/flows/{flow_id}/execute启动流程执行返回 execution_id。流程历史GET/api/executions/{exec_id}获取流程历史状态和最终输出。实时监控WS/ws/flows/{execution_id}实时推送节点状态和日志。3.3 Agent 执行器与扩展性所有 Agent 逻辑必须遵循统一的执行器接口,保证 Flow Executor 不关心底层细节。Python# Base Agent Executor Interface
from typing import Dict, Any
class BaseAgentExecutor:
# 核心执行方法
async def execute(self, node_config: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]:
"""执行 Agent 任务,返回结构化输出。"""
# 内部应调用 LangChain Components (Chains, Agents, Tools)
raise NotImplementedError
# 扩展示例:新增 Database Agent
class DBQueryExecutor(BaseAgentExecutor):
async def execute(self, node_config, input_data):
# 1. 解析 node_config 获取 SQL 模板
# 2. 调用 LangChain SQL ToolChain
# 3. 返回结果
pass
3.4 WebSocket 消息契约 (Backend -> Frontend)所有状态更新都通过这个统一的 JSON 格式推送。JSON{
"eventType": "status_update",
"executionId": "...",
"nodeId": "node_A",
"status": "success", // running, success, error, skipped
"timestamp": "2025-12-11T...",
"payload": {
"log_message": "Agent finished successfully.",
"output_preview": "Summary: The project is highly scalable."
}
}
4. 部署与环境规范4.1 环境依赖后端: Python 3.11+ (FastAPI, LangChain, Uvicorn)前端: Node.js 18+ (Vue 3+vite+ts, Vue Flow)4.2 部署建议分离部署: 前后端应分开部署。CORS 配置: 确保 FastAPI 允许前端 URL 访问 API 和 WebSocket 端口。密钥管理: 使用环境变量 (.env 或 secrets manager) 管理 OPENAI_API_KEY

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>流程编排</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3070
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "flow-orchestration-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@iconify/json": "^2.2.417",
"@iconify/vue": "^5.0.0",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.0",
"@vue-flow/core": "^1.33.0",
"@vue-flow/minimap": "^1.4.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"pinia": "^2.1.0",
"vue": "^3.4.0"
},
"devDependencies": {
"@types/node": "^25.0.1",
"@vitejs/plugin-vue": "^5.0.0",
"sass-embedded": "^1.96.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0"
}
}

498
frontend/src/App.vue Normal file
View File

@ -0,0 +1,498 @@
<template>
<div class="app-layout">
<header class="top-bar">
<div class="top-left">
<Icon icon="mdi:sitemap" width="24" class="logo-icon" />
<span class="app-title">流程编辑器</span>
</div>
<div class="top-right">
<el-button @click="saveFlow">
<Icon icon="mdi:content-save" width="16" style="margin-right: 4px" />保存
</el-button>
<el-button type="primary" @click="executeFlow">
<Icon icon="mdi:play" width="16" style="margin-right: 4px" />运行
</el-button>
</div>
</header>
<div class="main-content">
<aside class="node-sidebar">
<div v-for="category in nodeCategories" :key="category.key" class="node-section">
<div class="section-title">{{ category.label }}</div>
<div class="node-list">
<div v-for="n in category.nodes" :key="n.type" class="node-item" draggable="true" @dragstart="onDragStart($event, n.type)">
<span class="node-icon">
<Icon :icon="n.icon" width="18" />
</span>
<span class="node-label">{{ n.label }}</span>
</div>
</div>
</div>
<div class="stats-bar">
<span>{{ stats.nodeCount }} 节点</span>
<span>{{ stats.edgeCount }} 连线</span>
</div>
</aside>
<main class="canvas-area">
<VueFlow
v-model:nodes="store.nodes"
v-model:edges="store.edges"
:node-types="nodeTypes"
@pane-click="onPaneClick"
@dragover="onDragOver"
@drop="onDrop"
@node-context-menu="onNodeContextMenu"
@edge-context-menu="onEdgeContextMenu"
@pane-context-menu="onPaneContextMenu"
>
<Background :gap="20" />
</VueFlow>
<ContextMenu
:visible="contextMenu.visible"
:x="contextMenu.x"
:y="contextMenu.y"
:items="contextMenuItems"
@select="handleContextMenuAction"
@close="closeContextMenu"
/>
<PropertyPanel v-if="store.selectedNode" title="节点配置" @close="closePanel">
<div class="config-row">
<label>节点 ID</label>
<el-input :model-value="store.selectedNode.id" disabled size="small" />
</div>
<template v-if="store.selectedNode.type === 'containerNode'">
<div class="config-row">
<label>容器标题</label>
<el-input :model-value="store.selectedNode.data.title" @update:model-value="updateData('title', $event)" size="small" />
</div>
<div class="config-row">
<label>描述</label>
<el-input type="textarea" :rows="2" :model-value="store.selectedNode.data.description" @update:model-value="updateData('description', $event)" size="small" />
</div>
<div class="config-row toggle-row">
<label>自适应大小</label>
<el-switch
:model-value="store.selectedNode.data.autoSize"
@update:model-value="val => { updateData('autoSize', val); if (val) store.autoSizeContainer(store.selectedNode!.id) }"
/>
</div>
<div class="config-row size-row">
<label>宽度</label>
<el-input-number
:model-value="store.selectedNode.data.size?.width || 360"
:min="200"
size="small"
:disabled="store.selectedNode.data.autoSize"
@update:model-value="value => updateSizeDimension('width', Number(value))"
/>
</div>
<div class="config-row size-row">
<label>高度</label>
<el-input-number
:model-value="store.selectedNode.data.size?.height || 240"
:min="160"
size="small"
:disabled="store.selectedNode.data.autoSize"
@update:model-value="value => updateSizeDimension('height', Number(value))"
/>
</div>
<div class="config-row">
<el-button size="small" @click="store.autoSizeContainer(store.selectedNode!.id)">
<Icon icon="mdi:aspect-ratio" width="14" style="margin-right: 4px" />自适应当前
</el-button>
</div>
</template>
<template v-else>
<div
v-for="(value, key) in store.selectedNode.data"
:key="key"
class="config-row"
v-if="typeof value !== 'object'"
>
<label>{{ key }}</label>
<el-input
v-if="typeof value === 'string' || value === undefined"
:model-value="value"
size="small"
@update:model-value="updateData(key, $event)"
/>
<el-input-number
v-else-if="typeof value === 'number'"
:model-value="value"
size="small"
@update:model-value="val => updateData(key, Number(val))"
/>
<el-switch
v-else-if="typeof value === 'boolean'"
:model-value="value"
@update:model-value="updateData(key, $event)"
/>
</div>
</template>
<div class="config-row" style="margin-top: 16px">
<el-button type="danger" size="small" @click="store.deleteNode(store.selectedNode!.id)">
<Icon icon="mdi:delete" width="14" style="margin-right: 4px" />删除节点
</el-button>
</div>
</PropertyPanel>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Icon } from '@iconify/vue'
import { useFlowStore } from '@/stores/flow'
import { nodeTypes } from './components/nodes'
import { flowApi, createWebSocket } from './api'
import { nodeCategories } from './data/nodeCatalog'
import type { ContextMenuItem } from './types/context-menu'
import PropertyPanel from './components/PropertyPanel.vue'
import ContextMenu from './components/ContextMenu.vue'
const store = useFlowStore()
const { toObject, onConnect, addEdges, screenToFlowCoordinate, onNodesChange, onNodeDragStop } = useVueFlow()
const flowId = ref('')
// 右键菜单状态
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
type: '' as 'node' | 'edge' | 'pane',
targetId: ''
})
const paneClickPosition = ref({ x: 0, y: 0 })
const spawnNodeAt = (type: string, screenPoint: { x: number; y: number }) => {
const position = screenToFlowCoordinate({ x: screenPoint.x, y: screenPoint.y })
const container = store.findContainerByPoint(position)
if (container) {
const size = (container.data?.size as { width: number; height: number }) ?? { width: 360, height: 240 }
const relative = {
x: Math.max(24, Math.min(position.x - container.position.x - 40, size.width - 120)),
y: Math.max(48, Math.min(position.y - container.position.y - 40, size.height - 120))
}
store.addNode(type, relative, { parentId: container.id })
if (container.data.autoSize) {
nextTick(() => store.autoSizeContainer(container.id))
}
} else {
store.addNode(type, { x: position.x - 50, y: position.y - 20 })
}
}
// 右键菜单项
const buildPaneMenuItems = (): ContextMenuItem[] => {
const nodeGroups: ContextMenuItem[] = nodeCategories.map(category => ({
label: category.label,
icon: category.icon,
children: category.nodes.map(node => ({
label: node.label,
icon: node.icon,
action: 'add-node',
payload: { nodeType: node.type }
}))
}))
return [
{
label: '插入节点',
icon: 'mdi:plus-box-multiple-outline',
children: nodeGroups
},
{ label: '取消选择', icon: 'mdi:selection-off', action: 'clear-selection' },
{ label: '粘贴节点', icon: 'mdi:content-paste', action: 'paste', disabled: true }
]
}
const contextMenuItems = computed<ContextMenuItem[]>(() => {
if (contextMenu.value.type === 'node') {
return [
{ label: '复制节点', icon: 'mdi:content-copy', action: 'copy' },
{ label: '删除节点', icon: 'mdi:delete', action: 'delete' }
]
} else if (contextMenu.value.type === 'edge') {
return [
{ label: '删除连线', icon: 'mdi:delete', action: 'delete' }
]
} else if (contextMenu.value.type === 'pane') {
return buildPaneMenuItems()
}
return []
})
// 处理右键菜单操作
const handleContextMenuAction = (item: ContextMenuItem) => {
if (item.action === 'delete') {
if (contextMenu.value.type === 'node') {
store.deleteNode(contextMenu.value.targetId)
} else if (contextMenu.value.type === 'edge') {
store.deleteEdge(contextMenu.value.targetId)
}
} else if (item.action === 'copy') {
if (contextMenu.value.type === 'node') {
store.copyNode(contextMenu.value.targetId)
}
} else if (item.action === 'add-node' && item.payload?.nodeType) {
spawnNodeAt(item.payload.nodeType, paneClickPosition.value)
} else if (item.action === 'clear-selection') {
store.selectedNode = null
}
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenu.value.visible = false
}
// 节点右键事件
const onNodeContextMenu = ({ event, node }: { event: MouseEvent, node: any }) => {
event.preventDefault()
event.stopPropagation()
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
type: 'node',
targetId: node.id
}
}
// 边右键事件
const onEdgeContextMenu = ({ event, edge }: { event: MouseEvent, edge: any }) => {
event.preventDefault()
event.stopPropagation()
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
type: 'edge',
targetId: edge.id
}
}
// 画布右键事件(阻止默认行为)
const onPaneContextMenu = (event: MouseEvent) => {
event.preventDefault()
paneClickPosition.value = { x: event.clientX, y: event.clientY }
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
type: 'pane',
targetId: ''
}
}
// 键盘快捷键处理
const handleKeyDown = (event: KeyboardEvent) => {
// 阻止 Ctrl+S
if (event.ctrlKey && event.key === 's') {
event.preventDefault()
saveFlow()
return
}
// 阻止 Ctrl+P
if (event.ctrlKey && event.key === 'p') {
event.preventDefault()
return
}
// Delete 键删除选中的节点
if (event.key === 'Delete' && store.selectedNode) {
store.deleteNode(store.selectedNode.id)
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
onConnect((params) => addEdges([params]))
onNodesChange((changes) => {
changes.forEach((change: any) => {
if (change.type === 'select' && change.selected) {
const node = store.nodes.find((n: any) => n.id === change.id)
if (node) store.selectedNode = node
}
})
})
onNodeDragStop(({ node }) => {
if (node.parentNode) {
const parent = store.nodes.find((n: any) => n.id === node.parentNode)
if (parent?.data?.autoSize) {
store.autoSizeContainer(parent.id)
}
}
if (node.type === 'containerNode' && node.data?.autoSize) {
store.autoSizeContainer(node.id)
}
})
const stats = computed(() => {
const nodeCount = store.nodes.length
const edgeCount = store.edges.length
const typeSet = new Set(store.nodes.map((n: any) => n.type))
return { nodeCount, edgeCount, typeCount: typeSet.size }
})
const onDragStart = (e: DragEvent, type: string) => {
e.dataTransfer!.setData('application/vueflow', type)
e.dataTransfer!.effectAllowed = 'move'
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
}
const onDrop = (e: DragEvent) => {
const type = e.dataTransfer!.getData('application/vueflow')
if (!type) return
spawnNodeAt(type, { x: e.clientX, y: e.clientY })
}
const onPaneClick = () => {
store.selectedNode = null
closeContextMenu()
}
const closePanel = () => { store.selectedNode = null }
const saveFlow = async () => {
const schema = { ...toObject(), flowId: flowId.value || `flow_${Date.now()}` }
if (flowId.value) {
await flowApi.update(flowId.value, 'My Flow', schema)
} else {
const res = await flowApi.create('My Flow', schema)
flowId.value = res.data.id
}
}
const executeFlow = async () => {
if (!flowId.value) await saveFlow()
store.clearStatuses()
const res = await flowApi.execute(flowId.value)
const ws = createWebSocket(res.data.execution_id)
ws.onmessage = (e) => {
const data = JSON.parse(e.data)
if (data.eventType === 'status_update') store.setNodeStatus(data.nodeId, data.status)
}
}
const updateData = (key: string, val: any) => {
if (store.selectedNode) store.updateNodeData(store.selectedNode.id, { [key]: val })
}
const updateSizeDimension = (dimension: 'width' | 'height', value: number) => {
if (!store.selectedNode) return
const currentSize = store.selectedNode.data.size ?? { width: 360, height: 240 }
const nextSize = { ...currentSize, [dimension]: value }
store.updateNodeData(store.selectedNode.id, { size: nextSize, autoSize: false })
}
</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.app-layout {
height: 100%;
display: flex;
flex-direction: column;
background: #f0f2f5;
}
.top-bar {
height: 56px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
flex-shrink: 0;
}
.top-left { display: flex; align-items: center; gap: 10px; }
.logo-icon { color: #1890ff; }
.app-title { font-size: 16px; font-weight: 600; color: #333; }
.top-right { display: flex; gap: 8px; }
.main-content { flex: 1; display: flex; overflow: hidden; }
.node-sidebar {
width: 220px;
background: #fff;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.node-section { padding: 16px; border-bottom: 1px solid #f0f0f0; }
.section-title { font-size: 12px; color: #999; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.node-list { display: flex; flex-direction: column; gap: 8px; }
.node-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: grab;
transition: all 0.2s;
}
.node-item:hover { background: #f0f7ff; border-color: #91caff; }
.node-item:active { cursor: grabbing; }
.node-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.node-label { font-size: 13px; color: #333; }
.stats-bar {
margin-top: auto;
padding: 12px 16px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 16px;
font-size: 12px;
color: #666;
}
.canvas-area { flex: 1; position: relative; background: #f5f5f5; }
.vue-flow { width: 100%; height: 100%; }
.config-row { margin-bottom: 12px; }
.config-row label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
.vue-flow__handle {
width: 10px;
height: 10px;
background: #555;
border: 2px solid #fff;
border-radius: 50%;
}
.vue-flow__handle:hover {
background: #1890ff;
}
</style>

23
frontend/src/api/index.ts Normal file
View File

@ -0,0 +1,23 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
export interface FlowSchema {
flowId: string
nodes: any[]
edges: any[]
runtime_data?: Record<string, any>
}
export const flowApi = {
create: (name: string, schema: FlowSchema) => api.post('/flows', { name, schema_json: schema }),
update: (id: string, name: string, schema: FlowSchema) => api.put(`/flows/${id}`, { name, schema_json: schema }),
get: (id: string) => api.get(`/flows/${id}`),
execute: (id: string, runtimeData?: Record<string, any>) => api.post(`/flows/${id}/execute`, { runtime_data: runtimeData }),
getExecution: (execId: string) => api.get(`/flows/executions/${execId}`)
}
export const createWebSocket = (executionId: string): WebSocket => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return new WebSocket(`${protocol}//${window.location.host}/ws/flows/${executionId}`)
}

View File

@ -0,0 +1,54 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.layout { display: flex; height: 100%; }
/* 左侧边栏 */
.sidebar { width: 200px; background: #f5f5f5; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; }
.sidebar-header { padding: 16px; border-bottom: 1px solid #e0e0e0; }
.sidebar-header h2 { color: #333; font-size: 16px; font-weight: 600; }
.sidebar-section { padding: 12px; }
.sidebar-section-title { color: #666; font-size: 12px; margin-bottom: 10px; font-weight: 500; }
.node-list { display: flex; flex-direction: column; gap: 6px; }
.node-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; cursor: grab; color: #333; font-size: 13px; background: #fff; border: 1px solid #e0e0e0; transition: all 0.2s; }
.node-item:hover { border-color: #1890ff; box-shadow: 0 2px 8px rgba(24,144,255,0.15); }
.node-item:active { cursor: grabbing; }
.node-item .icon { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #fff; }
.sidebar-stats { margin-top: auto; padding: 12px; border-top: 1px solid #e0e0e0; display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #666; }
/* 主画布区域 */
.main-area { flex: 1; display: flex; flex-direction: column; background: #fafafa; }
.toolbar { display: flex; align-items: center; padding: 10px 16px; background: #fff; border-bottom: 1px solid #e0e0e0; }
.toolbar-left { display: flex; gap: 8px; }
.flow-canvas { flex: 1; position: relative; }
/* 右侧配置面板 */
.config-panel { width: 280px; background: #fff; border-left: 1px solid #e0e0e0; display: flex; flex-direction: column; }
.config-header { padding: 14px 16px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; }
.config-header h3 { color: #333; font-size: 14px; font-weight: 600; }
.config-body { flex: 1; padding: 16px; overflow-y: auto; }
.config-row { margin-bottom: 16px; }
.config-row label { display: block; color: #666; font-size: 12px; margin-bottom: 6px; }
/* Vue Flow */
.vue-flow { background: #fafafa !important; }
.vue-flow__node { border-radius: 8px; font-size: 13px; }
.vue-flow__edge-path { stroke: #b0b0b0; stroke-width: 2; }
.vue-flow__handle {
width: 10px !important;
height: 10px !important;
background: #1890ff !important;
border: 2px solid #fff !important;
border-radius: 50% !important;
box-shadow: 0 0 4px rgba(0,0,0,0.2) !important;
}
/* 节点状态 */
.vue-flow__node.running { box-shadow: 0 0 0 2px #faad14, 0 4px 12px rgba(250,173,20,0.3); animation: pulse 1s infinite; }
.vue-flow__node.success { box-shadow: 0 0 0 2px #52c41a, 0 4px 12px rgba(82,196,26,0.3); }
.vue-flow__node.error { box-shadow: 0 0 0 2px #f5222d, 0 4px 12px rgba(245,34,45,0.3); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}

View File

@ -0,0 +1,62 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="context-menu-overlay"
@click="emit('close')"
@contextmenu.prevent="emit('close')"
>
<div
class="context-menu"
:style="{ left: x + 'px', top: y + 'px' }"
@click.stop
>
<ContextMenuList :items="items" @select="handleSelect" />
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import ContextMenuList from './ContextMenuList.vue'
import type { ContextMenuItem } from '../types/context-menu'
defineProps<{
visible: boolean
x: number
y: number
items: ContextMenuItem[]
}>()
const emit = defineEmits<{
(e: 'select', item: ContextMenuItem): void
(e: 'close'): void
}>()
const handleSelect = (item: ContextMenuItem) => {
emit('select', item)
emit('close')
}
</script>
<style scoped>
.context-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.context-menu {
position: fixed;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
padding: 4px 0;
z-index: 10000;
}
</style>

View File

@ -0,0 +1,187 @@
<template>
<div class="context-menu-list">
<div
v-for="item in items"
:key="item.action ? `${item.action}-${item.label}` : item.label"
class="context-menu-item"
:class="{ disabled: item.disabled, 'has-children': hasChildren(item) }"
@mouseenter="handleItemEnter(item)"
@mouseleave="handleItemLeave(item)"
@click.stop="handleClick(item)"
>
<div class="context-menu-item-content">
<Icon :icon="item.icon" width="16" />
<span>{{ item.label }}</span>
</div>
<Icon v-if="hasChildren(item)" icon="mdi:chevron-right" width="14" class="submenu-arrow" />
<div
v-if="hasChildren(item)"
class="context-submenu"
:class="{ open: isOpen(item) }"
@mouseenter="handleSubmenuEnter"
@mouseleave="handleSubmenuLeave(getKey(item))"
>
<ContextMenuList :items="item.children!" @select="emit('select', $event)" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { onBeforeUnmount, ref } from 'vue'
import type { ContextMenuItem } from '../types/context-menu'
defineOptions({ name: 'ContextMenuList' })
const props = defineProps<{
items: ContextMenuItem[]
}>()
const emit = defineEmits<{
(e: 'select', item: ContextMenuItem): void
}>()
const hasChildren = (item: ContextMenuItem) => Array.isArray(item.children) && item.children.length > 0
const openItemKey = ref<string | null>(null)
let openTimer: number | null = null
let closeTimer: number | null = null
const clearTimers = () => {
if (openTimer) {
clearTimeout(openTimer)
openTimer = null
}
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
onBeforeUnmount(() => {
clearTimers()
})
const getKey = (item: ContextMenuItem) => {
if (item.action) return `${item.action}-${item.label}`
return item.label
}
const openSubmenu = (key: string) => {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (openItemKey.value === key) return
if (openTimer) clearTimeout(openTimer)
openTimer = window.setTimeout(() => {
openItemKey.value = key
}, 120)
}
const closeSubmenu = (key: string) => {
if (openTimer) {
clearTimeout(openTimer)
openTimer = null
}
closeTimer = window.setTimeout(() => {
if (openItemKey.value === key) {
openItemKey.value = null
}
}, 200)
}
const handleItemEnter = (item: ContextMenuItem) => {
if (!hasChildren(item)) {
openItemKey.value = null
return
}
openSubmenu(getKey(item))
}
const handleItemLeave = (item: ContextMenuItem) => {
if (!hasChildren(item)) return
closeSubmenu(getKey(item))
}
const handleSubmenuEnter = () => {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
const handleSubmenuLeave = (key: string) => {
closeSubmenu(key)
}
const isOpen = (item: ContextMenuItem) => openItemKey.value === getKey(item)
const handleClick = (item: ContextMenuItem) => {
if (item.disabled || hasChildren(item)) return
emit('select', item)
}
</script>
<style scoped>
.context-menu-list {
display: flex;
flex-direction: column;
}
.context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: #333;
transition: background 0.2s;
position: relative;
}
.context-menu-item-content {
display: flex;
align-items: center;
gap: 8px;
}
.context-menu-item:hover {
background: #f0f7ff;
}
.context-menu-item.disabled {
color: #999;
cursor: not-allowed;
}
.context-menu-item.disabled:hover {
background: transparent;
}
.context-submenu {
position: absolute;
top: 0;
left: 100%;
margin-left: 0;
transform: translateX(1px);
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
padding: 4px 0;
z-index: 1000;
display: none;
}
.submenu-arrow {
color: #999;
}
.context-submenu.open {
display: block;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div class="property-panel">
<div class="panel-header">
<span class="panel-title">{{ title || '属性配置' }}</span>
<button class="close-btn" @click="emit('close')">
<Icon icon="mdi:close" width="18" />
</button>
</div>
<div class="panel-body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineProps<{ title?: string }>()
const emit = defineEmits<{ close: [] }>()
</script>
<style scoped>
.property-panel {
position: absolute;
top: 16px;
right: 16px;
width: 320px;
max-height: calc(100% - 32px);
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 100;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #333;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: #666;
transition: all 0.2s;
}
.close-btn:hover {
background: #f5f5f5;
color: #333;
}
.panel-body {
padding: 16px;
overflow-y: auto;
flex: 1;
}
</style>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-aggregationNode) {
min-width: 230px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="aggregation-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { AggregationNodeData } from './types'
const props = defineProps<BaseNodeProps<AggregationNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:sigma-lower',
title: '聚合节点',
subtitle: 'Metrics',
accent: '#08979c',
handles: {
inputs: [Position.Left, Position.Top],
outputs: [Position.Right]
},
badge: ({ data }) => data.metric ?? '计数',
status: ({ data }) => data.status ?? '聚合中',
body: ({ data }) => [
{ label: '维度', value: data.dimension ?? 'global' },
{ label: '窗口', value: data.window ?? '1m' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface AggregationNodeData {
metric?: string
dimension?: string
window?: string
status?: string
}
export type AggregationNodeProps = BaseNodeProps<AggregationNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-conditionalNode) {
min-width: 240px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="conditional-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { ConditionalNodeData } from './types'
const props = defineProps<BaseNodeProps<ConditionalNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:help-rhombus',
title: '条件判断',
subtitle: 'Flow Control',
accent: '#faad14',
handles: {
inputs: [Position.Left],
outputs: [Position.Right, Position.Bottom]
},
badge: ({ data }) => data.mode ?? '分支',
status: ({ data }) => data.status ?? '待判定',
body: ({ data }) => [
{ label: '表达式', value: data.expression ?? 'true' },
{ label: '真/假', value: `${data.trueLabel ?? '通过'} / ${data.falseLabel ?? '驳回'}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,11 @@
import type { BaseNodeProps } from '../shared/types'
export interface ConditionalNodeData {
expression?: string
trueLabel?: string
falseLabel?: string
mode?: string
status?: string
}
export type ConditionalNodeProps = BaseNodeProps<ConditionalNodeData>

View File

@ -0,0 +1,91 @@
.container-node {
position: relative;
border: 2px dashed rgba(91, 143, 249, 0.6);
border-radius: 16px;
background: rgba(91, 143, 249, 0.04);
padding: 16px;
min-width: 320px;
min-height: 200px;
display: flex;
flex-direction: column;
gap: 12px;
transition: border-color 0.2s;
}
.container-node.is-selected {
border-style: solid;
}
.container-node__header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: #1f2329;
}
.container-node__header .title {
display: inline-flex;
align-items: center;
gap: 6px;
}
.container-node__header .actions {
display: flex;
gap: 6px;
}
.ghost-btn {
border: none;
padding: 4px;
background: rgba(0, 0, 0, 0.04);
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.ghost-btn:hover {
background: rgba(0, 0, 0, 0.08);
}
.container-node__body {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border-radius: 12px;
padding: 12px;
font-size: 12px;
color: #4a4a4a;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.container-node__stats {
display: flex;
justify-content: space-between;
color: #8c8c8c;
font-size: 11px;
}
.container-node__resize {
position: absolute;
right: 8px;
bottom: 8px;
width: 20px;
height: 20px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
cursor: se-resize;
}
.container-node.is-resizing .container-node__resize {
background: rgba(91, 143, 249, 0.2);
}
:global(.vue-flow__node-containerNode .vue-flow__handle) {
background: #fff;
border-color: rgba(91, 143, 249, 0.8);
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,123 @@
<template>
<div
class="container-node"
:class="{ 'is-selected': props.selected, 'is-resizing': resizing }"
:style="containerStyle"
ref="containerRef"
>
<div class="container-node__header">
<div class="title">
<Icon icon="mdi:shape-outline" width="16" />
<span>{{ props.data.title ?? '逻辑容器' }}</span>
</div>
<div class="actions">
<el-tooltip content="自适应大小">
<button class="ghost-btn" @click.stop="handleAutoSize">
<Icon icon="mdi:aspect-ratio" width="14" />
</button>
</el-tooltip>
<el-tooltip content="自由缩放">
<button class="ghost-btn" @click.stop="toggleAutoSize">
<Icon :icon="props.data.autoSize ? 'mdi:lock' : 'mdi:lock-open-variant'" width="14" />
</button>
</el-tooltip>
</div>
</div>
<div class="container-node__body">
<p class="description">
{{ props.data.description ?? '将流程节点放入同一容器,统一拖拽与管理。' }}
</p>
<div class="container-node__stats">
<span>{{ childCount }} 个节点</span>
<span>{{ props.data.autoSize ? '自动调整' : '自由缩放' }}</span>
</div>
</div>
<div class="container-node__resize" @mousedown="onResizeStart">
<Icon icon="mdi:cursor-move" width="14" />
</div>
<Handle type="target" :position="Position.Left" />
<Handle type="source" :position="Position.Right" />
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { Icon } from '@iconify/vue'
import { useFlowStore } from '@/stores/flow'
import type { ContainerNodeProps } from './types'
const props = defineProps<ContainerNodeProps>()
const store = useFlowStore()
const containerRef = ref<HTMLElement>()
const resizing = ref(false)
const startPoint = ref({ x: 0, y: 0 })
const startSize = ref({ width: 0, height: 0 })
const size = computed(() => ({
width: props.data.size?.width ?? 360,
height: props.data.size?.height ?? 240
}))
const containerStyle = computed(() => ({
width: `${size.value.width}px`,
height: `${size.value.height}px`,
borderColor: props.data.color ?? '#5b8ff9'
}))
const childCount = computed(() => store.countNodesInContainer(props.id))
const stopResize = () => {
resizing.value = false
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
const handleMouseMove = (event: MouseEvent) => {
if (!resizing.value) return
const deltaX = event.clientX - startPoint.value.x
const deltaY = event.clientY - startPoint.value.y
const width = Math.max(240, startSize.value.width + deltaX)
const height = Math.max(160, startSize.value.height + deltaY)
store.updateNodeData(props.id, {
size: { width, height },
autoSize: false
})
}
const handleMouseUp = () => {
stopResize()
}
const onResizeStart = (event: MouseEvent) => {
if (props.data.autoSize) return
event.preventDefault()
event.stopPropagation()
resizing.value = true
startPoint.value = { x: event.clientX, y: event.clientY }
startSize.value = { ...size.value }
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
}
const handleAutoSize = () => {
store.autoSizeContainer(props.id)
}
const toggleAutoSize = () => {
const nextState = !props.data.autoSize
store.updateNodeData(props.id, { autoSize: nextState })
if (nextState) {
store.autoSizeContainer(props.id)
}
}
onBeforeUnmount(() => {
stopResize()
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,16 @@
import type { BaseNodeProps } from '../shared/types'
export interface ContainerNodeSize {
width: number
height: number
}
export interface ContainerNodeData {
title?: string
description?: string
color?: string
autoSize?: boolean
size?: ContainerNodeSize
}
export type ContainerNodeProps = BaseNodeProps<ContainerNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-dataInputNode) {
min-width: 200px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,45 @@
<template>
<div class="data-input-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { DataInputNodeData } from './types'
const props = defineProps<BaseNodeProps<DataInputNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:database-import',
title: '数据输入',
subtitle: 'Input Stream',
accent: '#52c41a',
handles: {
outputs: [Position.Right]
},
status: ({ data }) => data.status ?? '待运行',
badge: ({ data }) => data.format ?? '自动检测',
body: ({ data }) => [
{ label: '来源', value: data.sourceType ?? '未配置' },
{ label: '路径', value: data.filePath ?? '待绑定' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface DataInputNodeData {
sourceType?: string
filePath?: string
format?: string
status?: string
}
export type DataInputNodeProps = BaseNodeProps<DataInputNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-dataOutputNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,45 @@
<template>
<div class="data-output-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { DataOutputNodeData } from './types'
const props = defineProps<BaseNodeProps<DataOutputNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:database-export',
title: '数据输出',
subtitle: 'Output Sink',
accent: '#f5222d',
handles: {
inputs: [Position.Left]
},
status: ({ data }) => data.status ?? '待推送',
badge: ({ data }) => data.strategy ?? '即时',
body: ({ data }) => [
{ label: '目标', value: data.destination ?? '未配置' },
{ label: '格式', value: data.format ?? '原样输出' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface DataOutputNodeData {
destination?: string
format?: string
strategy?: string
status?: string
}
export type DataOutputNodeProps = BaseNodeProps<DataOutputNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-dataTransferNode) {
min-width: 230px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="data-transfer-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { DataTransferNodeData } from './types'
const props = defineProps<BaseNodeProps<DataTransferNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:swap-horizontal',
title: '数据转接',
subtitle: 'Transfer Hub',
accent: '#1890ff',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.protocol ?? '自动',
status: ({ data }) => data.status ?? '就绪',
body: ({ data }) => [
{ label: '源头', value: data.source ?? '未配置' },
{ label: '目标', value: data.target ?? '未配置' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface DataTransferNodeData {
source?: string
target?: string
protocol?: string
status?: string
}
export type DataTransferNodeProps = BaseNodeProps<DataTransferNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-delayNode) {
min-width: 190px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="delay-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { DelayNodeData } from './types'
const props = defineProps<BaseNodeProps<DelayNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:timer-sand',
title: '延迟节点',
subtitle: 'Wait / Backoff',
accent: '#722ed1',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.mode ?? '固定',
status: ({ data }) => data.status ?? '排队',
body: ({ data }) => [
{ label: '时长', value: `${data.duration ?? 0}s` },
{ label: '策略', value: data.mode ?? '固定' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface DelayNodeData {
duration?: number
mode?: string
status?: string
}
export type DelayNodeProps = BaseNodeProps<DelayNodeData>

View File

@ -0,0 +1,11 @@
:global(.vue-flow__node-httpNode) {
min-width: 260px;
}
.url-row .row-value {
max-width: 140px;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,53 @@
<template>
<div class="http-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
>
<template #body>
<div class="standard-node__row">
<span class="row-label">方法</span>
<span class="row-value">{{ props.data.method ?? 'GET' }}</span>
</div>
<div class="standard-node__row url-row">
<span class="row-label">URL</span>
<span class="row-value">{{ props.data.url ?? '未配置' }}</span>
</div>
</template>
</StandardNode>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { HttpNodeData } from './types'
const props = defineProps<BaseNodeProps<HttpNodeData>>()
const { definition,bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:web',
title: 'HTTP 请求',
subtitle: 'REST Hook',
accent: '#fa8c16',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.method ?? 'GET',
status: ({ data }) => data.status ?? '待触发'
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface HttpNodeData {
method?: string
url?: string
status?: string
}
export type HttpNodeProps = BaseNodeProps<HttpNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-imageNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="image-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { ImageNodeData } from './types'
const props = defineProps<BaseNodeProps<ImageNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:image-filter-center-focus',
title: '图像处理',
subtitle: 'Vision Ops',
accent: '#d4380d',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.operation ?? '增强',
status: ({ data }) => data.status ?? '等待资源',
body: ({ data }) => [
{ label: '操作', value: data.operation ?? '未配置' },
{ label: '分辨率', value: data.resolution ?? 'Auto' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface ImageNodeData {
operation?: string
resolution?: string
status?: string
}
export type ImageNodeProps = BaseNodeProps<ImageNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-intentNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="intent-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { IntentNodeData } from './types'
const props = defineProps<BaseNodeProps<IntentNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:target',
title: '意图识别',
subtitle: 'Intent Parser',
accent: '#13c2c2',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => `${data.intents?.length ?? 0} 意图`,
status: ({ data }) => data.status ?? '待识别',
body: ({ data }) => [
{ label: '领域', value: data.domain ?? '通用' },
{ label: '置信度', value: `${data.confidence ?? 0}%` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface IntentNodeData {
intents?: string[]
confidence?: number
domain?: string
status?: string
}
export type IntentNodeProps = BaseNodeProps<IntentNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-llmNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="llm-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { LLMNodeData } from './types'
const props = defineProps<BaseNodeProps<LLMNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:robot',
title: '大模型',
subtitle: 'LLM Inference',
accent: '#722ed1',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.model ?? '未选择',
status: ({ data }) => data.status ?? '空闲',
body: ({ data }) => [
{ label: '模型', value: data.model ?? '未配置' },
{ label: '温度', value: `${data.temperature ?? 0.7}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface LLMNodeData {
model?: string
temperature?: number
status?: string
}
export type LLMNodeProps = BaseNodeProps<LLMNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-loopNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="loop-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { LoopNodeData } from './types'
const props = defineProps<BaseNodeProps<LoopNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:repeat-variant',
title: '循环节点',
subtitle: 'Iteration',
accent: '#fa541c',
handles: {
inputs: [Position.Left],
outputs: [Position.Right, Position.Bottom]
},
badge: ({ data }) => `${data.mode ?? '次数'}`,
status: ({ data }) => data.status ?? '迭代中',
body: ({ data }) => [
{ label: '次数', value: `${data.iterations ?? 1}` },
{ label: '变量', value: data.iterator ?? 'item' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface LoopNodeData {
iterations?: number
iterator?: string
mode?: string
status?: string
}
export type LoopNodeProps = BaseNodeProps<LoopNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-mcpNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="mcp-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { MCPNodeData } from './types'
const props = defineProps<BaseNodeProps<MCPNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:power-plug',
title: 'MCP 调用',
subtitle: 'Service Bridge',
accent: '#eb2f96',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.service ?? '未绑定',
status: ({ data }) => data.status ?? '空闲',
body: ({ data }) => [
{ label: '服务', value: data.service ?? '未知' },
{ label: '动作', value: data.action ?? '未配置' }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface MCPNodeData {
service?: string
action?: string
status?: string
}
export type MCPNodeProps = BaseNodeProps<MCPNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-scriptNode) {
min-width: 220px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="script-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { ScriptNodeData } from './types'
const props = defineProps<BaseNodeProps<ScriptNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:code-tags',
title: '脚本节点',
subtitle: 'Custom Logic',
accent: '#177ddc',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.language ?? 'TypeScript',
status: ({ data }) => data.status ?? '待执行',
body: ({ data }) => [
{ label: '入口', value: data.entry ?? 'main' },
{ label: '依赖', value: `${data.dependencies ?? 0}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface ScriptNodeData {
language?: string
entry?: string
dependencies?: number
status?: string
}
export type ScriptNodeProps = BaseNodeProps<ScriptNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-udpNode) {
min-width: 210px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="udp-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { UdpNodeData } from './types'
const props = defineProps<BaseNodeProps<UdpNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:broadcast',
title: 'UDP 发送',
subtitle: 'Realtime Push',
accent: '#2f54eb',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => `${data.port ?? 0}`,
status: ({ data }) => data.status ?? '监听中',
body: ({ data }) => [
{ label: '主机', value: data.host ?? '0.0.0.0' },
{ label: '端口', value: `${data.port ?? 0}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,9 @@
import type { BaseNodeProps } from '../shared/types'
export interface UdpNodeData {
host?: string
port?: number
status?: string
}
export type UdpNodeProps = BaseNodeProps<UdpNodeData>

View File

@ -0,0 +1,3 @@
:global(.vue-flow__node-webhookNode) {
min-width: 240px;
}

View File

@ -0,0 +1 @@
export {}

View File

@ -0,0 +1,46 @@
<template>
<div class="webhook-node">
<StandardNode
:icon="definition.icon"
:title="definition.title"
:subtitle="definition.subtitle"
:accent="definition.accent"
:badge="badge"
:status="status"
:handles="definition.handles"
:body-items="bodyItems"
:selected="props.selected"
/>
</div>
</template>
<script setup lang="ts">
import { Position } from '@vue-flow/core'
import StandardNode from '../shared/StandardNode.vue'
import { useStandardNode } from '../shared/useStandardNode'
import type { BaseNodeProps } from '../shared/types'
import type { WebhookNodeData } from './types'
const props = defineProps<BaseNodeProps<WebhookNodeData>>()
const { definition, bodyItems, badge, status } = useStandardNode(props, {
icon: 'mdi:webhook',
title: 'Webhook',
subtitle: 'Outbound Hook',
accent: '#52c41a',
handles: {
inputs: [Position.Left],
outputs: [Position.Right]
},
badge: ({ data }) => data.method ?? 'POST',
status: ({ data }) => data.status ?? '监听中',
body: ({ data }) => [
{ label: 'Endpoint', value: data.endpoint ?? '未配置' },
{ label: '重试', value: `${data.retries ?? 0}` }
]
})
</script>
<style scoped lang="scss">
@use './index.scss' as *;
</style>

View File

@ -0,0 +1,10 @@
import type { BaseNodeProps } from '../shared/types'
export interface WebhookNodeData {
endpoint?: string
method?: string
retries?: number
status?: string
}
export type WebhookNodeProps = BaseNodeProps<WebhookNodeData>

View File

@ -0,0 +1,36 @@
import { markRaw } from 'vue'
import AggregationNode from './AggregationNode/index.vue'
import ConditionalNode from './ConditionalNode/index.vue'
import ContainerNode from './ContainerNode/index.vue'
import DataInputNode from './DataInputNode/index.vue'
import DataOutputNode from './DataOutputNode/index.vue'
import DataTransferNode from './DataTransferNode/index.vue'
import DelayNode from './DelayNode/index.vue'
import HttpNode from './HttpNode/index.vue'
import ImageNode from './ImageNode/index.vue'
import IntentNode from './IntentNode/index.vue'
import LLMNode from './LLMNode/index.vue'
import LoopNode from './LoopNode/index.vue'
import MCPNode from './MCPNode/index.vue'
import ScriptNode from './ScriptNode/index.vue'
import UdpNode from './UdpNode/index.vue'
import WebhookNode from './WebhookNode/index.vue'
export const nodeTypes = {
dataInputNode: markRaw(DataInputNode),
dataTransferNode: markRaw(DataTransferNode),
dataOutputNode: markRaw(DataOutputNode),
llmNode: markRaw(LLMNode),
intentNode: markRaw(IntentNode),
mcpNode: markRaw(MCPNode),
httpNode: markRaw(HttpNode),
udpNode: markRaw(UdpNode),
conditionalNode: markRaw(ConditionalNode),
delayNode: markRaw(DelayNode),
loopNode: markRaw(LoopNode),
scriptNode: markRaw(ScriptNode),
imageNode: markRaw(ImageNode),
aggregationNode: markRaw(AggregationNode),
webhookNode: markRaw(WebhookNode),
containerNode: markRaw(ContainerNode)
}

View File

@ -0,0 +1,95 @@
<template>
<div class="standard-node" :class="nodeClasses" :style="nodeStyle">
<div class="standard-node__header">
<div class="standard-node__icon">
<Icon :icon="props.icon" width="18" />
</div>
<div class="standard-node__meta">
<div class="standard-node__title">{{ props.title }}</div>
<div v-if="props.subtitle" class="standard-node__subtitle">{{ props.subtitle }}</div>
</div>
<span v-if="badge" class="standard-node__badge">{{ badge }}</span>
</div>
<div class="standard-node__body">
<slot name="body">
<div
v-for="item in bodyItems"
:key="item.label"
class="standard-node__row"
>
<span class="row-label">{{ item.label }}</span>
<span class="row-value">{{ item.value }}</span>
</div>
</slot>
</div>
<div class="standard-node__footer">
<slot name="footer">
<span v-if="status" class="node-status">
<span class="dot" />
{{ status }}
</span>
</slot>
</div>
<div class="standard-node__handles standard-node__handles--input">
<Handle
v-for="handle in inputHandles"
:key="`input-${handle}`"
type="target"
:position="handle"
/>
</div>
<div class="standard-node__handles standard-node__handles--output">
<Handle
v-for="handle in outputHandles"
:key="`output-${handle}`"
type="source"
:position="handle"
/>
</div>
<div class="standard-node__handles standard-node__handles--bidirectional">
<Handle
v-for="handle in bidirectionalHandles"
:key="`both-${handle}`"
type="source"
:position="handle"
/>
<Handle
v-for="handle in bidirectionalHandles"
:key="`both-target-${handle}`"
type="target"
:position="handle"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { Icon } from '@iconify/vue'
import type { StandardNodeProps } from './types'
const props = defineProps<StandardNodeProps>()
const nodeClasses = computed(() => ({
'is-selected': props.selected
}))
const nodeStyle = computed(() => ({
'--node-accent': props.accent
}))
const mapHandles = (handles?: Position[]) => handles ?? []
const inputHandles = computed(() => mapHandles(props.handles?.inputs))
const outputHandles = computed(() => mapHandles(props.handles?.outputs))
const bidirectionalHandles = computed(() => mapHandles(props.handles?.both))
const bodyItems = computed(() => props.bodyItems ?? [])
const badge = computed(() => props.badge)
const status = computed(() => props.status)
</script>
<style scoped lang="scss">
@use './standard-node.scss';
</style>

View File

@ -0,0 +1,114 @@
.standard-node {
position: relative;
min-width: 160px;
min-height: 96px;
padding: 12px 14px;
background: #fff;
border: 2px solid rgba(0, 0, 0, 0.08);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(24, 39, 75, 0.08);
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.standard-node.is-selected {
border-color: var(--node-accent, #409eff);
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.25);
}
.standard-node__header {
display: flex;
align-items: center;
gap: 10px;
}
.standard-node__icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(64, 158, 255, 0.08);
color: var(--node-accent, #409eff);
display: flex;
align-items: center;
justify-content: center;
}
.standard-node__meta {
display: flex;
flex-direction: column;
flex: 1;
}
.standard-node__title {
font-size: 14px;
font-weight: 600;
color: #1f2329;
}
.standard-node__subtitle {
font-size: 11px;
color: #8c8c8c;
}
.standard-node__badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(64, 158, 255, 0.12);
color: var(--node-accent, #409eff);
}
.standard-node__body {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #4a4a4a;
}
.standard-node__row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.row-label {
color: #8c8c8c;
}
.row-value {
color: #1f2329;
font-weight: 500;
}
.standard-node__footer {
font-size: 11px;
color: #8c8c8c;
}
.node-status {
display: inline-flex;
align-items: center;
gap: 4px;
}
.node-status .dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--node-accent, #409eff);
display: inline-flex;
}
.standard-node__handles {
pointer-events: none;
}
:deep(.vue-flow__handle) {
background: #fff;
border: 2px solid var(--node-accent, #409eff);
width: 10px;
height: 10px;
}

View File

@ -0,0 +1,42 @@
import type { Position } from '@vue-flow/core'
export interface NodeBodyItem {
label: string
value: string
}
export interface NodeHandleConfig {
inputs?: Position[]
outputs?: Position[]
both?: Position[]
}
export interface StandardNodeProps {
icon: string
title: string
subtitle?: string
badge?: string
accent?: string
status?: string
bodyItems?: NodeBodyItem[]
handles?: NodeHandleConfig
selected?: boolean
}
export interface BaseNodeProps<T = Record<string, any>> {
id: string
label?: string
selected?: boolean
data: T
}
export interface StandardNodeOptions<TData> {
icon: string
title: string
subtitle?: string
accent?: string
handles?: NodeHandleConfig
badge?: string | ((props: BaseNodeProps<TData>) => string | undefined)
status?: (props: BaseNodeProps<TData>) => string | undefined
body?: (props: BaseNodeProps<TData>) => NodeBodyItem[]
}

View File

@ -0,0 +1,28 @@
import { computed } from 'vue'
import type { BaseNodeProps, StandardNodeOptions } from './types'
export function useStandardNode<TData>(
props: BaseNodeProps<TData>,
options: StandardNodeOptions<TData>
) {
const bodyItems = computed(() => {
return options.body ? options.body(props) : []
})
const badge = computed(() => {
if (typeof options.badge === 'function') {
return options.badge(props)
}
return options.badge
})
const status = computed(() => options.status?.(props))
return {
props,
definition: options,
bodyItems,
badge,
status
}
}

View File

@ -0,0 +1,197 @@
export interface NodeSize {
width: number
height: number
}
export interface NodeDescriptor {
type: string
label: string
icon: string
color: string
subtitle?: string
size?: NodeSize
defaults?: Record<string, any>
}
export interface NodeCategory {
key: string
label: string
icon: string
nodes: NodeDescriptor[]
}
const dataNodes: NodeDescriptor[] = [
{
type: 'dataInputNode',
label: '数据输入',
icon: 'mdi:database-import',
color: '#52c41a',
subtitle: 'Input Stream',
size: { width: 200, height: 120 },
defaults: { sourceType: '文件', format: '自动检测' }
},
{
type: 'dataTransferNode',
label: '数据转接',
icon: 'mdi:swap-horizontal',
color: '#1890ff',
subtitle: 'Transfer Hub',
size: { width: 230, height: 120 },
defaults: { protocol: '自动' }
},
{
type: 'dataOutputNode',
label: '数据输出',
icon: 'mdi:database-export',
color: '#f5222d',
subtitle: 'Output Sink',
size: { width: 220, height: 120 },
defaults: { strategy: '即时', format: 'JSON' }
},
{
type: 'aggregationNode',
label: '聚合节点',
icon: 'mdi:sigma-lower',
color: '#08979c',
subtitle: 'Metrics',
size: { width: 230, height: 130 },
defaults: { metric: 'count', dimension: 'global' }
}
]
const aiNodes: NodeDescriptor[] = [
{
type: 'llmNode',
label: '大模型',
icon: 'mdi:robot',
color: '#722ed1',
subtitle: 'LLM Inference',
size: { width: 220, height: 130 },
defaults: { model: 'gpt-4o-mini', temperature: 0.7 }
},
{
type: 'intentNode',
label: '意图识别',
icon: 'mdi:target',
color: '#13c2c2',
subtitle: 'Intent Parser',
size: { width: 210, height: 120 },
defaults: { domain: '通用', confidence: 80 }
},
{
type: 'imageNode',
label: '图像处理',
icon: 'mdi:image-filter-center-focus',
color: '#d4380d',
subtitle: 'Vision Ops',
size: { width: 220, height: 130 },
defaults: { operation: '增强', resolution: 'Auto' }
},
{
type: 'scriptNode',
label: '脚本节点',
icon: 'mdi:code-tags',
color: '#177ddc',
subtitle: 'Custom Logic',
size: { width: 220, height: 120 },
defaults: { language: 'TypeScript', entry: 'main' }
}
]
const integrationNodes: NodeDescriptor[] = [
{
type: 'httpNode',
label: 'HTTP 请求',
icon: 'mdi:web',
color: '#fa8c16',
subtitle: 'REST Hook',
size: { width: 260, height: 130 },
defaults: { method: 'GET', url: '' }
},
{
type: 'webhookNode',
label: 'Webhook',
icon: 'mdi:webhook',
color: '#52c41a',
subtitle: 'Outbound Hook',
size: { width: 240, height: 130 },
defaults: { method: 'POST', endpoint: '' }
},
{
type: 'mcpNode',
label: 'MCP 调用',
icon: 'mdi:power-plug',
color: '#eb2f96',
subtitle: 'Service Bridge',
size: { width: 210, height: 120 },
defaults: { service: '默认服务', action: 'invoke' }
},
{
type: 'udpNode',
label: 'UDP 发送',
icon: 'mdi:broadcast',
color: '#2f54eb',
subtitle: 'Realtime Push',
size: { width: 210, height: 120 },
defaults: { host: '127.0.0.1', port: 9000 }
}
]
const controlNodes: NodeDescriptor[] = [
{
type: 'conditionalNode',
label: '条件判断',
icon: 'mdi:help-rhombus',
color: '#faad14',
subtitle: 'Flow Control',
size: { width: 240, height: 130 },
defaults: { expression: 'score > 0.8', trueLabel: '通过', falseLabel: '拦截' }
},
{
type: 'delayNode',
label: '延迟节点',
icon: 'mdi:timer-sand',
color: '#722ed1',
subtitle: 'Wait / Backoff',
size: { width: 190, height: 110 },
defaults: { duration: 5, mode: '固定' }
},
{
type: 'loopNode',
label: '循环节点',
icon: 'mdi:repeat-variant',
color: '#fa541c',
subtitle: 'Iteration',
size: { width: 210, height: 130 },
defaults: { iterations: 3, iterator: 'item', mode: '次数' }
}
]
const layoutNodes: NodeDescriptor[] = [
{
type: 'containerNode',
label: '容器节点',
icon: 'mdi:shape-outline',
color: '#5b8ff9',
subtitle: 'Grouping',
size: { width: 360, height: 240 },
defaults: { title: '容器', autoSize: true, size: { width: 360, height: 240 } }
}
]
export const nodeCategories: NodeCategory[] = [
{ key: 'data', label: '数据层', icon: 'mdi:database', nodes: dataNodes },
{ key: 'ai', label: '智能层', icon: 'mdi:robot-happy-outline', nodes: aiNodes },
{ key: 'integrations', label: '集成层', icon: 'mdi:link-variant', nodes: integrationNodes },
{ key: 'control', label: '控制层', icon: 'mdi:axis-arrow', nodes: controlNodes },
{ key: 'structure', label: '结构节点', icon: 'mdi:arrange-bring-forward', nodes: layoutNodes }
]
export const nodePalette = nodeCategories.flatMap(category => category.nodes)
export const nodePaletteMap = nodePalette.reduce<Record<string, NodeDescriptor>>((acc, node) => {
acc[node.type] = node
return acc
}, {})
export const getNodeDescriptor = (type: string) => nodePaletteMap[type]

19
frontend/src/main.ts Normal file
View File

@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

161
frontend/src/stores/flow.ts Normal file
View File

@ -0,0 +1,161 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Edge, Node, XYPosition } from '@vue-flow/core'
import { getNodeDescriptor, nodePaletteMap } from '../data/nodeCatalog'
interface AddNodeOptions {
data?: Record<string, any>
parentId?: string
}
const defaultSize = { width: 200, height: 120 }
const containerPadding = 32
export const useFlowStore = defineStore('flow', () => {
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
const selectedNode = ref<Node | null>(null)
const nodeStatuses = ref<Record<string, string>>({})
const getNodeSize = (type: string) => nodePaletteMap[type]?.size ?? defaultSize
const addNode = (type: string, position: XYPosition, options: AddNodeOptions = {}) => {
const descriptor = getNodeDescriptor(type)
const id = `${type}_${Date.now()}`
const data = { ...(descriptor?.defaults ?? {}), ...(options.data ?? {}) }
const size = descriptor?.size
const node: Node = {
id,
type,
position,
label: descriptor?.label ?? type,
data,
style: size ? { width: size.width, height: size.height } : undefined
}
if (options.parentId) {
node.parentNode = options.parentId
node.extent = 'parent'
}
nodes.value.push(node)
return id
}
const updateNodeData = (nodeId: string, data: Record<string, any>) => {
const node = nodes.value.find(n => n.id === nodeId)
if (node) {
node.data = { ...node.data, ...data }
}
}
const setNodeStatus = (nodeId: string, status: string) => {
nodeStatuses.value[nodeId] = status
updateNodeData(nodeId, { status })
}
const clearStatuses = () => {
nodeStatuses.value = {}
}
const countNodesInContainer = (containerId: string) => {
return nodes.value.filter(n => n.parentNode === containerId).length
}
const deleteNode = (nodeId: string) => {
const node = nodes.value.find(n => n.id === nodeId)
if (!node) return
if (node.type === 'containerNode') {
const children = nodes.value.filter(n => n.parentNode === nodeId)
children.forEach(child => {
child.parentNode = undefined
child.extent = undefined
child.position = {
x: child.position.x + node.position.x,
y: child.position.y + node.position.y
}
})
}
nodes.value = nodes.value.filter(n => n.id !== nodeId)
edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId)
if (selectedNode.value?.id === nodeId) {
selectedNode.value = null
}
}
const deleteEdge = (edgeId: string) => {
edges.value = edges.value.filter(e => e.id !== edgeId)
}
const copyNode = (nodeId: string) => {
const node = nodes.value.find(n => n.id === nodeId)
if (!node) return
const newId = `${node.type}_${Date.now()}`
const newNode: Node = {
...node,
id: newId,
position: {
x: node.position.x + 40,
y: node.position.y + 40
},
data: { ...node.data }
}
nodes.value.push(newNode)
return newId
}
const autoSizeContainer = (containerId: string) => {
const container = nodes.value.find(n => n.id === containerId)
if (!container) return
const children = nodes.value.filter(n => n.parentNode === containerId)
if (!children.length) {
const fallback = container.data.size ?? nodePaletteMap.containerNode?.size ?? { width: 360, height: 240 }
updateNodeData(containerId, { size: fallback })
return
}
let maxX = 0
let maxY = 0
children.forEach(child => {
const size = getNodeSize(child.type)
maxX = Math.max(maxX, child.position.x + size.width)
maxY = Math.max(maxY, child.position.y + size.height)
})
updateNodeData(containerId, {
size: {
width: maxX + containerPadding,
height: maxY + containerPadding
}
})
}
const findContainerByPoint = (point: XYPosition) => {
return nodes.value.find(node => {
if (node.type !== 'containerNode') return false
const size = (node.data?.size as { width: number; height: number }) ?? nodePaletteMap.containerNode?.size ?? { width: 360, height: 240 }
return (
point.x >= node.position.x &&
point.x <= node.position.x + size.width &&
point.y >= node.position.y &&
point.y <= node.position.y + size.height
)
})
}
return {
nodes,
edges,
selectedNode,
nodeStatuses,
addNode,
updateNodeData,
setNodeStatus,
clearStatuses,
deleteNode,
deleteEdge,
copyNode,
countNodesInContainer,
autoSizeContainer,
findContainerByPoint
}
})

View File

@ -0,0 +1,8 @@
export interface ContextMenuItem {
label: string
icon: string
action?: string
disabled?: boolean
payload?: Record<string, any>
children?: ContextMenuItem[]
}

6
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

18
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*", "src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

Some files were not shown because too many files have changed in this diff Show More