Selaa lähdekoodia

新增子节点配置

ye-zhaojia 4 päivää sitten
vanhempi
commit
9ae2960579

+ 31 - 0
src/types/flow.ts

@@ -83,6 +83,28 @@ export interface FlowNodeConfig {
   properties?: Record<string, unknown>
 }
 
+export interface FlowSubNodeConfig {
+  id: string
+  name: string
+  assigneeValue?: string
+  approveMode?: 'or' | 'and'
+  timeoutHours?: number
+  timeoutAction?: 'remind' | 'pass' | 'reject'
+}
+
+export interface FlowNodeProperties {
+  assigneeType?: 'ROLE'
+  assigneeValue?: string
+  approveMode?: 'or' | 'and'
+  timeoutHours?: number
+  timeoutAction?: 'remind' | 'pass' | 'reject'
+  approveTimeField?: string
+  ccUsers?: string
+  subNodesEnabled?: boolean
+  subNodeMode?: 'sequential' | 'parallel'
+  subNodes?: FlowSubNodeConfig[]
+}
+
 export interface FlowEdgeConfig {
   id: string
   sourceNodeId: string
@@ -106,6 +128,14 @@ export interface ApprovalAction {
   attachmentUrls?: string
 }
 
+export interface SubNodeProgress {
+  subNodeId: string
+  subNodeName: string
+  status: 'completed' | 'current' | 'pending'
+  tasks: FlowTask[]
+  isMyTurn: boolean
+}
+
 export interface NodeProgress {
   nodeId: string
   nodeName: string
@@ -113,6 +143,7 @@ export interface NodeProgress {
   status: 'completed' | 'current' | 'pending'
   tasks: FlowTask[]
   isMyTurn: boolean
+  subNodes?: SubNodeProgress[]
 }
 
 export interface ProcessProgress {

+ 83 - 2
src/views/flow/designer/DesignerPropertyPanel.vue

@@ -74,6 +74,51 @@
           </template>
           <el-input v-model="nodeProps.approveTimeField" placeholder="如:审批时间" />
         </el-form-item>
+
+        <!-- 子节点配置 -->
+        <el-form-item label="启用子节点">
+          <el-switch v-model="nodeProps.subNodesEnabled" active-text="是" inactive-text="否" />
+        </el-form-item>
+        <template v-if="nodeProps.subNodesEnabled">
+          <el-form-item label="执行方式">
+            <el-radio-group v-model="nodeProps.subNodeMode">
+              <el-radio label="sequential">顺序执行</el-radio>
+              <el-radio label="parallel">并行执行</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <div class="panel-title">子节点配置</div>
+          <el-table :data="nodeProps.subNodes" size="small" border class="sub-node-table">
+            <el-table-column label="子节点名称" min-width="110">
+              <template #default="{ row }">
+                <el-input v-model="row.name" size="small" placeholder="如:初审" />
+              </template>
+            </el-table-column>
+            <el-table-column label="审批角色" min-width="110">
+              <template #default="{ row }">
+                <el-select v-model="row.assigneeValue" filterable clearable size="small" placeholder="请选择" style="width: 100%">
+                  <el-option v-for="role in roleList" :key="role.id" :label="role.roleName" :value="role.roleCode" />
+                </el-select>
+              </template>
+            </el-table-column>
+            <el-table-column label="审批方式" width="100">
+              <template #default="{ row }">
+                <el-select v-model="row.approveMode" size="small" style="width: 100%">
+                  <el-option label="或签" value="or" />
+                  <el-option label="会签" value="and" />
+                </el-select>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="70" align="center">
+              <template #default="{ $index }">
+                <el-button type="danger" size="small" text @click="removeSubNode($index)">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <el-button type="primary" size="small" link class="add-sub-node-btn" @click="addSubNode">
+            <el-icon><Plus /></el-icon> 添加子节点
+          </el-button>
+          <div class="form-tip">启用子节点后,父节点本身不再产生审批任务,由子节点替代执行。</div>
+        </template>
       </template>
       <template v-if="selectedNode.type === 'condition-node'">
         <div class="panel-title">分支配置</div>
@@ -129,7 +174,7 @@
 
 <script setup lang="ts">
 import { computed, toRefs } from 'vue'
-import { QuestionFilled } from '@element-plus/icons-vue'
+import { QuestionFilled, Plus } from '@element-plus/icons-vue'
 import type { Role } from '@/types/system'
 
 const props = defineProps<{
@@ -141,10 +186,11 @@ const props = defineProps<{
   updateNodeName: () => void
   onDefaultChange: (edge: any) => void
   onBranchBlur: (edge: any) => void
+  generateSubNodeId: () => string
 }>()
 
 const { selectedNode, outgoingEdges, roleList, nodeProps } = toRefs(props)
-const { getEdgeTargetName, updateNodeName, onDefaultChange, onBranchBlur } = props
+const { getEdgeTargetName, updateNodeName, onDefaultChange, onBranchBlur, generateSubNodeId } = props
 
 const nodeName = computed({
   get: () => selectedNode.value?.text?.value || '',
@@ -154,6 +200,25 @@ const nodeName = computed({
     }
   }
 })
+
+function addSubNode() {
+  if (!Array.isArray(nodeProps.value.subNodes)) {
+    nodeProps.value.subNodes = []
+  }
+  nodeProps.value.subNodes.push({
+    id: generateSubNodeId(),
+    name: '',
+    assigneeValue: '',
+    approveMode: 'or',
+    timeoutHours: undefined,
+    timeoutAction: 'remind',
+  })
+}
+
+function removeSubNode(index: number) {
+  if (!Array.isArray(nodeProps.value.subNodes)) return
+  nodeProps.value.subNodes.splice(index, 1)
+}
 </script>
 
 <style scoped>
@@ -181,4 +246,20 @@ const nodeName = computed({
   padding-left: 4px;
   padding-right: 4px;
 }
+.sub-node-table {
+  margin-top: 8px;
+}
+.sub-node-table :deep(.el-input__wrapper) {
+  padding-left: 4px;
+  padding-right: 4px;
+}
+.add-sub-node-btn {
+  margin-top: 8px;
+}
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 8px;
+  line-height: 1.5;
+}
 </style>

+ 2 - 0
src/views/flow/designer/index.vue

@@ -26,6 +26,7 @@
       :update-node-name="updateNodeName"
       :on-default-change="onDefaultChange"
       :on-branch-blur="onBranchBlur"
+      :generate-sub-node-id="generateSubNodeId"
     />
 
     <DesignerSaveDialog
@@ -78,6 +79,7 @@ const {
   updateNodeName,
   onDefaultChange,
   onBranchBlur,
+  generateSubNodeId,
   saveDialogVisible,
   optionDialogVisible,
   saveForm,

+ 64 - 2
src/views/flow/designer/useFlowDesigner.ts

@@ -43,6 +43,9 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     timeoutAction: 'remind',
     approveTimeField: '',
     ccUsers: '',
+    subNodesEnabled: false,
+    subNodeMode: 'parallel',
+    subNodes: [] as any[],
   })
 
   // 条件节点出连线配置
@@ -118,6 +121,10 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     syncEdgeToLf(edge)
   }
 
+  function generateSubNodeId(): string {
+    return 'sub-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
+  }
+
   function getDefaultProps(type: string) {
     if (type === 'approval-node')
       return {
@@ -127,6 +134,9 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
         timeoutHours: undefined,
         timeoutAction: 'remind',
         approveTimeField: '',
+        subNodesEnabled: false,
+        subNodeMode: 'parallel',
+        subNodes: [],
       }
     if (type === 'cc-node') return { ccUsers: '' }
     return {}
@@ -145,13 +155,40 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
         props.timeoutHours = nodeProps.timeoutHours
         props.timeoutAction = nodeProps.timeoutAction ?? 'remind'
         props.approveTimeField = nodeProps.approveTimeField ?? ''
+        props.subNodesEnabled = nodeProps.subNodesEnabled ?? false
+        props.subNodeMode = nodeProps.subNodeMode ?? 'parallel'
+        props.subNodes = Array.isArray(nodeProps.subNodes) ? nodeProps.subNodes : []
       } else if (type === 'cc-node') {
         props.ccUsers = nodeProps.ccUsers ?? ''
       }
       lf.setProperties(selectedNode.value.id, props)
+      // 同步子节点数量角标到节点文本
+      if (type === 'approval-node') {
+        syncNodeTextWithSubNodeBadge(selectedNode.value.id, props.subNodesEnabled, props.subNodes)
+      }
+    }
+  }
+
+  function getNodeRawText(nodeId: string): string {
+    if (!lf) return ''
+    try {
+      const node = lf.getNodeData(nodeId)
+      const text = node?.text?.value || node?.text || ''
+      return String(text).replace(/ \(\+\d+\)$/, '')
+    } catch {
+      return ''
     }
   }
 
