Просмотр исходного кода

今正cid加微后工作流需求变更优化

lk 5 часов назад
Родитель
Сommit
5a9a9942cc

+ 9 - 0
src/api/aiAddwxSop/api.js

@@ -98,6 +98,15 @@ export function delTemplateNode(nodeId) {
   })
 }
 
+// 批量保存画布节点(含位置坐标)
+export function saveCanvasNodes(data) {
+  return request({
+    url: '/aiAddwxSop/templateNode/saveCanvasNodes',
+    method: 'post',
+    data: data
+  })
+}
+
 // ==================== 模板节点规则管理 ====================
 
 // 查询节点规则列表

+ 13 - 0
src/router/index.js

@@ -288,6 +288,19 @@ export const constantRoutes = [
     ]
   },
 
+  {
+  path: '/aiAddwxSop',
+  component: Layout,
+  hidden: true,
+  children: [
+    {
+      path: 'canvas',
+      component: () => import('@/views/aiAddwxSop/canvas'),
+      name: 'AiAddwxSopCanvas',
+      meta: { title: '画布节点设计', activeMenu: '/aiAddwxSop/template' }
+    }
+  ]
+},
   {
     path: '/watch/deviceInfo/details',
     component: Layout,

+ 572 - 0
src/views/aiAddwxSop/canvas.vue

@@ -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>

+ 22 - 11
src/views/aiAddwxSop/tagTemplateBind.vue

@@ -44,8 +44,8 @@
     <!-- 新增/修改绑定对话框 -->
     <el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
       <el-form ref="form" :model="form" :rules="rules" label-width="100px">
-        <el-form-item label="企微标签" prop="qwTagId">
-          <el-select v-model="form.qwTagId" filterable placeholder="请选择企微标签" style="width: 100%;" @change="onTagChange">
+        <el-form-item label="企微标签" prop="qwTagIds">
+          <el-select v-model="form.qwTagIds" multiple filterable placeholder="请选择企微标签(可多选)" style="width: 100%;" @change="onTagChange">
             <el-option v-for="tag in tagList" :key="tag.id" :label="tag.name + ' (' + tag.tagId + ')'" :value="tag.id" />
           </el-select>
         </el-form-item>
@@ -92,7 +92,7 @@ export default {
       },
       form: {},
       rules: {
-        qwTagId: [{ required: true, message: '请选择企微标签', trigger: 'change' }],
+        qwTagIds: [{ required: true, message: '请选择企微标签', trigger: 'change' }],
         templateId: [{ required: true, message: '请选择工作流模板', trigger: 'change' }]
       }
     }
@@ -121,13 +121,16 @@ export default {
         this.templateList = response.rows || []
       })
     },
