Browse Source

画布正常拖动

master
MSI-7950X\刘泽明 6 days ago
parent
commit
cec1e0634d
  1. 11
      README.md
  2. 65
      docker-compose.prod.yml
  3. 20
      docker-compose.yml
  4. 12
      frontend/Dockerfile
  5. 10
      frontend/Dockerfile.dev
  6. 16
      frontend/Dockerfile.prod
  7. 8
      frontend/src/components/layout/MainLayout.vue
  8. 56
      frontend/src/router/index.ts
  9. 159
      frontend/src/views/flow/FlowCanvas.vue
  10. 186
      frontend/src/views/flow/FlowEditor.vue
  11. 64
      frontend/src/views/flow/node-configs/ConditionConfig.vue
  12. 92
      frontend/src/views/flow/node-configs/LlmConfig.vue
  13. 63
      frontend/src/views/flow/node-configs/McpConfig.vue
  14. 56
      frontend/src/views/flow/node-configs/OutputConfig.vue
  15. 56
      frontend/src/views/flow/node-configs/RagConfig.vue
  16. 89
      frontend/src/views/flow/node-configs/ToolConfig.vue
  17. 22
      frontend/src/views/flow/node-configs/TriggerConfig.vue
  18. 49
      frontend/src/views/flow/node-configs/WecomNotifyConfig.vue
  19. 5
      frontend/vite.config.ts
  20. BIN
      hg-agents.zip
  21. 52
      nginx/nginx.dev.conf
  22. 48
      nginx/nginx.prod.conf

11
README.md

@ -5,3 +5,14 @@ admin admin123 super_admin 系统管理员测试
manager manager123 dept_manager 部门经理权限测试 manager manager123 dept_manager 部门经理权限测试
employee employee123 employee 普通员工权限测试 employee employee123 employee 普通员工权限测试
需求ai工作平台,场景
1、用户和ai沟通工作,有现成流程给用户选择使用;
2、上司可以查看下属工作情况,ai会综合评估下属工作情况;
3、上司可以通过ai工作平台指派任务给下属;
4、流程由后台通过组件无代码搭建;
5、ai工作平台通过mcp服务获取系统数据,或者更新数据,mcp服务需要原系统支持,
6、知识库用于提升模型认知,提升专业程度;
7、预留企业微信接入逻辑

65
docker-compose.prod.yml

@ -0,0 +1,65 @@
services:
postgres:
image: postgres:16-alpine
container_name: ent-postgres
restart: always
environment:
POSTGRES_USER: enterprise
POSTGRES_PASSWORD: enterprise123
POSTGRES_DB: enterprise_ai
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
ports:
- "5431:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U enterprise"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: ent-redis
restart: always
command: redis-server --appendonly yes --requirepass redis123
volumes:
- redis_data:/data
ports:
- "6378:6379"
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ent-backend
restart: always
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
environment:
DATABASE_URL: postgresql+asyncpg://enterprise:enterprise123@postgres:5432/enterprise_ai
REDIS_URL: redis://:redis123@redis:6379/0
JWT_SECRET: dev-secret-key-change-in-production-32chars
LLM_API_KEY: ${LLM_API_KEY:-sk-placeholder}
LLM_API_BASE: ${LLM_API_BASE:-https://api.openai.com/v1}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
ports:
- "8100:8000"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
container_name: ent-frontend
restart: always
depends_on:
- backend
ports:
- "80:80"
volumes:
postgres_data:
redis_data:

20
docker-compose.yml

@ -1,3 +1,12 @@
# 开发环境配置文件
# 使用方式: docker compose -f docker-compose.yml up --build -d
#
# 特点:
# - 前端使用 Vite 开发服务器,支持热更新 (HMR)
# - 代码修改后自动刷新浏览器
# - Nginx 代理 WebSocket 连接用于 HMR
# - 挂载本地代码目录到容器
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@ -54,13 +63,18 @@ services:
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile.dev
container_name: ent-frontend container_name: ent-frontend
restart: always restart: always
depends_on: depends_on:
- backend - backend
ports: ports:
- "3000:80" - "3000:80"
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- CHOKIDAR_USEPOLLING=true
nginx: nginx:
image: nginx:alpine image: nginx:alpine
@ -70,10 +84,10 @@ services:
- frontend - frontend
- backend - backend
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro
ports: ports:
- "80:80" - "80:80"
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

12
frontend/Dockerfile

@ -1,12 +1,10 @@
FROM node:20-alpine AS build FROM node:20-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine EXPOSE 80
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "80"]
EXPOSE 80

10
frontend/Dockerfile.dev

@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 80
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "80"]

16
frontend/Dockerfile.prod

@ -0,0 +1,16 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY ../nginx/nginx.prod.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

8
frontend/src/components/layout/MainLayout.vue