+  function syncNodeTextWithSubNodeBadge(nodeId: string, enabled: boolean, subNodes: any[]) {
+    if (!lf) return
+    const rawText = getNodeRawText(nodeId)
+    if (!rawText) return
+    const count = enabled && Array.isArray(subNodes) ? subNodes.length : 0
+    const newText = count > 0 ? `${rawText} (+${count})` : rawText
+    lf.setNodeText(nodeId, newText)
+  }
+
   function loadNodeProps(data: any) {
     const props = data.properties || getDefaultProps(data.type)
     const cloned = JSON.parse(JSON.stringify(props))
@@ -182,6 +219,18 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
       nodeProps.timeoutHours = cloned.timeoutHours ?? undefined
       nodeProps.timeoutAction = cloned.timeoutAction ?? 'remind'
       nodeProps.approveTimeField = cloned.approveTimeField ?? ''
+      nodeProps.subNodesEnabled = cloned.subNodesEnabled === true
+      nodeProps.subNodeMode = cloned.subNodeMode ?? 'parallel'
+      nodeProps.subNodes = Array.isArray(cloned.subNodes)
+        ? cloned.subNodes.map((s: any) => ({
+            id: s.id || generateSubNodeId(),
+            name: s.name || '',
+            assigneeValue: s.assigneeValue ?? '',
+            approveMode: s.approveMode ?? 'or',
+            timeoutHours: s.timeoutHours ?? undefined,
+            timeoutAction: s.timeoutAction ?? 'remind',
+          }))
+        : []
     } else if (data.type === 'cc-node') {
       nodeProps.ccUsers = cloned.ccUsers ?? ''
     }