-    onTagChange(val) {
-      const tag = this.tagList.find(t => t.id === val)
-      // console.log(tag)
-      if (tag) {
-        this.form.qwTagName = tag.name
+    onTagChange(vals) {
+      if (vals && vals.length > 0) {
+        const names = vals.map(id => {
+          const tag = this.tagList.find(t => t.id === id)
+          return tag ? tag.name : ''
+        }).filter(n => n)
+        this.form.qwTagName = names.join('、')
+      } else {
+        this.form.qwTagName = ''
       }
-
     },
     onTemplateChange(val) {
       const tpl = this.templateList.find(t => t.templateId === val)
@@ -161,7 +164,7 @@ export default {
     },
     handleUpdate(row) {
       this.reset()
-      this.form = { ...row }
+      this.form = { ...row, qwTagIds: row.qwTagId ? [row.qwTagId] : [] }
       this.open = true
       this.title = '修改标签模板绑定'
     },
@@ -181,12 +184,20 @@ export default {
       this.$refs.form.validate(valid => {
         if (valid) {
           if (this.form.bindId != null) {
-            updateTagTemplateBind(this.form).then(() => {
+            // 编辑:单标签
+            const data = { ...this.form }
+            if (data.qwTagIds && data.qwTagIds.length > 0) {
+              data.qwTagId = data.qwTagIds[0]
+              const tag = this.tagList.find(t => t.id === data.qwTagId)
+              data.qwTagName = tag ? tag.name : ''
+            }
+            updateTagTemplateBind(data).then(() => {
               this.$message.success('修改成功')
               this.open = false
               this.getList()
             })
           } else {
+            // 新增:支持多标签批量绑定
             addTagTemplateBind(this.form).then(() => {
               this.$message.success('新增成功')
               this.open = false

+ 2 - 302
src/views/aiAddwxSop/template.vue

@@ -70,136 +70,11 @@
         <el-button type="primary" @click="submitForm">确 定</el-button>
       </div>
     </el-dialog>
-
-    <!-- 节点设计对话框 -->
-    <el-dialog :title="'节点设计 - ' + currentTemplate.templateName" :visible.sync="nodeDesignOpen" width="1100px" append-to-body @opened="loadTemplateNodes">
-      <div class="node-panel">
-        <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAddNode">添加节点</el-button>
-        <el-table :data="templateNodes" style="margin-top: 10px;" row-key="nodeId">
-          <el-table-column label="节点ID" align="center" prop="nodeId" width="80" />
-          <el-table-column label="排序" align="center" prop="sortOrder" width="60" />
-          <el-table-column label="节点名称" align="center" prop="nodeName" />
-          <el-table-column label="节点类型" align="center" prop="nodeType" width="120">
-            <template slot-scope="scope">
-              <el-tag :type="getNodeTypeTag(scope.row.nodeType)">{{ getNodeTypeName(scope.row.nodeType) }}</el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column label="上一节点" align="center" width="140">
-            <template slot-scope="scope">
-              <span v-if="scope.row.prevNodeId">{{ getPrevNodeLabel(scope.row.prevNodeId) }}</span>
-              <span v-else>-</span>
-            </template>
-          </el-table-column>
-          <el-table-column label="操作" align="center" width="180">
-            <template slot-scope="scope">
-              <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditNode(scope.row)">编辑</el-button>
-              <el-button size="mini" type="text" icon="el-icon-s-order" @click="handleNodeRules(scope.row)">管理规则</el-button>
-              <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDeleteNode(scope.row)">删除</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-      </div>
-    </el-dialog>
-
-    <!-- 新增/编辑节点对话框 -->
-    <el-dialog :title="nodeFormTitle" :visible.sync="nodeFormOpen" width="550px" append-to-body>
-      <el-form ref="nodeForm" :model="nodeForm" label-width="100px" size="small">
-        <el-form-item label="节点名称" prop="nodeName">
-          <el-input v-model="nodeForm.nodeName" placeholder="请输入节点名称" />
-        </el-form-item>
-        <el-form-item label="节点类型" prop="nodeType">
-          <el-select v-model="nodeForm.nodeType" placeholder="请选择节点类型">
-            <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="排序" prop="sortOrder">
-          <el-input-number v-model="nodeForm.sortOrder" :min="0" />
-        </el-form-item>
-        <el-form-item label="上一节点" prop="prevNodeId">
-          <el-select v-model="nodeForm.prevNodeId" placeholder="请选择上一节点" clearable>
-            <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="下一节点ID" prop="nextNodeId">
-          <el-input-number v-model="nodeForm.nextNodeId" :min="0" placeholder="可选" />
-        </el-form-item> -->
-        <el-form-item label="节点配置" prop="nodeConfig">
-          <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="4" placeholder="JSON格式配置" />
-        </el-form-item>
-      </el-form>
-      <div slot="footer" class="dialog-footer">
-        <el-button @click="nodeFormOpen = false">取 消</el-button>
-        <el-button type="primary" @click="submitNodeForm">保存节点</el-button>
-      </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="规则名称" prop="ruleName">
-            <el-input v-model="ruleForm.ruleName" placeholder="规则名称,仅内部可见" />
-          </el-form-item>
-          <el-form-item label="间隔时间" prop="sendTime">
-            <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-time-picker>
-          </el-form-item>
-          <el-form-item label="间隔天数" prop="sendDay">
-            <el-input-number v-model="ruleForm.sendDay" :min="0" placeholder="第几天" />
-          </el-form-item>
-          <!-- <el-form-item label="内容类别" prop="contentType">
-            <el-input v-model="ruleForm.contentType" placeholder="请输入内容类别" />
-          </el-form-item> -->
-          <el-form-item label="发送提示词" prop="prompt">
-            <el-input v-model="ruleForm.prompt" type="textarea" :rows="3" placeholder="发送提示词" />
-          </el-form-item>
-          <el-form-item label="条件" prop="rule">
-            <el-select v-model="ruleForm.rule" placeholder="请选择条件" style="width: 30%;">
-              <el-option label="已回复消息" value="replied" />
-              <el-option label="未回复消息" value="unreplied" />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="排序" prop="sortOrder">
-            <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 { listTemplate, getTemplate, addTemplate, updateTemplate, delTemplate } from '../../api/aiAddwxSop/api'
-import { listTemplateNode, addTemplateNode, updateTemplateNode, delTemplateNode } from '../../api/aiAddwxSop/api'
-import { listTemplateNodeRule, addTemplateNodeRule, updateTemplateNodeRule, delTemplateNodeRule } from '../../api/aiAddwxSop/api'
 
 export default {
   name: 'AiAddwxSopTemplate',
@@ -209,19 +84,9 @@ export default {
       showSearch: true,
       total: 0,
       templateList: [],
-      templateNodes: [],
       ids: [],
       title: '',
       open: false,
-      nodeDesignOpen: false,
-      nodeFormOpen: false,
-      currentTemplate: {},
-      ruleDialogOpen: false,
-      currentRuleNode: {},
-      nodeTypeOptions: [],
-      nodeRules: [],
-      ruleForm: {},
-      ruleFormVisible: false,
       queryParams: {
         pageNum: 1,
         pageSize: 10,
@@ -229,7 +94,6 @@ export default {
         status: null
       },
       form: {},
-      nodeForm: {},
       rules: {
         templateName: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }]
       },
@@ -239,18 +103,7 @@ export default {
       ]
     }
   },
-  computed: {
-    nodeFormTitle() {
-      return this.nodeForm.nodeId ? '编辑节点' : '新增节点'
-    },
-    ruleFormTitle() {
-      return this.ruleForm.ruleId ? '编辑规则' : '新增规则'
-    }
-  },
   created() {
-    this.getDicts('ai_add_wechat_sop_node').then(response => {
-      this.nodeTypeOptions = response.data
-    })
     this.getList()
   },
   methods: {
@@ -325,166 +178,13 @@ export default {
         }
       })
     },
-    // 节点设计
+    // 节点设计 - 跳转到画布页面
     handleNodeDesign(row) {
-      this.currentTemplate = row
-      this.nodeDesignOpen = true
-      this.nodeForm = {}
-      this.nodeForm.templateId = row.templateId
-    },
-    loadTemplateNodes() {
-      listTemplateNode(this.currentTemplate.templateId).then(response => {
-        this.templateNodes = response.data || []
-      })
-    },
-    getNodeTypeName(type) {
-      const dict = this.nodeTypeOptions.find(d => d.dictValue === type)
-      return dict ? dict.dictLabel : type
-    },
-    getNodeTypeTag(type) {
-      const map = {
-        'receive': 'success',
-        'medication': 'warning',
-        'effect_feedback': 'info',
-        'transfer_ai': 'danger'
-      }
-      return map[type] || ''
-    },
-    getPrevNodeLabel(prevNodeId) {
-      const node = this.templateNodes.find(n => n.nodeId === prevNodeId)
-      return node ? node.nodeName + ' (ID:' + node.nodeId + ')' : 'ID:' + prevNodeId
-    },
-    handleAddNode() {
-      this.nodeForm = {
-        templateId: this.currentTemplate.templateId,
-        sortOrder: this.templateNodes.length + 1
-      }
-      this.nodeFormOpen = true
-    },
-    handleEditNode(row) {
-      this.nodeForm = { ...row, templateId: this.currentTemplate.templateId }
-      this.nodeFormOpen = true
-    },
-    handleDeleteNode(row) {
-      this.$confirm('确认删除节点"' + row.nodeName + '"吗?', '警告', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        return delTemplateNode(row.nodeId)
-      }).then(() => {
-        this.loadTemplateNodes()
-        this.msgSuccess('删除成功')
-      })
-    },
-    submitNodeForm() {
-      if (this.nodeForm.nodeId) {
-        updateTemplateNode(this.nodeForm).then(() => {
-          this.msgSuccess('修改成功')
-          this.loadTemplateNodes()
-          this.nodeForm = {}
-          this.nodeFormOpen = false
-        })
-      } else {
-        const templateName = (this.currentTemplate.templateName || 'node').replace(/\s+/g, '_')
-        const nodeType = this.nodeForm.nodeType || 'unknown'
-        const timestamp = Date.now()
-        this.nodeForm.nodeKey = templateName + '_' + nodeType + '_' + timestamp
-        addTemplateNode(this.nodeForm).then(() => {
-          this.msgSuccess('新增成功')
-          this.loadTemplateNodes()
-          this.nodeForm = {}
-          this.nodeFormOpen = false
-        })
-      }
-    },
-    // 节点规则管理
-    handleNodeRules(row) {
-      this.currentRuleNode = row
-      this.ruleDialogOpen = true
-      this.ruleForm = {}
-      this.ruleFormVisible = false
-    },
-    loadNodeRules() {
-      listTemplateNodeRule(this.currentRuleNode.nodeId).then(response => {
-        this.nodeRules = response.data || []
-      })
-    },
-    getMsgTypeName(type) {
-      const map = { 1: '普通', 2: '课程', 4: 'AI触达', 5: '打标签', 20: '直播间' }
-      return map[type] || type
-    },
-    getMsgTypeTag(type) {
-      const map = { 1: '', 2: 'success', 4: 'warning', 5: 'danger', 20: 'info' }
-      return map[type] || ''
-    },
-    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 parsed = JSON.parse(row.contentJson)
-          this.ruleForm.prompt = parsed.prompt || ''
-          this.ruleForm.rule = parsed.rule || ''
-        } catch (e) {
-          this.ruleForm.prompt = ''
-          this.ruleForm.rule = ''
-        }
-      }
-      this.ruleFormVisible = true
-    },
-    handleDeleteRule(row) {
-      this.$confirm('确认删除规则"' + row.ruleName + '"吗?', '警告', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        return 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
-        })
-      }
+      this.$router.push({ path: '/aiAddwxSop/canvas', query: { templateId: row.templateId } })
     }
   }
 }
 </script>
 
 <style scoped>
-.node-panel {
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
-  padding: 15px;
-  min-height: 300px;
-}
-.node-panel h4 {
-  margin: 0 0 10px 0;
-}
 </style>

+ 1 - 1
src/views/aiAddwxSop/userWorkflow.vue

@@ -16,7 +16,7 @@
     <!-- 操作按钮 -->
     <el-row :gutter="10" class="mb8">
       <el-col :span="1.5">
-        <el-button type="primary" plain icon="el-icon-plus" @click="handleCreateDialog">从模板创建</el-button>
+        <!-- <el-button type="primary" plain icon="el-icon-plus" @click="handleCreateDialog">从模板创建</el-button> -->
       </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
     </el-row>