|
@@ -0,0 +1,572 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="canvas-page">
|
|
|
|
|
+ <!-- 顶部工具栏 -->
|
|
|
|
|
+ <div class="canvas-topbar">
|
|
|
|
|
+ <el-button icon="el-icon-arrow-left" size="small" @click="goBack">返回模板列表</el-button>
|
|
|
|
|
+ <span class="topbar-title">画布节点设计 - {{ templateInfo.templateName }}</span>
|
|
|
|
|
+ <div class="topbar-actions">
|
|
|
|
|
+ <el-button v-if="canvasNodes.length === 0" type="primary" icon="el-icon-plus" @click="addStartNode">新增开始节点</el-button>
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <el-button type="success" size="small" icon="el-icon-check" @click="saveCanvasNodes">保存画布</el-button>
|
|
|
|
|
+ <el-button size="small" icon="el-icon-refresh" @click="resetCanvas">重置</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <el-button size="small" icon="el-icon-document" @click="showWorkflowTemplate">工作流模板</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span v-if="canvasNodes.length > 0" style="margin-left: 10px; color: #909399; font-size: 12px;">
|
|
|
|
|
+ 节点数: {{ canvasNodes.length }} | 点击节点"+"添加子节点 | 拖拽节点调整位置 | 点击节点编辑配置
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 主体:画布 + 右侧面板 -->
|
|
|
|
|
+ <div class="canvas-layout">
|
|
|
|
|
+ <!-- 左侧:画布 -->
|
|
|
|
|
+ <div class="canvas-main">
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="canvas-container"
|
|
|
|
|
+ ref="canvasContainer"
|
|
|
|
|
+ @mousedown="onCanvasMouseDown"
|
|
|
|
|
+ @mousemove="onCanvasMouseMove"
|
|
|
|
|
+ @mouseup="onCanvasMouseUp"
|
|
|
|
|
+ @mouseleave="onCanvasMouseUp"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg :width="canvasWidth" :height="canvasHeight" class="canvas-svg">
|
|
|
|
|
+ <defs>
|
|
|
|
|
+ <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
|
|
|
|
+ <polygon points="0 0, 10 3.5, 0 7" fill="#999" />
|
|
|
|
|
+ </marker>
|
|
|
|
|
+ </defs>
|
|
|
|
|
+ <line
|
|
|
|
|
+ v-for="(line, idx) in canvasLines"
|
|
|
|
|
+ :key="'line-' + idx"
|
|
|
|
|
+ :x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
|
|
|
|
|
+ stroke="#999" stroke-width="2" marker-end="url(#arrowhead)"
|
|
|
|
|
+ />
|
|
|
|
|
+ <g
|
|
|
|
|
+ v-for="node in canvasNodes"
|
|
|
|
|
+ :key="node._cid"
|
|
|
|
|
+ @mousedown.stop="onNodeMouseDown($event, node)"
|
|
|
|
|
+ @click.stop="onNodeClick(node)"
|
|
|
|
|
+ :style="{ cursor: draggingNode && draggingNode._cid === node._cid ? 'grabbing' : 'pointer' }"
|
|
|
|
|
+ >
|
|
|
|
|
+ <rect
|
|
|
|
|
+ :x="node._x" :y="node._y" :width="nodeWidth" :height="nodeHeight"
|
|
|
|
|
+ :rx="8" :ry="8"
|
|
|
|
|
+ :fill="getNodeColor(node.nodeType)"
|
|
|
|
|
+ :stroke="selectedNode && selectedNode._cid === node._cid ? '#303133' : 'transparent'"
|
|
|
|
|
+ stroke-width="2" opacity="0.9"
|
|
|
|
|
+ />
|
|
|
|
|
+ <text :x="node._x + nodeWidth / 2" :y="node._y + nodeHeight / 2 - 6" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">
|
|
|
|
|
+ {{ node.nodeName || '未命名' }}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ <text :x="node._x + nodeWidth / 2" :y="node._y + nodeHeight / 2 + 14" text-anchor="middle" fill="rgba(255,255,255,0.85)" font-size="11">
|
|
|
|
|
+ {{ getNodeTypeName(node.nodeType) }}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ <g @click.stop="addChildNode(node)" class="node-add-btn">
|
|
|
|
|
+ <circle :cx="node._x + nodeWidth / 2" :cy="node._y + nodeHeight + 14" r="10" fill="#fff" stroke="#409EFF" stroke-width="2" />
|
|
|
|
|
+ <line :x1="node._x + nodeWidth / 2 - 6" :y1="node._y + nodeHeight + 14" :x2="node._x + nodeWidth / 2 + 6" :y2="node._y + nodeHeight + 14" stroke="#409EFF" stroke-width="2" />
|
|
|
|
|
+ <line :x1="node._x + nodeWidth / 2" :y1="node._y + nodeHeight + 8" :x2="node._x + nodeWidth / 2" :y2="node._y + nodeHeight + 20" stroke="#409EFF" stroke-width="2" />
|
|
|
|
|
+ </g>
|
|
|
|
|
+ <g v-if="canvasNodes.length > 1" @click.stop="removeCanvasNode(node)" class="node-del-btn">
|
|
|
|
|
+ <circle :cx="node._x + nodeWidth" :cy="node._y" r="9" fill="#F56C6C" opacity="0.85" />
|
|
|
|
|
+ <line :x1="node._x + nodeWidth - 4" :y1="node._y - 4" :x2="node._x + nodeWidth + 4" :y2="node._y + 4" stroke="#fff" stroke-width="2" />
|
|
|
|
|
+ <line :x1="node._x + nodeWidth + 4" :y1="node._y - 4" :x2="node._x + nodeWidth - 4" :y2="node._y + 4" stroke="#fff" stroke-width="2" />
|
|
|
|
|
+ </g>
|
|
|
|
|
+ </g>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 右侧:节点配置面板 -->
|
|
|
|
|
+ <div class="canvas-sidebar" >
|
|
|
|
|
+ <div class="sidebar-header">
|
|
|
|
|
+ <span class="sidebar-title">{{ selectedNode ? '节点配置' : '操作面板' }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="sidebar-body">
|
|
|
|
|
+ <!-- 未选中节点时 -->
|
|
|
|
|
+ <div v-if="!selectedNode" class="sidebar-empty">
|
|
|
|
|
+ <el-alert title="操作提示" type="info" :closable="false" show-icon>
|
|
|
|
|
+ <div>点击 <strong>新增开始节点</strong> 创建起始节点</div>
|
|
|
|
|
+ <div>点击节点上的 <strong>+</strong> 按钮添加子节点</div>
|
|
|
|
|
+ <div>拖拽节点调整画布位置</div>
|
|
|
|
|
+ <div>点击节点进行配置</div>
|
|
|
|
|
+ </el-alert>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 选中节点时显示编辑表单 -->
|
|
|
|
|
+ <div v-else class="sidebar-form">
|
|
|
|
|
+ <el-form ref="nodeForm" :model="nodeForm" label-width="100px" size="small">
|
|
|
|
|
+ <el-form-item label="节点名称">
|
|
|
|
|
+ <el-input v-model="nodeForm.nodeName" placeholder="请输入节点名称" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="节点类型">
|
|
|
|
|
+ <el-select v-model="nodeForm.nodeType" placeholder="请选择节点类型" style="width:100%">
|
|
|
|
|
+ <el-option v-for="dict in nodeTypeOptions" :key="dict.dictValue" :label="dict.dictLabel" :value="dict.dictValue" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="排序">
|
|
|
|
|
+ <el-input-number v-model="nodeForm.sortOrder" :min="0" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="上一节点">
|
|
|
|
|
+ <el-select v-model="nodeForm.prevNodeId" placeholder="请选择上一节点" clearable style="width:100%">
|
|
|
|
|
+ <el-option v-for="node in templateNodes" :key="node.nodeId" :label="node.nodeName + ' (ID:' + node.nodeId + ')'" :value="node.nodeId" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="节点配置">
|
|
|
|
|
+ <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="8" placeholder="JSON格式配置" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item>
|
|
|
|
|
+ <el-button type="primary" icon="el-icon-check" @click="submitNodeForm">保存节点</el-button>
|
|
|
|
|
+ <el-button v-if="nodeForm.nodeId" type="warning" icon="el-icon-s-order" @click="handleNodeRulesFromSidebar">管理规则</el-button>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item>
|
|
|
|
|
+ <el-button @click="selectedNode = null">取消</el-button>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 工作流模板查看对话框 -->
|
|
|
|
|
+ <el-dialog :title="'工作流模板 - 和泰cid加好友后流程'" :visible.sync="workflowTemplateOpen" width="900px" top="5vh" append-to-body>
|
|
|
|
|
+ <div class="workflow-template-body">
|
|
|
|
|
+ <iframe src="https://docs.qq.com/flowchart/DV1JobkhDT3BTcm9m" frameborder="0" class="workflow-iframe-large" sandbox="allow-scripts allow-same-origin allow-popups" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 节点规则管理对话框 -->
|
|
|
|
|
+ <el-dialog :title="'管理规则 - ' + currentRuleNode.nodeName" :visible.sync="ruleDialogOpen" width="800px" append-to-body @opened="loadNodeRules">
|
|
|
|
|
+ <div class="rule-actions mb8">
|
|
|
|
|
+ <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAddRule">新增规则</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-table :data="nodeRules" border>
|
|
|
|
|
+ <el-table-column label="排序" align="center" prop="sortOrder" width="60" />
|
|
|
|
|
+ <el-table-column label="规则名称" align="center" prop="ruleName" />
|
|
|
|
|
+ <el-table-column label="间隔天数" align="center" prop="sendDay" width="80" />
|
|
|
|
|
+ <el-table-column label="间隔时间" align="center" prop="sendTime" width="100" />
|
|
|
|
|
+ <el-table-column label="操作" align="center" width="120">
|
|
|
|
|
+ <template slot-scope="scope">
|
|
|
|
|
+ <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditRule(scope.row)">编辑</el-button>
|
|
|
|
|
+ <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDeleteRule(scope.row)">删除</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ <div v-if="ruleFormVisible" class="rule-form-panel" style="margin-top: 20px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 15px;">
|
|
|
|
|
+ <h4>{{ ruleFormTitle }}</h4>
|
|
|
|
|
+ <el-form ref="ruleForm" :model="ruleForm" label-width="100px" size="small">
|
|
|
|
|
+ <el-form-item label="规则名称"><el-input v-model="ruleForm.ruleName" placeholder="规则名称" /></el-form-item>
|
|
|
|
|
+ <el-form-item label="间隔时间">
|
|
|
|
|
+ <el-time-picker v-model="ruleForm.sendTime" value-format="HH:mm" format="HH:mm" :picker-options="{ selectableRange: ['00:00:00 - 23:59:59'] }" placeholder="选择时间" style="width:160px;" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="间隔天数"><el-input-number v-model="ruleForm.sendDay" :min="0" /></el-form-item>
|
|
|
|
|
+ <el-form-item label="发送内容"><el-input v-model="ruleForm.prompt" type="textarea" :rows="3" /></el-form-item>
|
|
|
|
|
+ <el-form-item label="条件">
|
|
|
|
|
+ <el-select v-model="ruleForm.rule" placeholder="请选择条件" style="width:160px;">
|
|
|
|
|
+ <el-option label="已回复消息" value="replied" />
|
|
|
|
|
+ <el-option label="未回复消息" value="unreplied" />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="排序"><el-input-number v-model="ruleForm.sortOrder" :min="0" /></el-form-item>
|
|
|
|
|
+ <el-form-item>
|
|
|
|
|
+ <el-button type="primary" @click="submitRuleForm">保存规则</el-button>
|
|
|
|
|
+ <el-button @click="ruleFormVisible = false">取消</el-button>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { getTemplate } from '../../api/aiAddwxSop/api'
|
|
|
|
|
+import { listTemplateNode, addTemplateNode, updateTemplateNode, delTemplateNode } from '../../api/aiAddwxSop/api'
|
|
|
|
|
+import { listTemplateNodeRule, addTemplateNodeRule, updateTemplateNodeRule, delTemplateNodeRule } from '../../api/aiAddwxSop/api'
|
|
|
|
|
+import { saveCanvasNodes } from '../../api/aiAddwxSop/api'
|
|
|
|
|
+
|
|
|
|
|
+let _canvasNodeCid = 0
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ name: 'AiAddwxSopCanvas',
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ templateId: null,
|
|
|
|
|
+ templateInfo: { templateName: '' },
|
|
|
|
|
+ templateNodes: [],
|
|
|
|
|
+ nodeTypeOptions: [],
|
|
|
|
|
+ // 画布
|
|
|
|
|
+ canvasNodes: [],
|
|
|
|
|
+ canvasWidth: 2000,
|
|
|
|
|
+ canvasHeight: 1500,
|
|
|
|
|
+ nodeWidth: 130,
|
|
|
|
|
+ nodeHeight: 56,
|
|
|
|
|
+ selectedNode: null,
|
|
|
|
|
+ draggingNode: null,
|
|
|
|
|
+ dragOffsetX: 0,
|
|
|
|
|
+ dragOffsetY: 0,
|
|
|
|
|
+ isDragging: false,
|
|
|
|
|
+ // 节点表单(嵌入右侧面板)
|
|
|
|
|
+ nodeForm: {},
|
|
|
|
|
+ // 规则
|
|
|
|
|
+ ruleDialogOpen: false,
|
|
|
|
|
+ currentRuleNode: {},
|
|
|
|
|
+ nodeRules: [],
|
|
|
|
|
+ ruleForm: {},
|
|
|
|
|
+ ruleFormVisible: false,
|
|
|
|
|
+ // 工作流模板
|
|
|
|
|
+ workflowTemplateOpen: false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ computed: {
|
|
|
|
|
+ ruleFormTitle() {
|
|
|
|
|
+ return this.ruleForm.ruleId ? '编辑规则' : '新增规则'
|
|
|
|
|
+ },
|
|
|
|
|
+ canvasLines() {
|
|
|
|
|
+ const lines = []
|
|
|
|
|
+ this.canvasNodes.forEach(node => {
|
|
|
|
|
+ if (node._parentCid) {
|
|
|
|
|
+ const parent = this.canvasNodes.find(n => n._cid === node._parentCid)
|
|
|
|
|
+ if (parent) {
|
|
|
|
|
+ lines.push({
|
|
|
|
|
+ x1: parent._x + this.nodeWidth / 2,
|
|
|
|
|
+ y1: parent._y + this.nodeHeight,
|
|
|
|
|
+ x2: node._x + this.nodeWidth / 2,
|
|
|
|
|
+ y2: node._y
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ return lines
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ created() {
|
|
|
|
|
+ this.templateId = this.$route.query.templateId
|
|
|
|
|
+ if (!this.templateId) {
|
|
|
|
|
+ this.$message.error('缺少模板ID')
|
|
|
|
|
+ this.goBack()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.getDicts('ai_add_wechat_sop_node').then(response => {
|
|
|
|
|
+ this.nodeTypeOptions = response.data
|
|
|
|
|
+ })
|
|
|
|
|
+ this.loadTemplateInfo()
|
|
|
|
|
+ this.loadCanvasNodes()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ goBack() {
|
|
|
|
|
+ this.$router.go(-1)
|
|
|
|
|
+ },
|
|
|
|
|
+ loadTemplateInfo() {
|
|
|
|
|
+ getTemplate(this.templateId).then(response => {
|
|
|
|
|
+ this.templateInfo = response.data || response || {}
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ loadCanvasNodes() {
|
|
|
|
|
+ listTemplateNode(this.templateId).then(response => {
|
|
|
|
|
+ const serverNodes = response.data || []
|
|
|
|
|
+ this.templateNodes = serverNodes
|
|
|
|
|
+ if (serverNodes.length === 0) {
|
|
|
|
|
+ this.canvasNodes = []
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.canvasNodes = serverNodes.map(node => ({
|
|
|
|
|
+ _cid: 'n_' + (++_canvasNodeCid),
|
|
|
|
|
+ _x: node.positionX != null ? node.positionX : 100,
|
|
|
|
|
+ _y: node.positionY != null ? node.positionY : 100,
|
|
|
|
|
+ _parentCid: null,
|
|
|
|
|
+ nodeId: node.nodeId,
|
|
|
|
|
+ templateId: node.templateId,
|
|
|
|
|
+ nodeKey: node.nodeKey,
|
|
|
|
|
+ nodeName: node.nodeName,
|
|
|
|
|
+ nodeType: node.nodeType,
|
|
|
|
|
+ prevNodeId: node.prevNodeId,
|
|
|
|
|
+ nextNodeId: node.nextNodeId,
|
|
|
|
|
+ nodeConfig: node.nodeConfig,
|
|
|
|
|
+ sortOrder: node.sortOrder
|
|
|
|
|
+ }))
|
|
|
|
|
+ this.canvasNodes.forEach(node => {
|
|
|
|
|
+ if (node.prevNodeId) {
|
|
|
|
|
+ const parent = this.canvasNodes.find(n => n.nodeId === node.prevNodeId)
|
|
|
|
|
+ if (parent) node._parentCid = parent._cid
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ const hasPositions = this.canvasNodes.some(n => n._x !== 100 || n._y !== 100)
|
|
|
|
|
+ if (!hasPositions) this.autoLayout()
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ autoLayout() {
|
|
|
|
|
+ if (this.canvasNodes.length === 0) return
|
|
|
|
|
+ const gapX = 200; const gapY = 140
|
|
|
|
|
+ const visited = new Set(); const queue = []
|
|
|
|
|
+ const roots = this.canvasNodes.filter(n => !n._parentCid)
|
|
|
|
|
+ roots.forEach((root, idx) => {
|
|
|
|
|
+ root._x = 120 + idx * gapX; root._y = 80
|
|
|
|
|
+ queue.push(root); visited.add(root._cid)
|
|
|
|
|
+ })
|
|
|
|
|
+ while (queue.length > 0) {
|
|
|
|
|
+ const parent = queue.shift()
|
|
|
|
|
+ const children = this.canvasNodes.filter(n => n._parentCid === parent._cid && !visited.has(n._cid))
|
|
|
|
|
+ children.forEach((child, idx) => {
|
|
|
|
|
+ child._x = parent._x + (idx - (children.length - 1) / 2) * gapX
|
|
|
|
|
+ child._y = parent._y + gapY
|
|
|
|
|
+ visited.add(child._cid); queue.push(child)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ addStartNode() {
|
|
|
|
|
+ const cid = 'n_' + (++_canvasNodeCid)
|
|
|
|
|
+ this.canvasNodes.push({
|
|
|
|
|
+ _cid: cid, _x: (this.canvasWidth - this.nodeWidth) / 2, _y: 80, _parentCid: null,
|
|
|
|
|
+ nodeId: null, templateId: this.templateId, nodeKey: null,
|
|
|
|
|
+ nodeName: '开始', nodeType: 'receive', prevNodeId: null, nextNodeId: null,
|
|
|
|
|
+ nodeConfig: null, sortOrder: 1
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ addChildNode(parentNode) {
|
|
|
|
|
+ const cid = 'n_' + (++_canvasNodeCid)
|
|
|
|
|
+ const childCount = this.canvasNodes.filter(n => n._parentCid === parentNode._cid).length
|
|
|
|
|
+ this.canvasNodes.push({
|
|
|
|
|
+ _cid: cid, _x: parentNode._x + (childCount - 0.5) * 180, _y: parentNode._y + 140,
|
|
|
|
|
+ _parentCid: parentNode._cid,
|
|
|
|
|
+ nodeId: null, templateId: this.templateId, nodeKey: null,
|
|
|
|
|
+ nodeName: '新节点' + (this.canvasNodes.length + 1), nodeType: 'medication',
|
|
|
|
|
+ prevNodeId: parentNode.nodeId, nextNodeId: null, nodeConfig: null,
|
|
|
|
|
+ sortOrder: this.canvasNodes.length + 1
|
|
|
|
|
+ })
|
|
|
|
|
+ const newNode = this.canvasNodes[this.canvasNodes.length - 1]
|
|
|
|
|
+ this.selectedNode = newNode
|
|
|
|
|
+ this.$nextTick(() => this.fillNodeForm(newNode))
|
|
|
|
|
+ },
|
|
|
|
|
+ onNodeClick(node) {
|
|
|
|
|
+ if (this.isDragging) return
|
|
|
|
|
+ this.selectedNode = node
|
|
|
|
|
+ this.fillNodeForm(node)
|
|
|
|
|
+ },
|
|
|
|
|
+ fillNodeForm(canvasNode) {
|
|
|
|
|
+ this.nodeForm = {
|
|
|
|
|
+ nodeId: canvasNode.nodeId || null,
|
|
|
|
|
+ templateId: this.templateId,
|
|
|
|
|
+ nodeKey: canvasNode.nodeKey || null,
|
|
|
|
|
+ nodeName: canvasNode.nodeName,
|
|
|
|
|
+ nodeType: canvasNode.nodeType,
|
|
|
|
|
+ prevNodeId: canvasNode.prevNodeId,
|
|
|
|
|
+ nextNodeId: canvasNode.nextNodeId || null,
|
|
|
|
|
+ nodeConfig: canvasNode.nodeConfig || null,
|
|
|
|
|
+ sortOrder: canvasNode.sortOrder || 1,
|
|
|
|
|
+ positionX: canvasNode._x,
|
|
|
|
|
+ positionY: canvasNode._y
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ removeCanvasNode(canvasNode) {
|
|
|
|
|
+ this.$confirm('确认删除节点"' + (canvasNode.nodeName || '未命名') + '"及其子节点吗?', '警告', {
|
|
|
|
|
+ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
|
|
|
|
+ }).then(() => {
|
|
|
|
|
+ const removeRecursive = (cid) => {
|
|
|
|
|
+ const children = this.canvasNodes.filter(n => n._parentCid === cid)
|
|
|
|
|
+ children.forEach(child => removeRecursive(child._cid))
|
|
|
|
|
+ this.canvasNodes = this.canvasNodes.filter(n => n._cid !== cid)
|
|
|
|
|
+ }
|
|
|
|
|
+ removeRecursive(canvasNode._cid)
|
|
|
|
|
+ if (this.selectedNode && this.selectedNode._cid === canvasNode._cid) {
|
|
|
|
|
+ this.selectedNode = null
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+ },
|
|
|
|
|
+ // 拖拽
|
|
|
|
|
+ onCanvasMouseDown() { this.selectedNode = null },
|
|
|
|
|
+ onNodeMouseDown(event, node) {
|
|
|
|
|
+ this.draggingNode = node
|
|
|
|
|
+ const rect = this.$refs.canvasContainer.getBoundingClientRect()
|
|
|
|
|
+ this.dragOffsetX = event.clientX - rect.left + this.$refs.canvasContainer.scrollLeft - node._x
|
|
|
|
|
+ this.dragOffsetY = event.clientY - rect.top + this.$refs.canvasContainer.scrollTop - node._y
|
|
|
|
|
+ this.isDragging = false
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ },
|
|
|
|
|
+ onCanvasMouseMove(event) {
|
|
|
|
|
+ if (!this.draggingNode) return
|
|
|
|
|
+ this.isDragging = true
|
|
|
|
|
+ const rect = this.$refs.canvasContainer.getBoundingClientRect()
|
|
|
|
|
+ this.draggingNode._x = Math.max(0, event.clientX - rect.left + this.$refs.canvasContainer.scrollLeft - this.dragOffsetX)
|
|
|
|
|
+ this.draggingNode._y = Math.max(0, event.clientY - rect.top + this.$refs.canvasContainer.scrollTop - this.dragOffsetY)
|
|
|
|
|
+ // 同步更新表单坐标
|
|
|
|
|
+ if (this.selectedNode && this.selectedNode._cid === this.draggingNode._cid) {
|
|
|
|
|
+ this.nodeForm.positionX = Math.round(this.draggingNode._x)
|
|
|
|
|
+ this.nodeForm.positionY = Math.round(this.draggingNode._y)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onCanvasMouseUp() { this.draggingNode = null; setTimeout(() => { this.isDragging = false }, 50) },
|
|
|
|
|
+ resetCanvas() {
|
|
|
|
|
+ this.$confirm('确认重置画布?所有未保存的修改将丢失。', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
|
|
|
|
+ .then(() => { this.loadCanvasNodes(); this.selectedNode = null }).catch(() => {})
|
|
|
|
|
+ },
|
|
|
|
|
+ saveCanvasNodes() {
|
|
|
|
|
+ if (this.canvasNodes.length === 0) { this.$message.warning('请先添加节点'); return }
|
|
|
|
|
+ this.canvasNodes.forEach(node => {
|
|
|
|
|
+ if (node._parentCid) {
|
|
|
|
|
+ const parent = this.canvasNodes.find(n => n._cid === node._parentCid)
|
|
|
|
|
+ if (parent && parent.nodeId) node.prevNodeId = parent.nodeId
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ const payload = this.canvasNodes.map(node => ({
|
|
|
|
|
+ templateId: this.templateId,
|
|
|
|
|
+ nodeId: node.nodeId,
|
|
|
|
|
+ nodeKey: node.nodeKey || (this.templateInfo.templateName + '_' + (node.nodeType || 'node') + '_' + Date.now()),
|
|
|
|
|
+ nodeName: node.nodeName,
|
|
|
|
|
+ nodeType: node.nodeType || 'receive',
|
|
|
|
|
+ prevNodeId: node.prevNodeId,
|
|
|
|
|
+ nextNodeId: node.nextNodeId,
|
|
|
|
|
+ nodeConfig: node.nodeConfig,
|
|
|
|
|
+ sortOrder: node.sortOrder || 1,
|
|
|
|
|
+ positionX: Math.round(node._x),
|
|
|
|
|
+ positionY: Math.round(node._y)
|
|
|
|
|
+ }))
|
|
|
|
|
+ saveCanvasNodes(payload).then(() => { this.msgSuccess('画布保存成功'); this.loadCanvasNodes() })
|
|
|
|
|
+ },
|
|
|
|
|
+ // 保存单个节点
|
|
|
|
|
+ submitNodeForm() {
|
|
|
|
|
+ if (this.nodeForm.nodeId) {
|
|
|
|
|
+ updateTemplateNode(this.nodeForm).then(() => {
|
|
|
|
|
+ this.msgSuccess('修改成功')
|
|
|
|
|
+ this.syncCanvasFromForm()
|
|
|
|
|
+ this.loadCanvasNodes()
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const tName = (this.templateInfo.templateName || 'node').replace(/\s+/g, '_')
|
|
|
|
|
+ this.nodeForm.nodeKey = tName + '_' + (this.nodeForm.nodeType || 'unknown') + '_' + Date.now()
|
|
|
|
|
+ addTemplateNode(this.nodeForm).then(response => {
|
|
|
|
|
+ this.msgSuccess('新增成功')
|
|
|
|
|
+ const newNodeId = response.data || response
|
|
|
|
|
+ const cn = this.canvasNodes.find(n => n._cid === this.selectedNode._cid)
|
|
|
|
|
+ if (cn) cn.nodeId = newNodeId
|
|
|
|
|
+ this.loadCanvasNodes()
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ syncCanvasFromForm() {
|
|
|
|
|
+ if (!this.selectedNode) return
|
|
|
|
|
+ this.selectedNode.nodeName = this.nodeForm.nodeName
|
|
|
|
|
+ this.selectedNode.nodeType = this.nodeForm.nodeType
|
|
|
|
|
+ this.selectedNode.nodeConfig = this.nodeForm.nodeConfig
|
|
|
|
|
+ this.selectedNode.sortOrder = this.nodeForm.sortOrder
|
|
|
|
|
+ this.selectedNode.prevNodeId = this.nodeForm.prevNodeId
|
|
|
|
|
+ if (this.nodeForm.positionX != null) this.selectedNode._x = this.nodeForm.positionX
|
|
|
|
|
+ if (this.nodeForm.positionY != null) this.selectedNode._y = this.nodeForm.positionY
|
|
|
|
|
+ },
|
|
|
|
|
+ getNodeColor(type) {
|
|
|
|
|
+ if (!type) return '#409EFF'
|
|
|
|
|
+ // 按节点类型分组颜色(数字码 1-11)
|
|
|
|
|
+ const groupMap = {
|
|
|
|
|
+ '1': '#409EFF', // 用户加微
|
|
|
|
|
+ '2': '#67C23A', // 到货
|
|
|
|
|
+ '3': '#67C23A', // 未到货
|
|
|
|
|
+ '4': '#E6A23C', // 使用
|
|
|
|
|
+ '5': '#E6A23C', // 未使用
|
|
|
|
|
+ '6': '#E6A23C', // 断药
|
|
|
|
|
+ '7': '#909399', // 反馈结果1
|
|
|
|
|
+ '8': '#909399', // 反馈结果2
|
|
|
|
|
+ '9': '#909399', // 攻单/推荐转人工
|
|
|
|
|
+ '10': '#F56C6C', // 成交
|
|
|
|
|
+ '11': '#F56C6C' // 未成交
|
|
|
|
|
+ }
|
|
|
|
|
+ if (groupMap[type]) return groupMap[type]
|
|
|
|
|
+ // 兜底英文码映射
|
|
|
|
|
+ const fallback = { 'receive': '#67C23A', 'medication': '#E6A23C', 'effect_feedback': '#909399', 'transfer_ai': '#F56C6C', 'start': '#409EFF' }
|
|
|
|
|
+ return fallback[type] || '#409EFF'
|
|
|
|
|
+ },
|
|
|
|
|
+ getNodeTypeName(type) {
|
|
|
|
|
+ if (!type) return '未知'
|
|
|
|
|
+ const d = this.nodeTypeOptions.find(d => d.dictValue === type)
|
|
|
|
|
+ if (d) return d.dictLabel
|
|
|
|
|
+ const f = { 'receive': '收货节点', 'medication': '用药节点', 'effect_feedback': '效果反馈', 'transfer_ai': '转AI节点', 'start': '开始' }
|
|
|
|
|
+ return f[type] || type
|
|
|
|
|
+ },
|
|
|
|
|
+ showWorkflowTemplate() { this.workflowTemplateOpen = true },
|
|
|
|
|
+ // 规则管理
|
|
|
|
|
+ handleNodeRulesFromSidebar() {
|
|
|
|
|
+ this.currentRuleNode = { nodeId: this.nodeForm.nodeId, nodeName: this.nodeForm.nodeName }
|
|
|
|
|
+ this.ruleDialogOpen = true
|
|
|
|
|
+ this.ruleForm = {}
|
|
|
|
|
+ this.ruleFormVisible = false
|
|
|
|
|
+ },
|
|
|
|
|
+ loadNodeRules() {
|
|
|
|
|
+ listTemplateNodeRule(this.currentRuleNode.nodeId).then(response => { this.nodeRules = response.data || [] })
|
|
|
|
|
+ },
|
|
|
|
|
+ handleAddRule() {
|
|
|
|
|
+ this.ruleForm = { nodeId: this.currentRuleNode.nodeId, msgType: 1, sortOrder: this.nodeRules.length + 1 }
|
|
|
|
|
+ this.ruleFormVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ handleEditRule(row) {
|
|
|
|
|
+ this.ruleForm = { ...row }
|
|
|
|
|
+ if (row.contentJson) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const p = JSON.parse(row.contentJson)
|
|
|
|
|
+ this.ruleForm.prompt = p.prompt || ''; this.ruleForm.rule = p.rule || ''
|
|
|
|
|
+ } catch (e) { this.ruleForm.prompt = ''; this.ruleForm.rule = '' }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.ruleFormVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ handleDeleteRule(row) {
|
|
|
|
|
+ this.$confirm('确认删除规则"' + row.ruleName + '"吗?', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
|
|
|
|
+ .then(() => delTemplateNodeRule(row.ruleId)).then(() => { this.loadNodeRules(); this.msgSuccess('删除成功') })
|
|
|
|
|
+ },
|
|
|
|
|
+ submitRuleForm() {
|
|
|
|
|
+ this.ruleForm.contentJson = JSON.stringify({ prompt: this.ruleForm.prompt || '', rule: this.ruleForm.rule || '' })
|
|
|
|
|
+ if (this.ruleForm.ruleId) {
|
|
|
|
|
+ updateTemplateNodeRule(this.ruleForm).then(() => { this.msgSuccess('修改成功'); this.loadNodeRules(); this.ruleForm = {}; this.ruleFormVisible = false })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ addTemplateNodeRule(this.ruleForm).then(() => { this.msgSuccess('新增成功'); this.loadNodeRules(); this.ruleForm = {}; this.ruleFormVisible = false })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.canvas-page {
|
|
|
|
|
+ height: calc(100vh - 84px);
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+.canvas-topbar {
|
|
|
|
|
+ padding: 10px 20px;
|
|
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
|
|
+ background: #fafafa;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+}
|
|
|
|
|
+.topbar-title { font-size: 15px; font-weight: 600; color: #303133; }
|
|
|
|
|
+.topbar-actions { margin-left: auto; display: flex; gap: 8px; }
|
|
|
|
|
+.canvas-layout { flex: 1; display: flex; overflow: hidden; }
|
|
|
|
|
+.canvas-main { flex: 1; overflow: hidden; min-width: 0; }
|
|
|
|
|
+.canvas-container {
|
|
|
|
|
+ width: 100%; height: 100%; overflow: auto;
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ background-image: radial-gradient(circle, #dcdfe6 1px, transparent 1px);
|
|
|
|
|
+ background-size: 20px 20px;
|
|
|
|
|
+ cursor: default;
|
|
|
|
|
+}
|
|
|
|
|
+.canvas-svg { display: block; min-width: 100%; min-height: 100%; }
|
|
|
|
|
+.node-add-btn { cursor: pointer; opacity: 0.7; transition: opacity 0.2s; }
|
|
|
|
|
+.node-add-btn:hover { opacity: 1; }
|
|
|
|
|
+.node-del-btn { cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
|
|
|
|
|
+.node-del-btn:hover { opacity: 1; }
|
|
|
|
|
+
|
|
|
|
|
+.canvas-sidebar {
|
|
|
|
|
+ width: 350px; flex-shrink: 0; border-left: 1px solid #dcdfe6;
|
|
|
|
|
+ display: flex; flex-direction: column; overflow: hidden; background: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+.sidebar-header {
|
|
|
|
|
+ padding: 12px 15px; border-bottom: 1px solid #ebeef5; background: #fafafa;
|
|
|
|
|
+ display: flex; align-items: center; justify-content: space-between;
|
|
|
|
|
+}
|
|
|
|
|
+.sidebar-title { font-size: 14px; font-weight: 600; color: #303133; }
|
|
|
|
|
+.sidebar-body { flex: 1; overflow-y: auto; padding: 15px; }
|
|
|
|
|
+.sidebar-empty { padding-top: 10px; }
|
|
|
|
|
+.sidebar-form { /* form inside sidebar */ }
|
|
|
|
|
+
|
|
|
|
|
+.workflow-template-body { height: 70vh; overflow: hidden; }
|
|
|
|
|
+.workflow-iframe-large { width: 100%; height: 100%; border: none; }
|
|
|
|
|
+</style>
|