@@ -347,6 +396,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
               const parsed = JSON.parse(def.flowJson)
               const graphData = convertToFrontendFormat(parsed)
               lf?.render(graphData)
+              nextTick(() => syncAllSubNodeBadges())
             } catch {
               lf?.render({ nodes: defaultNodes(), edges: [] })
             }
@@ -360,9 +410,18 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     } else {
       lf.render({ nodes: defaultNodes(), edges: [] })
     }
+    nextTick(() => syncAllSubNodeBadges())
   }
 
-  // 节点类型映射:LogicFlow <-> 后端
+  function syncAllSubNodeBadges() {
+    if (!lf) return
+    const graphData = lf.getGraphData()
+    for (const node of graphData.nodes || []) {
+      if (node.type !== 'approval-node') continue
+      const props = node.properties || {}
+      syncNodeTextWithSubNodeBadge(node.id, props.subNodesEnabled, props.subNodes)
+    }
+  }
   const typeToBackend: Record<string, string> = {
     'start-node': 'start',
     'approval-node': 'approval',
@@ -390,6 +449,8 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
         // text -> name
         if (n.text !== undefined) {
           n.name = typeof n.text === 'object' ? n.text.value : n.text
+          // 去掉子节点数量角标,避免保存到后端
+          n.name = String(n.name).replace(/ \(\+\d+\)$/, '')
           delete n.text
         }
         return n
@@ -478,7 +539,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
         }
         // name -> text
         if (n.name !== undefined) {
-          n.text = n.name
+          n.text = String(n.name).replace(/ \(\+\d+\)$/, '')
           delete n.name
         }
         return n
