init
This commit is contained in:
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal 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
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"git.ignoreLimitWarning": true
|
||||||
|
}
|
||||||
2
backend/.env.example
Normal file
2
backend/.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
OPENAI_API_KEY=your_api_key_here
|
||||||
|
OPENAI_BASE_URL=
|
||||||
197
backend/bun.lock
Normal file
197
backend/bun.lock
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/flow.db
Normal file
BIN
backend/flow.db
Normal file
Binary file not shown.
12
backend/package.json
Normal file
12
backend/package.json
Normal 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
25
backend/src/db.ts
Normal 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;
|
||||||
7
backend/src/executors/base.ts
Normal file
7
backend/src/executors/base.ts
Normal 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>>;
|
||||||
|
}
|
||||||
14
backend/src/executors/index.ts
Normal file
14
backend/src/executors/index.ts
Normal 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];
|
||||||
20
backend/src/executors/llm.ts
Normal file
20
backend/src/executors/llm.ts
Normal 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
101
backend/src/index.ts
Normal 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}`);
|
||||||
73
backend/src/services/executor.ts
Normal file
73
backend/src/services/executor.ts
Normal 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;
|
||||||
|
}
|
||||||
25
backend/src/services/socket.ts
Normal file
25
backend/src/services/socket.ts
Normal 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
40
flow.md
Normal 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
12
frontend/index.html
Normal 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
3070
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal 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
498
frontend/src/App.vue
Normal 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
23
frontend/src/api/index.ts
Normal 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}`)
|
||||||
|
}
|
||||||
54
frontend/src/assets/main.css
Normal file
54
frontend/src/assets/main.css
Normal 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; }
|
||||||
|
}
|
||||||
62
frontend/src/components/ContextMenu.vue
Normal file
62
frontend/src/components/ContextMenu.vue
Normal 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>
|
||||||
187
frontend/src/components/ContextMenuList.vue
Normal file
187
frontend/src/components/ContextMenuList.vue
Normal 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>
|
||||||
70
frontend/src/components/PropertyPanel.vue
Normal file
70
frontend/src/components/PropertyPanel.vue
Normal 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>
|
||||||
3
frontend/src/components/nodes/AggregationNode/index.scss
Normal file
3
frontend/src/components/nodes/AggregationNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-aggregationNode) {
|
||||||
|
min-width: 230px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/AggregationNode/index.ts
Normal file
1
frontend/src/components/nodes/AggregationNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/AggregationNode/index.vue
Normal file
46
frontend/src/components/nodes/AggregationNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/AggregationNode/types.ts
Normal file
10
frontend/src/components/nodes/AggregationNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/ConditionalNode/index.scss
Normal file
3
frontend/src/components/nodes/ConditionalNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-conditionalNode) {
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/ConditionalNode/index.ts
Normal file
1
frontend/src/components/nodes/ConditionalNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/ConditionalNode/index.vue
Normal file
46
frontend/src/components/nodes/ConditionalNode/index.vue
Normal 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>
|
||||||
11
frontend/src/components/nodes/ConditionalNode/types.ts
Normal file
11
frontend/src/components/nodes/ConditionalNode/types.ts
Normal 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>
|
||||||
91
frontend/src/components/nodes/ContainerNode/index.scss
Normal file
91
frontend/src/components/nodes/ContainerNode/index.scss
Normal 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);
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/ContainerNode/index.ts
Normal file
1
frontend/src/components/nodes/ContainerNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
123
frontend/src/components/nodes/ContainerNode/index.vue
Normal file
123
frontend/src/components/nodes/ContainerNode/index.vue
Normal 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>
|
||||||
16
frontend/src/components/nodes/ContainerNode/types.ts
Normal file
16
frontend/src/components/nodes/ContainerNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/DataInputNode/index.scss
Normal file
3
frontend/src/components/nodes/DataInputNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-dataInputNode) {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/DataInputNode/index.ts
Normal file
1
frontend/src/components/nodes/DataInputNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
45
frontend/src/components/nodes/DataInputNode/index.vue
Normal file
45
frontend/src/components/nodes/DataInputNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/DataInputNode/types.ts
Normal file
10
frontend/src/components/nodes/DataInputNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/DataOutputNode/index.scss
Normal file
3
frontend/src/components/nodes/DataOutputNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-dataOutputNode) {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/DataOutputNode/index.ts
Normal file
1
frontend/src/components/nodes/DataOutputNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
45
frontend/src/components/nodes/DataOutputNode/index.vue
Normal file
45
frontend/src/components/nodes/DataOutputNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/DataOutputNode/types.ts
Normal file
10
frontend/src/components/nodes/DataOutputNode/types.ts
Normal 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>
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-dataTransferNode) {
|
||||||
|
min-width: 230px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/DataTransferNode/index.ts
Normal file
1
frontend/src/components/nodes/DataTransferNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/DataTransferNode/index.vue
Normal file
46
frontend/src/components/nodes/DataTransferNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/DataTransferNode/types.ts
Normal file
10
frontend/src/components/nodes/DataTransferNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/DelayNode/index.scss
Normal file
3
frontend/src/components/nodes/DelayNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-delayNode) {
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/DelayNode/index.ts
Normal file
1
frontend/src/components/nodes/DelayNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/DelayNode/index.vue
Normal file
46
frontend/src/components/nodes/DelayNode/index.vue
Normal 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>
|
||||||
9
frontend/src/components/nodes/DelayNode/types.ts
Normal file
9
frontend/src/components/nodes/DelayNode/types.ts
Normal 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>
|
||||||
11
frontend/src/components/nodes/HttpNode/index.scss
Normal file
11
frontend/src/components/nodes/HttpNode/index.scss
Normal 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;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/HttpNode/index.ts
Normal file
1
frontend/src/components/nodes/HttpNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
53
frontend/src/components/nodes/HttpNode/index.vue
Normal file
53
frontend/src/components/nodes/HttpNode/index.vue
Normal 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>
|
||||||
9
frontend/src/components/nodes/HttpNode/types.ts
Normal file
9
frontend/src/components/nodes/HttpNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/ImageNode/index.scss
Normal file
3
frontend/src/components/nodes/ImageNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-imageNode) {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/ImageNode/index.ts
Normal file
1
frontend/src/components/nodes/ImageNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/ImageNode/index.vue
Normal file
46
frontend/src/components/nodes/ImageNode/index.vue
Normal 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>
|
||||||
9
frontend/src/components/nodes/ImageNode/types.ts
Normal file
9
frontend/src/components/nodes/ImageNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/IntentNode/index.scss
Normal file
3
frontend/src/components/nodes/IntentNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-intentNode) {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/IntentNode/index.ts
Normal file
1
frontend/src/components/nodes/IntentNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/IntentNode/index.vue
Normal file
46
frontend/src/components/nodes/IntentNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/IntentNode/types.ts
Normal file
10
frontend/src/components/nodes/IntentNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/LLMNode/index.scss
Normal file
3
frontend/src/components/nodes/LLMNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-llmNode) {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/LLMNode/index.ts
Normal file
1
frontend/src/components/nodes/LLMNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/LLMNode/index.vue
Normal file
46
frontend/src/components/nodes/LLMNode/index.vue
Normal 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>
|
||||||
9
frontend/src/components/nodes/LLMNode/types.ts
Normal file
9
frontend/src/components/nodes/LLMNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/LoopNode/index.scss
Normal file
3
frontend/src/components/nodes/LoopNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-loopNode) {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/LoopNode/index.ts
Normal file
1
frontend/src/components/nodes/LoopNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/LoopNode/index.vue
Normal file
46
frontend/src/components/nodes/LoopNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/LoopNode/types.ts
Normal file
10
frontend/src/components/nodes/LoopNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/MCPNode/index.scss
Normal file
3
frontend/src/components/nodes/MCPNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-mcpNode) {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/MCPNode/index.ts
Normal file
1
frontend/src/components/nodes/MCPNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/MCPNode/index.vue
Normal file
46
frontend/src/components/nodes/MCPNode/index.vue
Normal 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>
|
||||||
9
frontend/src/components/nodes/MCPNode/types.ts
Normal file
9
frontend/src/components/nodes/MCPNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/ScriptNode/index.scss
Normal file
3
frontend/src/components/nodes/ScriptNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-scriptNode) {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/ScriptNode/index.ts
Normal file
1
frontend/src/components/nodes/ScriptNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/ScriptNode/index.vue
Normal file
46
frontend/src/components/nodes/ScriptNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/ScriptNode/types.ts
Normal file
10
frontend/src/components/nodes/ScriptNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/UdpNode/index.scss
Normal file
3
frontend/src/components/nodes/UdpNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-udpNode) {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/UdpNode/index.ts
Normal file
1
frontend/src/components/nodes/UdpNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/UdpNode/index.vue
Normal file
46
frontend/src/components/nodes/UdpNode/index.vue
Normal 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>
|
||||||
9
frontend/src/components/nodes/UdpNode/types.ts
Normal file
9
frontend/src/components/nodes/UdpNode/types.ts
Normal 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>
|
||||||
3
frontend/src/components/nodes/WebhookNode/index.scss
Normal file
3
frontend/src/components/nodes/WebhookNode/index.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
:global(.vue-flow__node-webhookNode) {
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
1
frontend/src/components/nodes/WebhookNode/index.ts
Normal file
1
frontend/src/components/nodes/WebhookNode/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {}
|
||||||
46
frontend/src/components/nodes/WebhookNode/index.vue
Normal file
46
frontend/src/components/nodes/WebhookNode/index.vue
Normal 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>
|
||||||
10
frontend/src/components/nodes/WebhookNode/types.ts
Normal file
10
frontend/src/components/nodes/WebhookNode/types.ts
Normal 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>
|
||||||
36
frontend/src/components/nodes/index.ts
Normal file
36
frontend/src/components/nodes/index.ts
Normal 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)
|
||||||
|
}
|
||||||
95
frontend/src/components/nodes/shared/StandardNode.vue
Normal file
95
frontend/src/components/nodes/shared/StandardNode.vue
Normal 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>
|
||||||
114
frontend/src/components/nodes/shared/standard-node.scss
Normal file
114
frontend/src/components/nodes/shared/standard-node.scss
Normal 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;
|
||||||
|
}
|
||||||
42
frontend/src/components/nodes/shared/types.ts
Normal file
42
frontend/src/components/nodes/shared/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
28
frontend/src/components/nodes/shared/useStandardNode.ts
Normal file
28
frontend/src/components/nodes/shared/useStandardNode.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
197
frontend/src/data/nodeCatalog.ts
Normal file
197
frontend/src/data/nodeCatalog.ts
Normal 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
19
frontend/src/main.ts
Normal 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
161
frontend/src/stores/flow.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
8
frontend/src/types/context-menu.ts
Normal file
8
frontend/src/types/context-menu.ts
Normal 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
6
frontend/src/vite-env.d.ts
vendored
Normal 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
18
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
Reference in New Issue
Block a user