@ -59,6 +59,11 @@
<span>通知中心</span> <span>通知中心</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/user/flow/list" v-if="can('flow:read')">
<el-icon><Promotion /></el-icon>
<span>流程管理</span>
</el-menu-item>
<el-menu-item index="/user/profile"> <el-menu-item index="/user/profile">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>个人中心</span> <span>个人中心</span>
@ -108,7 +113,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown, Tools, Search } from '@element-plus/icons-vue' import { Fold, User, ArrowDown, Tools, Search, Promotion } from '@element-plus/icons-vue'
import PortalSwitcher from '@/components/common/PortalSwitcher.vue' import PortalSwitcher from '@/components/common/PortalSwitcher.vue'
const route = useRoute() const route = useRoute()
@ -122,6 +127,7 @@ const activeMenu = computed(() => {
if (path.startsWith('/user/task')) return '/user/task/list' if (path.startsWith('/user/task')) return '/user/task/list'
if (path.startsWith('/user/agent')) return '/user/agent/list' if (path.startsWith('/user/agent')) return '/user/agent/list'
if (path.startsWith('/user/document')) return '/user/document/manager' if (path.startsWith('/user/document')) return '/user/document/manager'
if (path.startsWith('/user/flow')) return '/user/flow/list'
if (path.startsWith('/user/wecom')) return '/user/wecom/config' if (path.startsWith('/user/wecom')) return '/user/wecom/config'
if (path.startsWith('/user/notification')) return '/user/notification/center' if (path.startsWith('/user/notification')) return '/user/notification/center'
if (path.startsWith('/user/settings')) return '/user/settings' if (path.startsWith('/user/settings')) return '/user/settings'

56
frontend/src/router/index.ts

@ -94,6 +94,24 @@ const router = createRouter({
component: () => import('@/views/notification/NotificationCenter.vue'), component: () => import('@/views/notification/NotificationCenter.vue'),
meta: { title: '通知中心' }, meta: { title: '通知中心' },
}, },
{
path: 'flow/list',
name: 'UserFlowList',
component: () => import('@/views/flow/FlowList.vue'),
meta: { title: '流程管理', perms: ['flow:read'] },
},
{
path: 'flow/editor',
name: 'UserFlowEditor',
component: () => import('@/views/flow/FlowEditor.vue'),
meta: { title: '流编辑器', perms: ['flow:create'] },
},
{
path: 'flow/editor/:id',
name: 'UserFlowEditorEdit',
component: () => import('@/views/flow/FlowEditor.vue'),
meta: { title: '编辑流', perms: ['flow:update'] },
},
{ {
path: 'profile', path: 'profile',
name: 'Profile', name: 'Profile',
@ -211,29 +229,31 @@ const router = createRouter({
router.beforeEach(async (to, _from) => { router.beforeEach(async (to, _from) => {
const userStore = useUserStore() const userStore = useUserStore()
if (!userStore.token) { if (userStore.token) {
if (to.name === 'Login') { return true } if (to.name === 'Login' || to.path === '/') {
return { name: 'Login', query: { redirect: to.fullPath } } return { name: 'Dashboard' }
}
if (!userStore.user) {
try {
await userStore.fetchUser()
} catch {
return { name: 'Login', query: { redirect: to.fullPath } }
} }
if (!userStore.isLoggedIn) { if (!userStore.user) {
return { name: 'Login', query: { redirect: to.fullPath } } try {
await userStore.fetchUser()
} catch {
userStore.logout()
return { name: 'Login', query: { redirect: to.fullPath } }
}
if (!userStore.isLoggedIn) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
} }
} if (to.meta.perms && Array.isArray(to.meta.perms) && to.meta.perms.length > 0) {
if (!userStore.hasPermission(to.meta.perms[0])) {
if (to.meta.perms && Array.isArray(to.meta.perms) && to.meta.perms.length > 0) { return { name: 'Dashboard' }
if (!userStore.hasPermission(to.meta.perms[0])) { }
return '/user/dashboard'
} }
return true
} }
return true if (to.name === 'Login') { return true }
return { name: 'Login', query: { redirect: to.fullPath } }
}) })
export default router export default router

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

@ -6,9 +6,11 @@
@edges-change="onEdgesChange" @edges-change="onEdgesChange"
@connect="handleConnect" @connect="handleConnect"
@node-click="$emit('node-click', $event)" @node-click="$emit('node-click', $event)"
@pane-click="$emit('pane-click')" @pane-click="onPaneClickLocal"
@drop="$emit('drop', $event)" @drop="$emit('drop', $event)"
@dragover.prevent @dragover.prevent
@edge-click="onEdgeClick"
@contextmenu="onContextMenu"
> >
<Background :gap="20" :size="1" /> <Background :gap="20" :size="1" />
<Controls position="bottom-right" /> <Controls position="bottom-right" />
@ -32,7 +34,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch, nextTick } from 'vue'
import { MarkerType } from '@vue-flow/core' import { MarkerType } from '@vue-flow/core'
import { VueFlow, useVueFlow } from '@vue-flow/core' import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background' import { Background } from '@vue-flow/background'
@ -58,23 +60,137 @@ const emit = defineEmits([
const localNodes = ref<any[]>([]) const localNodes = ref<any[]>([])
const localEdges = ref<any[]>([]) const localEdges = ref<any[]>([])
watch(() => props.nodes, (val) => { localNodes.value = val || [] }, { immediate: true }) let isExternalUpdate = false
watch(() => props.edges, (val) => { localEdges.value = val || [] }, { immediate: true }) let isInitialized = false
let pendingAdditions = new Map<string, any>()
function onNodesChange() { watch(() => props.nodes, async (newNodes) => {
emit('update:nodes', [...localNodes.value]) if (isExternalUpdate) {
isExternalUpdate = false
return
}
if (!newNodes || newNodes.length === 0) return
const existingNodeMap = new Map(localNodes.value.map(n => [n.id, { position: n.position, data: n.data }]))
const mergedNodes = newNodes.map(newNode => {
const pending = pendingAdditions.get(newNode.id)
if (pending) {
pendingAdditions.delete(newNode.id)
return {
...newNode,
position: { ...pending.position },
data: { ...newNode.data },
}
}
const existing = existingNodeMap.get(newNode.id)
if (existing) {
return {
...newNode,
position: { ...existing.position },
data: { ...newNode.data },
}
}
return { ...newNode, data: { ...newNode.data } }
})
localNodes.value = mergedNodes
if (!isInitialized) {
isInitialized = true
}
}, { immediate: true })
watch(() => props.edges, (newEdges) => {
if (isExternalUpdate) {
isExternalUpdate = false
return
}
if (!newEdges || newEdges.length === 0) return
localEdges.value = JSON.parse(JSON.stringify(newEdges))
}, { immediate: true })
function onNodesChange(changes: any[]) {
if (isExternalUpdate) {
isExternalUpdate = false
return
}
const positionChanges = changes.filter((c: any) => c.type === 'position')
if (positionChanges.length > 0 && localNodes.value.length > 0) {
const updatedNodes = localNodes.value.map((node: any) => {
const change = positionChanges.find((c: any) => c.id === node.id)
if (change) {
return {
...node,
position: {
x: change.position?.x ?? node.position?.x,
y: change.position?.y ?? node.position?.y,
},
}
}
return node
})
localNodes.value = updatedNodes
}
} }
function onEdgesChange() { function onEdgesChange(changes: any[]) {
emit('update:edges', [...localEdges.value]) if (isExternalUpdate) {
isExternalUpdate = false
return
}
const removeChanges = changes.filter((c: any) => c.type === 'remove')
if (removeChanges.length > 0) {
const removeIds = new Set(removeChanges.map((c: any) => c.id))
localEdges.value = localEdges.value.filter((e: any) => !removeIds.has(e.id))
}
const selectChanges = changes.filter((c: any) => c.type === 'select')
if (selectChanges.length > 0) {
const selected = selectChanges.find((c: any) => c.selected)
selectedEdgeId.value = selected ? selected.id : null
}
} }
const store = useVueFlow() const store = useVueFlow()
const selectedEdgeId = ref<string | null>(null)
function onEdgeClick({ edge }: any) {
selectedEdgeId.value = edge.id
}
function onPaneClickLocal() {
selectedEdgeId.value = null
emit('pane-click')
}
function onContextMenu(event: MouseEvent) {
const target = event.target as HTMLElement
const edgeEl = target.closest('.vue-flow__edge')
if (edgeEl) {
event.preventDefault()
const edgeId = edgeEl.getAttribute('data-id')
if (edgeId) {
localEdges.value = localEdges.value.filter((e: any) => e.id !== edgeId)
selectedEdgeId.value = null
}
return
}
}
function handleConnect(connection: any) { function handleConnect(connection: any) {
const sourceHandle = connection.sourceHandle || 'source' const sourceHandle = connection.sourceHandle || 'source'
const edge: any = { const edge: any = {
id: `edge_${connection.source}_${connection.target}_${sourceHandle}`, id: `edge_${connection.source}_${connection.target}_${sourceHandle}_${Date.now()}`,
source: connection.source, source: connection.source,
target: connection.target, target: connection.target,
sourceHandle, sourceHandle,
@ -82,25 +198,38 @@ function handleConnect(connection: any) {
type: 'smoothstep', type: 'smoothstep',
animated: true, animated: true,
markerEnd: MarkerType.ArrowClosed, markerEnd: MarkerType.ArrowClosed,
style: sourceHandle === 'false' ? { stroke: '#F56C6C', strokeWidth: 2 } : { stroke: '#409EFF', strokeWidth: 2 }, style: sourceHandle === 'false'
? { stroke: '#E53935', strokeWidth: 3 }
: { stroke: '#1976D2', strokeWidth: 3 },
} }
localEdges.value.push(edge) localEdges.value.push(edge)
emit('update:edges', [...localEdges.value])
emit('connect', connection) emit('connect', connection)
} }
function getSnapshot() {
return { nodes: JSON.parse(JSON.stringify(localNodes.value)), edges: JSON.parse(JSON.stringify(localEdges.value)) }
}
defineExpose({ defineExpose({
undo: () => (store as any).undo?.(), undo: () => (store as any).undo?.(),
redo: () => (store as any).redo?.(), redo: () => (store as any).redo?.(),
get canUndo() { return !!(store as any).canUndo }, get canUndo() { return !!(store as any).canUndo },
get canRedo() { return !!(store as any).canRedo }, get canRedo() { return !!(store as any).canRedo },
addNodes: (n: any[]) => { localNodes.value = [...localNodes.value, ...n]; emit('update:nodes', [...localNodes.value]) }, getSnapshot,
addEdges: (e: any[]) => { localEdges.value = [...localEdges.value, ...e]; emit('update:edges', [...localEdges.value]) }, addNodes: (newNodes: any[]) => {
newNodes.forEach(n => {
pendingAdditions.set(n.id, { position: { ...n.position } })
})
localNodes.value = [...localNodes.value, ...newNodes]
nextTick(() => {
pendingAdditions.clear()
})
},
removeNodes: (ids: string[]) => { removeNodes: (ids: string[]) => {
localNodes.value = localNodes.value.filter((n: any) => !ids.includes(n.id)) localNodes.value = localNodes.value.filter((n: any) => !ids.includes(n.id))
localEdges.value = localEdges.value.filter((e: any) => !ids.includes(e.source) && !ids.includes(e.target)) localEdges.value = localEdges.value.filter((e: any) => !ids.includes(e.source) && !ids.includes(e.target))
emit('update:nodes', [...localNodes.value])
emit('update:edges', [...localEdges.value])
}, },
}) })
</script> </script>

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

@ -35,7 +35,9 @@
<p>拖拽节点到画布</p> <p>拖拽节点到画布</p>
<p>从绿色圆点拖线(true)</p> <p>从绿色圆点拖线(true)</p>
<p>从红色圆点拖线(false)</p> <p>从红色圆点拖线(false)</p>
<p>右键点击边可删除</p> <p>选中连线后按 Delete 删除</p>
<p>右键点击连线可删除</p>
<p>点击空白处取消选中</p>
<p>滚轮缩放画布</p> <p>滚轮缩放画布</p>
</div> </div>
</div> </div>
@ -47,8 +49,8 @@
</div> </div>
<FlowCanvas <FlowCanvas
ref="canvasRef" ref="canvasRef"
v-model:nodes="nodes" :nodes="nodes"
v-model:edges="edges" :edges="edges"
:node-color="getMiniMapColor" :node-color="getMiniMapColor"
@node-click="onNodeClick" @node-click="onNodeClick"
@pane-click="onPaneClick" @pane-click="onPaneClick"
@ -70,102 +72,14 @@
<el-input v-model="selectedNodeData.label" @change="onConfigLabelChange" /> <el-input v-model="selectedNodeData.label" @change="onConfigLabelChange" />
</el-form-item> </el-form-item>
<template v-if="selectedNode?.data?.type === 'trigger'"> <component
<el-form-item label="事件类型"> v-if="selectedNode?.data?.type"
<el-select v-model="selectedNodeData.event_type" @change="onConfigChange" placeholder="企微触发事件"> :is="getConfigComponent(selectedNode.data.type)"
<el-option label="文本消息" value="text_message" /> v-model="selectedNodeData.config"
<el-option label="按钮点击" value="button_click" /> :agent-list="agentList"
<el-option label="进入聊天" value="enter_chat" /> :mcp-servers="mcpServers"
</el-select> @change="onConfigChange"
</el-form-item> />
</template>
<template v-if="selectedNode?.data?.type === 'llm'">
<el-form-item label="选择Agent">
<el-select v-model="selectedNodeData.agent_id" @change="onAgentSelect" placeholder="手动配置或选择已有Agent" clearable>
<el-option v-for="a in agentList" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</el-form-item>
<el-form-item label="系统提示词">
<el-input v-model="selectedNodeData.system_prompt" type="textarea" :rows="4" @change="onConfigChange" />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="selectedNodeData.model" @change="onConfigChange">
<el-option label="GPT-4o-mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" />
<el-option label="GPT-3.5-turbo" value="gpt-3.5-turbo" />
<el-option label="DeepSeek-V3" value="deepseek-chat" />
</el-select>
</el-form-item>
<el-form-item label="温度">
<el-slider v-model="selectedNodeData.temperature" :min="0" :max="2" :step="0.1" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.data?.type === 'tool'">
<el-form-item label="工具名称">
<el-select v-model="selectedNodeData.tool_name" @change="onConfigChange">
<el-option label="解析文档" value="parse_document" />
<el-option label="修正格式" value="format_correction" />
<el-option label="发送企微通知" value="send_notification" />
<el-option label="查询企微用户" value="query_wecom_user" />
<el-option label="查询任务列表" value="list_tasks" />
<el-option label="创建任务" value="create_task" />
<el-option label="查询任务详情" value="get_task" />
<el-option label="更新任务" value="update_task" />
<el-option label="推送任务至企微" value="push_task_to_wecom" />
<el-option label="查询下属" value="list_subordinates" />
<el-option label="生成效率报告" value="generate_efficiency_report" />
<el-option label="任务统计" value="get_task_statistics" />
<el-option label="员工看板" value="get_employee_dashboard" />
</el-select>
</el-form-item>
</template>
<template v-if="selectedNode?.data?.type === 'mcp'">
<el-form-item label="MCP服务">
<el-select v-model="selectedNodeData.mcp_server" @change="onConfigChange">
<el-option label="未选择" value="" />
<el-option v-for="s in mcpServers" :key="s.id" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="工具名">
<el-input v-model="selectedNodeData.tool_name" placeholder="如: search" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.data?.type === 'wecom_notify'">
<el-form-item label="消息模板">
<el-input v-model="selectedNodeData.message_template" type="textarea" :rows="3" @change="onConfigChange" />
</el-form-item>
<el-form-item label="目标用户">
<el-input v-model="selectedNodeData.target" placeholder="@all 或用户ID" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.data?.type === 'condition'">
<el-form-item label="条件表达式">
<el-input v-model="selectedNodeData.condition" placeholder="如: status == 'done'" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.data?.type === 'rag'">
<el-form-item label="知识库">
<el-input v-model="selectedNodeData.knowledge_base" placeholder="知识库ID" @change="onConfigChange" />
</el-form-item>
<el-form-item label="TopK">
<el-input-number v-model="selectedNodeData.top_k" :min="1" :max="20" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.data?.type === 'output'">
<el-form-item label="输出格式">
<el-select v-model="selectedNodeData.format" @change="onConfigChange">
<el-option label="纯文本" value="text" />
<el-option label="JSON" value="json" />
</el-select>
</el-form-item>
</template>
</el-form> </el-form>
</div> </div>
</div> </div>
@ -178,6 +92,14 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Promotion, ChatDotRound, Tools, Connection, Bell, DataAnalysis, Search } from '@element-plus/icons-vue' import { Promotion, ChatDotRound, Tools, Connection, Bell, DataAnalysis, Search } from '@element-plus/icons-vue'
import FlowCanvas from './FlowCanvas.vue' import FlowCanvas from './FlowCanvas.vue'
import TriggerConfig from './node-configs/TriggerConfig.vue'
import LlmConfig from './node-configs/LlmConfig.vue'
import ToolConfig from './node-configs/ToolConfig.vue'
import McpConfig from './node-configs/McpConfig.vue'
import WecomNotifyConfig from './node-configs/WecomNotifyConfig.vue'
import ConditionConfig from './node-configs/ConditionConfig.vue'
import RagConfig from './node-configs/RagConfig.vue'
import OutputConfig from './node-configs/OutputConfig.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -212,6 +134,21 @@ const nodeTypes = [
{ type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出' }, { type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出' },
] ]
const configComponentMap: Record<string, any> = {
trigger: TriggerConfig,
llm: LlmConfig,
tool: ToolConfig,
mcp: McpConfig,
wecom_notify: WecomNotifyConfig,
condition: ConditionConfig,
rag: RagConfig,
output: OutputConfig,
}
function getConfigComponent(type: string) {
return configComponentMap[type] || null
}
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
trigger: '#722ed1', trigger: '#722ed1',
llm: '#409EFF', llm: '#409EFF',
@ -255,17 +192,31 @@ function onDrop(event: DragEvent) {
type: nodeData.type, type: nodeData.type,
typeDesc: nodeData.typeDesc, typeDesc: nodeData.typeDesc,
color: colorMap[nodeData.type] || '#409EFF', color: colorMap[nodeData.type] || '#409EFF',
config: nodeData.type === 'llm' ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '' } : {}, config: getDefaultConfig(nodeData.type),
}, },
draggable: true, draggable: true,
connectable: true, connectable: true,
}]) }])
} }
function getDefaultConfig(type: string) {
const defaults: Record<string, any> = {
trigger: { event_type: 'text_message', callback_url: '' },
llm: { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '', max_tokens: 2000, context_length: 5, memory_mode: 'short_term', stream: true, tool_call: false },
tool: { tool_type: '', tool_name: '', param_mapping: '{}', timeout: 30, retry_count: 0, error_handling: 'throw' },
mcp: { mcp_server: '', tool_name: '', input_params: '{}', timeout: 30, response_parser: 'json', error_handling: 'throw' },
wecom_notify: { message_template: '', target: '', message_type: 'text', async_send: false, error_handling: 'throw' },
condition: { condition_type: 'expression', condition: '', true_label: '是', false_label: '否', short_circuit: true, default_branch: 'false' },
rag: { knowledge_base: '', top_k: 5, search_mode: 'hybrid', similarity_threshold: 0.7, result_sort: 'similarity', include_metadata: true },
output: { format: 'text', output_template: '', indent: 2, encoding: 'utf-8', truncate: false, max_length: 2000 },
}
return defaults[type] || {}
}
function onNodeClick({ node }: any) { function onNodeClick({ node }: any) {
selectedNodeId.value = node.id selectedNodeId.value = node.id
const d = node.data || {} const d = node.data || {}
selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', ...(d.config || {}) } selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', config: { ...(d.config || {}) } }
} }
function onPaneClick() { selectedNodeId.value = '' } function onPaneClick() { selectedNodeId.value = '' }
@ -279,38 +230,18 @@ function onConfigLabelChange() {
} }
} }
function onAgentSelect(val: string) {
if (!val) return
const agent = agentList.value.find(a => a.id === val)
if (agent) {
selectedNodeData.value.system_prompt = agent.system_prompt || ''
selectedNodeData.value.model = agent.model || 'gpt-4o-mini'
selectedNodeData.value.temperature = agent.temperature ?? 0.7
}
onConfigChange()
}
function onConfigChange() { function onConfigChange() {
const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value) const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
if (idx === -1) return if (idx === -1) return
const found = nodes.value[idx] const found = nodes.value[idx]
const nodeType = found.data.type
const cfg: any = {}
if (nodeType === 'trigger') cfg.event_type = selectedNodeData.value.event_type
else if (nodeType === 'llm') { cfg.system_prompt = selectedNodeData.value.system_prompt; cfg.model = selectedNodeData.value.model; cfg.temperature = selectedNodeData.value.temperature; cfg.agent_id = selectedNodeData.value.agent_id }
else if (nodeType === 'tool') cfg.tool_name = selectedNodeData.value.tool_name
else if (nodeType === 'mcp') { cfg.mcp_server = selectedNodeData.value.mcp_server; cfg.tool_name = selectedNodeData.value.tool_name }
else if (nodeType === 'wecom_notify') { cfg.message_template = selectedNodeData.value.message_template; cfg.target = selectedNodeData.value.target }
else if (nodeType === 'condition') cfg.condition = selectedNodeData.value.condition
else if (nodeType === 'rag') { cfg.knowledge_base = selectedNodeData.value.knowledge_base; cfg.top_k = selectedNodeData.value.top_k }
else if (nodeType === 'output') cfg.format = selectedNodeData.value.format
const updated = { ...found } const updated = { ...found }
updated.data = { ...found.data, config: cfg } updated.data = { ...found.data, config: { ...selectedNodeData.value.config } }
nodes.value[idx] = updated nodes.value[idx] = updated
} }
function removeNode(id: string) { function removeNode(id: string) {
canvasRef.value?.removeNodes?.([id]) nodes.value = nodes.value.filter((n: any) => n.id !== id)
edges.value = edges.value.filter((e: any) => e.source !== id && e.target !== id)
if (selectedNodeId.value === id) selectedNodeId.value = '' if (selectedNodeId.value === id) selectedNodeId.value = ''
} }
@ -344,7 +275,7 @@ async function loadFlow() {
const source = e.source || e.from const source = e.source || e.from
const target = e.target || e.to const target = e.target || e.to
const cond = e.condition || e.sourceHandle const cond = e.condition || e.sourceHandle
loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: true, style: cond === 'false' ? { stroke: '#F56C6C', strokeWidth: 2 } : { stroke: '#409EFF', strokeWidth: 2 } }) loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: true, markerEnd: true, style: cond === 'false' ? { stroke: '#E53935', strokeWidth: 3 } : { stroke: '#1976D2', strokeWidth: 3 } })
} }
nodes.value = loadedNodes nodes.value = loadedNodes
edges.value = loadedEdges edges.value = loadedEdges
@ -367,8 +298,9 @@ async function saveFlow() {
saving.value = true saving.value = true
try { try {
const { flowApi } = await import('@/api') const { flowApi } = await import('@/api')
const serializedNodes = nodes.value.map((n: any) => ({ id: n.id, type: n.data?.type || n.type, label: n.data?.label || n.id, config: n.data?.config || {}, position: n.position })) const snapshot = canvasRef.value?.getSnapshot() || { nodes: [], edges: [] }
const serializedEdges = edges.value.map((e: any) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source', condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined) })) const serializedNodes = snapshot.nodes.map((n: any) => ({ id: n.id, type: n.data?.type || n.type, label: n.data?.label || n.id, config: n.data?.config || {}, position: n.position }))
const serializedEdges = snapshot.edges.map((e: any) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source', condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined) }))
const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} } const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} }
if (isEdit.value) { await flowApi.updateFlow(flowId.value, payload); ElMessage.success('保存成功') } if (isEdit.value) { await flowApi.updateFlow(flowId.value, payload); ElMessage.success('保存成功') }
else { const res: any = await flowApi.createFlow(payload); const data = res?.data || res || {}; if (data.id) { router.replace(`/admin/flow/editor/${data.id}`); ElMessage.success('创建成功') } } else { const res: any = await flowApi.createFlow(payload); const data = res?.data || res || {}; if (data.id) { router.replace(`/admin/flow/editor/${data.id}`); ElMessage.success('创建成功') } }

64
frontend/src/views/flow/node-configs/ConditionConfig.vue

@ -0,0 +1,64 @@
<template>
<div class="node-config">
<el-divider content-position="left">条件配置</el-divider>
<el-form-item label="条件类型">
<el-select v-model="config.condition_type" @change="$emit('change')">
<el-option label="表达式" value="expression" />
<el-option label="JSON路径" value="json_path" />
<el-option label="正则匹配" value="regex" />
</el-select>
</el-form-item>
<el-form-item label="条件表达式">
<el-input v-model="config.condition" placeholder="如: status == 'done'" @change="$emit('change')" />
<div class="condition-hint">支持变量: {{input_data}}, {{status}}, {{result}}</div>
</el-form-item>
<el-divider content-position="left">分支配置</el-divider>
<el-form-item label="True分支">
<el-input v-model="config.true_label" placeholder="条件满足时的输出标签" @change="$emit('change')" />
</el-form-item>
<el-form-item label="False分支">
<el-input v-model="config.false_label" placeholder="条件不满足时的输出标签" @change="$emit('change')" />
</el-form-item>
<el-divider content-position="left">高级选项</el-divider>
<el-form-item label="短路评估">
<el-switch v-model="config.short_circuit" @change="$emit('change')" />
</el-form-item>
<el-form-item label="默认分支">
<el-select v-model="config.default_branch" @change="$emit('change')">
<el-option label="True" value="true" />
<el-option label="False" value="false" />
</el-select>
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({
default: () => ({
condition_type: 'expression',
condition: '',
true_label: '是',
false_label: '否',
short_circuit: true,
default_branch: 'false'
})
})
defineEmits(['change'])
</script>
<style scoped>
.condition-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

92
frontend/src/views/flow/node-configs/LlmConfig.vue

@ -0,0 +1,92 @@
<template>
<div class="node-config">
<el-divider content-position="left">Agent配置</el-divider>
<el-form-item label="选择Agent">
<el-select v-model="config.agent_id" @change="onAgentSelect" placeholder="手动配置或选择已有Agent" clearable>
<el-option v-for="a in agentList" :key="a.id" :label="a.name" :value="a.id" />
</el-select>
</el-form-item>
<el-divider content-position="left">模型配置</el-divider>
<el-form-item label="系统提示词">
<el-input v-model="config.system_prompt" type="textarea" :rows="4" @change="$emit('change')" placeholder="输入系统提示词,定义AI角色和行为" />
</el-form-item>
<el-form-item label="模型">
<el-select v-model="config.model" @change="$emit('change')">
<el-option label="GPT-4o-mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" />
<el-option label="GPT-3.5-turbo" value="gpt-3.5-turbo" />
<el-option label="DeepSeek-V3" value="deepseek-chat" />
<el-option label="DeepSeek-R1" value="deepseek-reasoner" />
</el-select>
</el-form-item>
<el-form-item label="温度">
<el-slider v-model="config.temperature" :min="0" :max="2" :step="0.1" @change="$emit('change')" />
</el-form-item>
<el-form-item label="最大Token数">
<el-input-number v-model="config.max_tokens" :min="1" :max="4000" :step="100" @change="$emit('change')" />
</el-form-item>
<el-divider content-position="left">记忆配置</el-divider>
<el-form-item label="上下文长度">
<el-input-number v-model="config.context_length" :min="1" :max="20" :step="1" @change="$emit('change')" />
</el-form-item>
<el-form-item label="记忆模式">
<el-select v-model="config.memory_mode" @change="$emit('change')">
<el-option label="无记忆" value="none" />
<el-option label="短期记忆" value="short_term" />
<el-option label="长期记忆" value="long_term" />
</el-select>
</el-form-item>
<el-divider content-position="left">高级选项</el-divider>
<el-form-item label="流式输出">
<el-switch v-model="config.stream" @change="$emit('change')" />
</el-form-item>
<el-form-item label="函数调用">
<el-switch v-model="config.tool_call" @change="$emit('change')" />
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({
default: () => ({
system_prompt: '',
model: 'gpt-4o-mini',
temperature: 0.7,
agent_id: '',
max_tokens: 2000,
context_length: 5,
memory_mode: 'short_term',
stream: true,
tool_call: false
})
})
const props = defineProps<{
agentList: any[]
}>()
defineEmits(['change'])
function onAgentSelect(val: string) {
if (!val) return
const agent = props.agentList.find(a => a.id === val)
if (agent) {
config.value.system_prompt = agent.system_prompt || config.value.system_prompt
config.value.model = agent.model || config.value.model
config.value.temperature = agent.temperature ?? config.value.temperature
config.value.max_tokens = agent.max_tokens || config.value.max_tokens
}
}
</script>

63
frontend/src/views/flow/node-configs/McpConfig.vue

@ -0,0 +1,63 @@
<template>
<div class="node-config">
<el-divider content-position="left">MCP服务配置</el-divider>
<el-form-item label="MCP服务器">
<el-select v-model="config.mcp_server" @change="$emit('change')" placeholder="选择MCP服务">
<el-option label="未选择" value="" />
<el-option v-for="s in mcpServers" :key="s.id" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="工具名">
<el-input v-model="config.tool_name" placeholder="如: search" @change="$emit('change')" />
</el-form-item>
<el-divider content-position="left">请求参数</el-divider>
<el-form-item label="输入参数">
<el-input v-model="config.input_params" type="textarea" :rows="3" @change="$emit('change')" placeholder='{"param1": "value1"}' />
</el-form-item>
<el-form-item label="超时时间(秒)">
<el-input-number v-model="config.timeout" :min="1" :max="300" :step="10" @change="$emit('change')" />
</el-form-item>
<el-divider content-position="left">响应处理</el-divider>
<el-form-item label="响应解析">
<el-select v-model="config.response_parser" @change="$emit('change')">
<el-option label="JSON解析" value="json" />
<el-option label="文本提取" value="text" />
<el-option label="自定义解析" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="错误处理">
<el-select v-model="config.error_handling" @change="$emit('change')">
<el-option label="抛出错误" value="throw" />
<el-option label="返回默认值" value="default" />
<el-option label="跳过继续" value="skip" />
</el-select>
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({
default: () => ({
mcp_server: '',
tool_name: '',
input_params: '{}',
timeout: 30,
response_parser: 'json',
error_handling: 'throw'
})
})
defineProps<{
mcpServers: any[]
}>()
defineEmits(['change'])
</script>

56
frontend/src/views/flow/node-configs/OutputConfig.vue

@ -0,0 +1,56 @@
<template>
<div class="node-config">
<el-divider content-position="left">输出配置</el-divider>
<el-form-item label="输出格式">
<el-select v-model="config.format" @change="$emit('change')">
<el-option label="纯文本" value="text" />
<el-option label="JSON" value="json" />
<el-option label="XML" value="xml" />
<el-option label="HTML" value="html" />
</el-select>
</el-form-item>
<el-form-item label="输出模板">
<el-input v-model="config.output_template" type="textarea" :rows="3" @change="$emit('change')" placeholder="可选,自定义输出格式" />
</el-form-item>
<el-divider content-position="left">格式化选项</el-divider>
<el-form-item label="JSON缩进">
<el-input-number v-model="config.indent" :min="0" :max="4" :step="1" @change="$emit('change')" />
</el-form-item>
<el-form-item label="字符编码">
<el-select v-model="config.encoding" @change="$emit('change')">
<el-option label="UTF-8" value="utf-8" />
<el-option label="GBK" value="gbk" />
</el-select>
</el-form-item>
<el-divider content-position="left">输出行为</el-divider>
<el-form-item label="截断长输出">
<el-switch v-model="config.truncate" @change="$emit('change')" />
</el-form-item>
<el-form-item label="最大输出长度">
<el-input-number v-model="config.max_length" :min="100" :max="10000" :step="100" @change="$emit('change')" />
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({
default: () => ({
format: 'text',
output_template: '',
indent: 2,
encoding: 'utf-8',
truncate: false,
max_length: 2000
})
})
defineEmits(['change'])
</script>

56
frontend/src/views/flow/node-configs/RagConfig.vue

@ -0,0 +1,56 @@
<template>
<div class="node-config">
<el-divider content-position="left">知识库配置</el-divider>
<el-form-item label="知识库">
<el-input v-model="config.knowledge_base" placeholder="知识库ID" @change="$emit('change')" />
</el-form-item>
<el-form-item label="TopK">
<el-input-number v-model="config.top_k" :min="1" :max="20" :step="1" @change="$emit('change')" />
</el-form-item>
<el-divider content-position="left">检索配置</el-divider>
<el-form-item label="检索模式">
<el-select v-model="config.search_mode" @change="$emit('change')">
<el-option label="向量检索" value="vector" />
<el-option label="关键词检索" value="keyword" />
<el-option label="混合检索" value="hybrid" />
</el-select>
</el-form-item>
<el-form-item label="相似度阈值">
<el-slider v-model="config.similarity_threshold" :min="0" :max="1" :step="0.05" @change="$emit('change')" />
</el-form-item>
<el-divider content-position="left">结果处理</el-divider>
<el-form-item label="结果排序">
<el-select v-model="config.result_sort" @change="$emit('change')">
<el-option label="按相似度" value="similarity" />
<el-option label="按相关性" value="relevance" />
<el-option label="按时间" value="time" />
</el-select>
</el-form-item>
<el-form-item label="包含元数据">
<el-switch v-model="config.include_metadata" @change="$emit('change')" />
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({
default: () => ({
knowledge_base: '',
top_k: 5,
search_mode: 'hybrid',
similarity_threshold: 0.7,
result_sort: 'similarity',
include_metadata: true
})
})
defineEmits(['change'])
</script>

89
frontend/src/views/flow/node-configs/ToolConfig.vue

@ -0,0 +1,89 @@
<template>
<div class="node-config">
<el-divider content-position="left">工具选择</el-divider>
<el-form-item label="工具类型">
<el-select v-model="config.tool_type" @change="$emit('change')" placeholder="选择工具类型">
<el-option label="企微消息" value="wecom_message" />
<el-option label="任务管理" value="task_management" />
<el-option label="文档处理" value="document_processing" />
<el-option label="格式处理" value="format_processing" />
<el-option label="数据查询" value="data_query" />
<el-option label="报表生成" value="report_generation" />
<el-option label="HTTP请求" value="http_request" />
</el-select>
</el-form-item>
<el-form-item label="工具名称">
<el-select v-model="config.tool_name" @change="$emit('change')" placeholder="选择具体工具">
<template v-if="config.tool_type === 'wecom_message'">
<el-option label="发送企微通知" value="send_notification" />
<el-option label="查询企微用户" value="query_wecom_user" />
<el-option label="推送任务至企微" value="push_task_to_wecom" />
<el-option label="查询下属" value="list_subordinates" />
</template>
<template v-else-if="config.tool_type === 'task_management'">
<el-option label="查询任务列表" value="list_tasks" />
<el-option label="创建任务" value="create_task" />
<el-option label="查询任务详情" value="get_task" />
<el-option label="更新任务" value="update_task" />
</template>
<template v-else-if="config.tool_type === 'document_processing'">
<el-option label="解析文档" value="parse_document" />
</template>
<template v-else-if="config.tool_type === 'format_processing'">
<el-option label="修正格式" value="format_correction" />
</template>
<template v-else-if="config.tool_type === 'data_query'">
<el-option label="查询用户数据" value="query_user_data" />
<el-option label="查询部门信息" value="query_department_info" />
</template>
<template v-else-if="config.tool_type === 'report_generation'">
<el-option label="生成效率报告" value="generate_efficiency_report" />
<el-option label="任务统计" value="get_task_statistics" />
<el-option label="员工看板" value="get_employee_dashboard" />
</template>
<template v-else-if="config.tool_type === 'http_request'">
<el-option label="自定义HTTP请求" value="custom_http_request" />
</template>
</el-select>
</el-form-item>
<el-divider content-position="left">参数配置</el-divider>
<el-form-item label="参数映射">
<el-input v-model="config.param_mapping" type="textarea" :rows="3" @change="$emit('change')" placeholder='{"input_key": "output_key"}' />
</el-form-item>
<el-form-item label="超时时间(秒)">
<el-input-number v-model="config.timeout" :min="1" :max="300" :step="10" @change="$emit('change')" />
</el-form-item>
<el-form-item label="重试次数">
<el-input-number v-model="config.retry_count" :min="0" :max="5" :step="1" @change="$emit('change')" />
</el-form-item>
<el-form-item label="错误处理">
<el-select v-model="config.error_handling" @change="$emit('change')">
<el-option label="抛出错误" value="throw" />
<el-option label="返回默认值" value="default" />
<el-option label="跳过继续" value="skip" />
</el-select>
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({
default: () => ({
tool_type: '',
tool_name: '',
param_mapping: '{}',
timeout: 30,
retry_count: 0,
error_handling: 'throw'
})
})
defineEmits(['change'])
</script>

22
frontend/src/views/flow/node-configs/TriggerConfig.vue

@ -0,0 +1,22 @@
<template>
<div class="node-config">
<el-form-item label="事件类型">
<el-select v-model="config.event_type" @change="$emit('change')" placeholder="企微触发事件">
<el-option label="文本消息" value="text_message" />
<el-option label="按钮点击" value="button_click" />
<el-option label="进入聊天" value="enter_chat" />
<el-option label="图片消息" value="image_message" />
<el-option label="语音消息" value="voice_message" />
<el-option label="文件消息" value="file_message" />
</el-select>
</el-form-item>
<el-form-item label="回调地址">
<el-input v-model="config.callback_url" @change="$emit('change')" placeholder="可选,默认使用系统回调" />
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({ default: () => ({}) })
defineEmits(['change'])
</script>

49
frontend/src/views/flow/node-configs/WecomNotifyConfig.vue

@ -0,0 +1,49 @@
<template>
<div class="node-config">
<el-divider content-position="left">消息配置</el-divider>
<el-form-item label="消息模板">
<el-input v-model="config.message_template" type="textarea" :rows="3" @change="$emit('change')" placeholder="支持变量: {user_name}, {task_name}, {status}" />
</el-form-item>
<el-form-item label="目标用户">
<el-input v-model="config.target" placeholder="@all 或用户ID" @change="$emit('change')" />
</el-form-item>
<el-form-item label="消息类型">
<el-select v-model="config.message_type" @change="$emit('change')">
<el-option label="文本消息" value="text" />
<el-option label="Markdown消息" value="markdown" />
<el-option label="卡片消息" value="card" />
</el-select>
</el-form-item>
<el-divider content-position="left">发送选项</el-divider>
<el-form-item label="异步发送">
<el-switch v-model="config.async_send" @change="$emit('change')" />
</el-form-item>
<el-form-item label="发送失败处理">
<el-select v-model="config.error_handling" @change="$emit('change')">
<el-option label="抛出错误" value="throw" />
<el-option label="记录日志" value="log" />
<el-option label="重试" value="retry" />
</el-select>
</el-form-item>
</div>
</template>
<script setup lang="ts">
const config = defineModel<any>({
default: () => ({
message_template: '',
target: '',
message_type: 'text',
async_send: false,
error_handling: 'throw'
})
})
defineEmits(['change'])
</script>

5
frontend/vite.config.ts

@ -10,10 +10,11 @@ export default defineConfig({
}, },
}, },
server: { server: {
port: 3000, host: '0.0.0.0',
port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://backend:8000',
changeOrigin: true, changeOrigin: true,
}, },
}, },

BIN
hg-agents.zip

Binary file not shown.

52
nginx/nginx.dev.conf

@ -0,0 +1,52 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream backend_api {
server backend:8000;
}
upstream frontend_app {
server frontend:80;
}
server {
listen 80;
location / {
proxy_pass http://frontend_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support for Vite HMR
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /api/ {
proxy_pass http://backend_api/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /wecom/ {
proxy_pass http://backend_api/wecom/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}

48
nginx/nginx.prod.conf

@ -0,0 +1,48 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream backend_api {
server backend:8000;
}
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback - try file first, then fallback to index.html
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend_api/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /wecom/ {
proxy_pass http://backend_api/wecom/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
Loading…
Cancel
Save