@@ -816,6 +877,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     saveCurrentNodeProps,
     loadNodeProps,
     updateNodeName,
+    generateSubNodeId,
     // save dialog
     saveDialogVisible,
     saveForm,

+ 127 - 3
src/views/flow/execute/InstanceDetail.vue

@@ -97,9 +97,18 @@
                 <div class="node-title">
                   <span class="node-name">{{ node.nodeName }}</span>
                   <el-tag v-if="node.isMyTurn" type="danger" size="small" effect="dark">待我处理</el-tag>
+                  <el-button
+                    v-if="node.subNodes?.length"
+                    type="primary"
+                    size="small"
+                    link
+                    @click="toggleNodeExpand(node.nodeId)"
+                  >
+                    {{ isNodeExpanded(node.nodeId) ? '收起' : '展开' }} 子节点 ({{ node.subNodes.length }})
+                  </el-button>
                 </div>
                 <!-- 任务分配信息 -->
-                <div v-if="node.tasks?.length" class="node-tasks">
+                <div v-if="node.tasks?.length && !node.subNodes?.length" class="node-tasks">
                   <div
                     v-for="task in node.tasks"
                     :key="task.id"
@@ -113,7 +122,7 @@
                   </div>
                 </div>
                 <!-- 该节点的审批记录 -->
-                <div v-if="getNodeRecords(node.nodeId).length" class="node-records">
+                <div v-if="getNodeRecords(node.nodeId).length && !node.subNodes?.length" class="node-records">
                   <div
                     v-for="record in getNodeRecords(node.nodeId)"
                     :key="record.id"
@@ -127,6 +136,34 @@
                     <span class="record-time">{{ record.createTime }}</span>
                   </div>
                 </div>
+                <!-- 子节点列表 -->
+                <div v-if="node.subNodes?.length && isNodeExpanded(node.nodeId)" class="sub-node-list">
+                  <div
+                    v-for="sub in node.subNodes"
+                    :key="sub.subNodeId"
+                    class="sub-node-item"
+                    :class="{ 'current-sub-node': sub.status === 'current' }"
+                    :ref="el => setSubNodeRef(node.nodeId, sub.subNodeId, el)"
+                  >
+                    <div class="sub-node-title">
+                      <span class="sub-node-name">{{ sub.subNodeName }}</span>
+                      <el-tag v-if="sub.isMyTurn" type="danger" size="small" effect="dark">待我处理</el-tag>
+                    </div>
+                    <div v-if="sub.tasks?.length" class="node-tasks">
+                      <div
+                        v-for="task in sub.tasks"
+                        :key="task.id"
+                        class="task-item"
+                      >
+                        <span class="task-assignee">{{ task.assigneeName || '用户' + task.assigneeId }}</span>
+                        <el-tag :type="taskStatusType(task.status)" size="small">
+                          {{ taskStatusText(task.status) }}
+                        </el-tag>
+                        <span v-if="task.comment" class="task-comment">{{ task.comment }}</span>
+                      </div>
+                    </div>
+                  </div>
+                </div>
               </div>
             </el-timeline-item>
           </el-timeline>
@@ -210,7 +247,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed, watch } from 'vue'
+import { ref, reactive, computed, watch, nextTick } from 'vue'
 import { ElMessage } from 'element-plus'
 import { getProgress, getInstanceAttachments, updateInstanceFormData } from '@/api/flow/instance'
 import { approveTask, rejectTask, returnTask, addSignTask, getTransferableUsers, listNextNodes } from '@/api/flow/task'
