init
This commit is contained in:
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"]
|
||||
}
|
||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler'
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/ws': { target: 'ws://localhost:8000', ws: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user