@@ -242,6 +279,8 @@ const visible = computed({
 
 const loading = ref(false)
 const progress = ref<ProcessProgress | null>(null)
+const expandedNodes = ref<Set<string>>(new Set())
+const subNodeRefs = ref<Record<string, Record<string, HTMLElement | null>>>({})
 
 type ApprovalActionType = 'pass' | 'reject' | 'rollback'
 
@@ -319,6 +358,18 @@ const myPendingTask = computed<FlowTask | null>(() => {
       const pending = node.tasks.find(t => t.status === 0)
       if (pending) return pending
     }
+    if (node.subNodes) {
+      for (const sub of node.subNodes) {
+        if (sub.isMyTurn && sub.tasks) {
+          const pending = sub.tasks.find(t => t.status === 0)
+          if (pending) {
+            // 给子节点任务附加父节点名称,便于审批操作区展示
+            pending.nodeName = `${node.nodeName} / ${sub.subNodeName}`
+            return pending
+          }
+        }
+      }
+    }
   }
   return null
 })
@@ -353,6 +404,7 @@ async function loadProgress() {
     // 静默加载进度,无权限时不在详情页弹全局错误提示
     const res = await getProgress(props.instanceId, { silent: true })
     progress.value = res
+    autoExpandSubNodes()
   } catch {
     progress.value = null
   } finally {
@@ -360,6 +412,52 @@ async function loadProgress() {
   }
 }
 
+function autoExpandSubNodes() {
+  if (!progress.value?.nodes) return
+  expandedNodes.value.clear()
+  subNodeRefs.value = {}
+  for (const node of progress.value.nodes) {
+    if (!node.subNodes?.length) continue
+    const hasCurrentSub = node.subNodes.some(s => s.status === 'current')
+    if (node.status === 'current' || hasCurrentSub) {
+      expandedNodes.value.add(node.nodeId)
+    }
+  }
+  nextTick(() => scrollToCurrentSubNode())
+}
+
+function scrollToCurrentSubNode() {
+  if (!progress.value?.nodes) return
+  for (const node of progress.value.nodes) {
+    if (!node.subNodes?.length || !isNodeExpanded(node.nodeId)) continue
+    const currentSub = node.subNodes.find(s => s.status === 'current')
+    if (currentSub) {
+      const el = subNodeRefs.value[node.nodeId]?.[currentSub.subNodeId]
+      el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+      return
+    }
+  }
+}
+
+function setSubNodeRef(nodeId: string, subNodeId: string, el: any) {
+  if (!subNodeRefs.value[nodeId]) {
+    subNodeRefs.value[nodeId] = {}
+  }
+  subNodeRefs.value[nodeId][subNodeId] = el as HTMLElement | null
+}
+
+function toggleNodeExpand(nodeId: string) {
+  if (expandedNodes.value.has(nodeId)) {
+    expandedNodes.value.delete(nodeId)
+  } else {
+    expandedNodes.value.add(nodeId)
+  }
+}
+
+function isNodeExpanded(nodeId: string): boolean {
+  return expandedNodes.value.has(nodeId)
+}
+
 async function submitApprove() {
   const task = myPendingTask.value
   if (!task) return
@@ -673,4 +771,30 @@ watch(() => props.modelValue, async (val) => {
   color: #e6a23c;
   line-height: 1.5;
 }
+.sub-node-list {
+  margin-top: 8px;
+  padding-left: 12px;
+  border-left: 2px solid #e4e7ed;
+}
+.sub-node-item {
+  padding: 8px;
+  margin: 8px 0;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+.sub-node-item.current-sub-node {
+  background: #fdf6ec;
+  border: 1px solid #f5dab1;
+}
+.sub-node-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+.sub-node-name {
+  font-weight: bold;
+  color: #303133;
+  font-size: 13px;
+}
 </style>