Forráskód Böngészése

子节点bug修复

ye-zhaojia 3 napja
szülő
commit
e770d613ed

+ 7 - 1
src/api/flow/instance.ts

@@ -8,7 +8,13 @@ export function listMyInstance(params?: PageQuery): Promise<PageResult<FlowInsta
 }
 
 export function listInstance(
-  params?: PageQuery & { applicantName?: string; processName?: string; definitionName?: string; processDefinitionId?: number; currentNodeId?: string }
+  params?: PageQuery & {
+    applicantName?: string
+    processName?: string
+    definitionName?: string
+    processDefinitionId?: number
+    currentNodeId?: string
+  }
 ): Promise<PageResult<FlowInstance>> {
   return http.get('/flow/instance/page', { params })
 }

+ 6 - 1
src/api/flow/task.ts

@@ -26,7 +26,12 @@ export function rejectTask(id: number, data: ApprovalAction): Promise<void> {
 }
 
 export function batchApproveTask(taskIds: number[], data: ApprovalAction): Promise<void> {
-  return http.post('/flow/task/batch-approve', { taskIds, comment: data.comment, attachmentUrls: data.attachmentUrls, targetNodeId: data.targetNodeId })
+  return http.post('/flow/task/batch-approve', {
+    taskIds,
+    comment: data.comment,
+    attachmentUrls: data.attachmentUrls,
+    targetNodeId: data.targetNodeId,
+  })
 }
 
 export function batchRejectTask(taskIds: number[], data: ApprovalAction): Promise<void> {

+ 5 - 1
src/router/index.ts

@@ -54,7 +54,11 @@ const router = createRouter({
           path: 'flow',
           name: 'FlowAnalysis',
           component: () => import('@/views/analysis/index.vue'),
-          meta: { title: '流程分析看板', icon: 'TrendCharts', permissions: ['dept_manager', 'flow_manager', 'super_admin'] },
+          meta: {
+            title: '流程分析看板',
+            icon: 'TrendCharts',
+            permissions: ['dept_manager', 'flow_manager', 'super_admin'],
+          },
         },
       ],
     },

+ 1 - 1
src/views/analysis/index.vue

@@ -115,7 +115,7 @@ const {
   formatMinutes
 } = useAnalysis()
 
-function onInstanceQueryUpdate(newQuery: typeof instanceQuery) {
+function onInstanceQueryUpdate(newQuery: Partial<typeof instanceQuery>) {
   Object.assign(instanceQuery, newQuery)
 }
 </script>

+ 14 - 6
src/views/analysis/useAnalysis.ts

@@ -1,4 +1,4 @@
-import { ref, reactive, computed, onMounted, nextTick } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage } from 'element-plus'
 import { use } from 'echarts/core'
 import { CanvasRenderer } from 'echarts/renderers'
@@ -105,7 +105,14 @@ export function useAnalysis() {
   const loadingAll = ref(false)
 
   // 流程明细
-  const instanceQuery = reactive({ pageNum: 1, pageSize: 10, processDefinitionId: undefined as number | undefined, processName: '', applicantName: '', currentNodeId: '' })
+  const instanceQuery = reactive({
+    pageNum: 1,
+    pageSize: 10,
+    processDefinitionId: undefined as number | undefined,
+    processName: '',
+    applicantName: '',
+    currentNodeId: '',
+  })
   const instanceList = ref<FlowInstance[]>([])
   const instanceTotal = ref(0)
   const loadingInstanceList = ref(false)
@@ -406,10 +413,11 @@ export function useAnalysis() {
   function onInstanceProcessChange() {
     instanceQuery.currentNodeId = ''
     // 按名称或 ID 查找定义(StuckInstanceTable 切换时传 processName,顶栏切换时传 processDefinitionId)
-    const def = definitionList.value.find((d) => d.name === instanceQuery.processName)
-      || definitionOptions.value.find((d) => d.name === instanceQuery.processName)
-      || definitionList.value.find((d) => d.id === instanceQuery.processDefinitionId)
-      || definitionOptions.value.find((d) => d.id === instanceQuery.processDefinitionId)
+    const def =
+      definitionList.value.find((d) => d.name === instanceQuery.processName) ||
+      definitionOptions.value.find((d) => d.name === instanceQuery.processName) ||
+      definitionList.value.find((d) => d.id === instanceQuery.processDefinitionId) ||
+      definitionOptions.value.find((d) => d.id === instanceQuery.processDefinitionId)
     if (def?.flowJson) {
       try {
         currentNodeOptions.value = JSON.parse(def.flowJson)

+ 24 - 0
src/views/flow/designer/DesignerCanvas.vue

@@ -23,6 +23,18 @@
         <el-button size="small" @click="$emit('clear')">清空</el-button>
         <el-button size="small" @click="$emit('back')">返回</el-button>
       </div>
+
+      <SubNodeDrawer
+        :lf="lf"
+        :node="selectedNode"
+        :node-props="nodeProps"
+        :role-list="roleList"
+        :selected-index="selectedSubNodeIndex"
+        :visible="drawerVisible"
+        @update:visible="$emit('update:drawerVisible', $event)"
+        @select="$emit('selectSubNode', $event)"
+        @add="$emit('addSubNode')"
+      />
     </div>
   </div>
 </template>
@@ -30,9 +42,17 @@
 <script setup lang="ts">
 import type { ComponentPublicInstance } from 'vue'
 import { VideoPlay, User, Message, Operation, CircleCheck } from '@element-plus/icons-vue'
+import type { Role } from '@/types/system'
+import SubNodeDrawer from './SubNodeDrawer.vue'
 
 defineProps<{
   setContainer: (el: Element | ComponentPublicInstance | null) => void
+  lf: any
+  selectedNode: any
+  nodeProps: Record<string, any>
+  roleList: Role[]
+  selectedSubNodeIndex: number | null
+  drawerVisible: boolean
 }>()
 
 defineEmits<{
@@ -40,6 +60,9 @@ defineEmits<{
   save: []
   clear: []
   back: []
+  'update:drawerVisible': [visible: boolean]
+  selectSubNode: [index: number]
+  addSubNode: []
 }>()
 
 const nodeTypes = [
@@ -108,6 +131,7 @@ function handleDragStart(e: DragEvent, node: any) {
   flex: 1;
   position: relative;
   background: #f5f7fa;
+  overflow: hidden;
 }
 .lf-container {
   width: 100%;

+ 362 - 40
src/views/flow/designer/DesignerPropertyPanel.vue

@@ -77,7 +77,7 @@
 
         <!-- 子节点配置 -->
         <el-form-item label="启用子节点">
-          <el-switch v-model="nodeProps.subNodesEnabled" active-text="是" inactive-text="否" />
+          <el-switch v-model="nodeProps.subNodesEnabled" active-text="是" inactive-text="否" @change="onSubNodesEnabledChange" />
         </el-form-item>
         <template v-if="nodeProps.subNodesEnabled">
           <el-form-item label="执行方式">
@@ -86,38 +86,117 @@
               <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" />
+
+          <!-- 子节点列表 -->
+          <div v-if="selectedSubNodeIndex === null" class="sub-node-section">
+            <div class="section-header">
+              <span class="section-title">子节点配置</span>
+              <span class="section-count">{{ nodeProps.subNodes.length }} 个</span>
+            </div>
+            <div class="sub-node-list">
+              <div
+                v-for="(sub, index) in nodeProps.subNodes"
+                :key="sub.id"
+                class="sub-node-row"
+                :class="{
+                  'drag-over': dragOverIndex === index,
+                  sequential: isSequential,
+                }"
+                :draggable="isSequential"
+                @click="selectSubNode(index)"
+                @dragstart="handleDragStart(index, $event)"
+                @dragover.prevent="handleDragOver(index, $event)"
+                @drop="handleDrop(index)"
+                @dragend="handleDragEnd"
+              >
+                <div v-if="isSequential" class="drag-handle" @click.stop>
+                  <el-icon><Rank /></el-icon>
+                </div>
+                <div class="row-index">{{ index + 1 }}</div>
+                <div class="row-info">
+                  <div class="row-name">{{ sub.name || `子节点 ${index + 1}` }}</div>
+                  <div class="row-role">
+                    {{ getRoleName(sub.assigneeValue) || '未设置审批角色' }}
+                    <span v-if="sub.approveMode === 'and'"> · 会签</span>
+                    <span v-else> · 或签</span>
+                  </div>
+                </div>
+                <div class="row-actions">
+                  <el-icon class="delete-icon" @click.stop="removeSubNode(index)">
+                    <Delete />
+                  </el-icon>
+                </div>
+              </div>
+              <div v-if="!nodeProps.subNodes.length" class="empty-tip">暂无子节点,点击下方按钮添加</div>
+            </div>
+            <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>
+          </div>
+
+          <!-- 子节点独立配置页 -->
+          <div v-else class="sub-node-detail">
+            <div class="detail-header">
+              <el-button type="primary" size="small" link @click="backToSubNodeList">
+                <el-icon><ArrowLeft /></el-icon>
+                返回
+              </el-button>
+              <span class="section-title">子节点属性</span>
+            </div>
+            <template v-if="currentSubNode">
+              <el-form-item label="子节点名称">
+                <el-input v-model="nodeProps.subNodes[selectedSubNodeIndex].name" placeholder="如:初审" />
+              </el-form-item>
+              <el-form-item label="审批角色">
+                <el-select
+                  v-model="nodeProps.subNodes[selectedSubNodeIndex].assigneeValue"
+                  filterable
+                  clearable
+                  placeholder="请选择审批角色"
+                  style="width: 100%"
+                >
+                  <el-option
+                    v-for="role in roleList"
+                    :key="role.id"
+                    :label="role.roleName"
+                    :value="role.roleCode"
+                  />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="审批方式">
+                <el-select v-model="nodeProps.subNodes[selectedSubNodeIndex].approveMode" style="width: 100%">
+                  <el-option label="或签(一人通过即可)" value="or" />
+                  <el-option label="会签(全部通过)" value="and" />
                 </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-form-item>
+              <el-form-item>
+                <template #label>
+                  审批时限
+                  <el-tooltip placement="top" content="单位:小时,为空则不计算超时">
+                    <el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
+                  </el-tooltip>
+                </template>
+                <el-input-number
+                  v-model="nodeProps.subNodes[selectedSubNodeIndex].timeoutHours"
+                  :min="1"
+                  :max="720"
+                  :precision="0"
+                  placeholder="不填表示无限制"
+                  controls-position="right"
+                  style="width: 100%"
+                />
+              </el-form-item>
+              <el-form-item label="超时动作">
+                <el-select v-model="nodeProps.subNodes[selectedSubNodeIndex].timeoutAction" style="width: 100%">
+                  <el-option label="提醒" value="remind" />
+                  <el-option label="无" value="" />
                 </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>
+              </el-form-item>
+            </template>
+          </div>
         </template>
       </template>
       <template v-if="selectedNode.type === 'condition-node'">
@@ -165,6 +244,25 @@
         </el-form-item>
       </template>
     </el-form>
+
+    <div class="panel-actions">
+      <el-button
+        size="small"
+        style="width: 100%"
+        @click="emit('update:drawerVisible', !drawerVisible)"
+      >
+        {{ drawerVisible ? '收起画布抽屉' : '展开画布抽屉' }}
+      </el-button>
+      <el-button
+        size="small"
+        type="danger"
+        plain
+        style="width: 100%"
+        @click="deleteNode"
+      >
+        删除节点
+      </el-button>
+    </div>
   </div>
   <div v-else class="property-panel empty">
     <div class="panel-title">节点属性</div>
@@ -173,8 +271,8 @@
 </template>
 
 <script setup lang="ts">
-import { computed, toRefs } from 'vue'
-import { QuestionFilled, Plus } from '@element-plus/icons-vue'
+import { computed, toRefs, ref } from 'vue'
+import { QuestionFilled, Plus, Delete, Rank, ArrowLeft } from '@element-plus/icons-vue'
 import type { Role } from '@/types/system'
 
 const props = defineProps<{
@@ -187,10 +285,19 @@ const props = defineProps<{
   onDefaultChange: (edge: any) => void
   onBranchBlur: (edge: any) => void
   generateSubNodeId: () => string
+  selectedSubNodeIndex: number | null
+  drawerVisible: boolean
+  saveCurrentNodeProps?: () => void
 }>()
 
-const { selectedNode, outgoingEdges, roleList, nodeProps } = toRefs(props)
-const { getEdgeTargetName, updateNodeName, onDefaultChange, onBranchBlur, generateSubNodeId } = props
+const emit = defineEmits<{
+  (e: 'update:selectedSubNodeIndex', index: number | null): void
+  (e: 'update:drawerVisible', visible: boolean): void
+  (e: 'deleteNode'): void
+}>()
+
+const { selectedNode, outgoingEdges, roleList, nodeProps, selectedSubNodeIndex, drawerVisible } = toRefs(props)
+const { getEdgeTargetName, updateNodeName, onDefaultChange, onBranchBlur, generateSubNodeId, saveCurrentNodeProps } = props
 
 const nodeName = computed({
   get: () => selectedNode.value?.text?.value || '',
@@ -201,6 +308,29 @@ const nodeName = computed({
   }
 })
 
+const isSequential = computed(() => nodeProps.value.subNodeMode === 'sequential')
+const currentSubNode = computed(() => {
+  const idx = selectedSubNodeIndex.value
+  if (idx === null || !Array.isArray(nodeProps.value.subNodes)) return null
+  return nodeProps.value.subNodes[idx]
+})
+
+function getRoleName(roleCode?: string): string {
+  if (!roleCode) return ''
+  const role = roleList.value.find((r) => r.roleCode === roleCode)
+  return role ? role.roleName : roleCode
+}
+
+function onSubNodesEnabledChange(enabled: boolean | string | number) {
+  if (enabled && !Array.isArray(nodeProps.value.subNodes)) {
+    nodeProps.value.subNodes = []
+  }
+  if (enabled) {
+    emit('update:drawerVisible', true)
+  }
+  saveCurrentNodeProps?.()
+}
+
 function addSubNode() {
   if (!Array.isArray(nodeProps.value.subNodes)) {
     nodeProps.value.subNodes = []
@@ -213,11 +343,79 @@ function addSubNode() {
     timeoutHours: undefined,
     timeoutAction: 'remind',
   })
+  saveCurrentNodeProps?.()
 }
 
 function removeSubNode(index: number) {
   if (!Array.isArray(nodeProps.value.subNodes)) return
   nodeProps.value.subNodes.splice(index, 1)
+  if (selectedSubNodeIndex.value === index) {
+    emit('update:selectedSubNodeIndex', null)
+  } else if (selectedSubNodeIndex.value !== null && selectedSubNodeIndex.value > index) {
+    emit('update:selectedSubNodeIndex', selectedSubNodeIndex.value - 1)
+  }
+  saveCurrentNodeProps?.()
+}
+
+function selectSubNode(index: number) {
+  emit('update:selectedSubNodeIndex', index)
+}
+
+function backToSubNodeList() {
+  emit('update:selectedSubNodeIndex', null)
+}
+
+function deleteNode() {
+  emit('deleteNode')
+}
+
+// 顺序模式下拖动排序
+const dragIndex = ref<number | null>(null)
+const dragOverIndex = ref<number | null>(null)
+
+function handleDragStart(index: number, e: DragEvent) {
+  if (!isSequential.value) return
+  dragIndex.value = index
+  if (e.dataTransfer) {
+    e.dataTransfer.effectAllowed = 'move'
+    e.dataTransfer.setData('text/plain', String(index))
+  }
+}
+
+function handleDragOver(index: number, e: DragEvent) {
+  if (!isSequential.value || dragIndex.value === null) return
+  dragOverIndex.value = index
+  if (e.dataTransfer) {
+    e.dataTransfer.dropEffect = 'move'
+  }
+}
+
+function handleDrop(index: number) {
+  dragOverIndex.value = null
+  if (!isSequential.value || dragIndex.value === null) return
+  const from = dragIndex.value
+  const to = index
+  dragIndex.value = null
+  if (from === to) return
+  const list = nodeProps.value.subNodes
+  const item = list.splice(from, 1)[0]
+  list.splice(to, 0, item)
+  // 同步选中索引
+  if (selectedSubNodeIndex.value === from) {
+    emit('update:selectedSubNodeIndex', to)
+  } else if (selectedSubNodeIndex.value !== null) {
+    if (from < selectedSubNodeIndex.value && to >= selectedSubNodeIndex.value) {
+      emit('update:selectedSubNodeIndex', selectedSubNodeIndex.value - 1)
+    } else if (from > selectedSubNodeIndex.value && to <= selectedSubNodeIndex.value) {
+      emit('update:selectedSubNodeIndex', selectedSubNodeIndex.value + 1)
+    }
+  }
+  saveCurrentNodeProps?.()
+}
+
+function handleDragEnd() {
+  dragIndex.value = null
+  dragOverIndex.value = null
 }
 </script>
 
@@ -228,6 +426,8 @@ function removeSubNode(index: number) {
   padding: 16px;
   background: #fafafa;
   overflow-y: auto;
+  display: flex;
+  flex-direction: column;
 }
 .property-panel.empty {
   display: flex;
@@ -246,13 +446,112 @@ function removeSubNode(index: number) {
   padding-left: 4px;
   padding-right: 4px;
 }
-.sub-node-table {
-  margin-top: 8px;
+
+.sub-node-section {
+  margin-bottom: 16px;
 }
-.sub-node-table :deep(.el-input__wrapper) {
-  padding-left: 4px;
-  padding-right: 4px;
+.section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
 }
+.section-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+.section-count {
+  font-size: 12px;
+  color: #909399;
+}
+
+.sub-node-list {
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  background: #fff;
+  overflow: hidden;
+}
+.sub-node-row {
+  display: flex;
+  align-items: center;
+  padding: 8px 10px;
+  border-bottom: 1px solid #ebeef5;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+.sub-node-row:last-child {
+  border-bottom: none;
+}
+.sub-node-row:hover {
+  background: #f5f7fa;
+}
+.sub-node-row.active {
+  background: #ecf5ff;
+}
+.sub-node-row.drag-over {
+  background: #e6f2ff;
+  border: 1px dashed #409eff;
+}
+.sub-node-row.sequential {
+  cursor: move;
+}
+
+.drag-handle {
+  margin-right: 8px;
+  color: #c0c4cc;
+  cursor: move;
+  display: flex;
+  align-items: center;
+}
+.row-index {
+  width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  background: #409eff;
+  color: #fff;
+  font-size: 11px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10px;
+  flex-shrink: 0;
+}
+.row-info {
+  flex: 1;
+  min-width: 0;
+}
+.row-name {
+  font-size: 13px;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.row-role {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
+.row-actions {
+  flex-shrink: 0;
+  margin-left: 8px;
+}
+.delete-icon {
+  color: #f56c6c;
+  cursor: pointer;
+  font-size: 14px;
+}
+.delete-icon:hover {
+  color: #ff4d4f;
+}
+.empty-tip {
+  padding: 16px;
+  text-align: center;
+  font-size: 12px;
+  color: #909399;
+}
+
 .add-sub-node-btn {
   margin-top: 8px;
 }
@@ -262,4 +561,27 @@ function removeSubNode(index: number) {
   margin-top: 8px;
   line-height: 1.5;
 }
+
+.sub-node-detail {
+  margin-bottom: 16px;
+}
+.detail-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+.detail-header .section-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.panel-actions {
+  margin-top: auto;
+  padding-top: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
 </style>

+ 303 - 0
src/views/flow/designer/SubNodeDrawer.vue

@@ -0,0 +1,303 @@
+<template>
+  <div v-if="showOverlay" class="sub-node-overlay">
+    <Transition name="drawer">
+      <div
+        v-show="visible"
+        class="sub-node-drawer"
+        :style="drawerStyle"
+      >
+        <div class="drawer-header">
+          <span class="drawer-title">子节点配置</span>
+          <el-icon class="drawer-close" @click="emit('update:visible', false)">
+            <Close />
+          </el-icon>
+        </div>
+        <div class="drawer-body">
+          <div
+            v-for="(sub, index) in subNodes"
+            :key="sub.id"
+            class="sub-node-card"
+            :class="{ active: selectedIndex === index }"
+            @click="emit('select', index)"
+          >
+            <div class="card-index">{{ index + 1 }}</div>
+            <div class="card-info">
+              <div class="card-name">{{ sub.name || `子节点 ${index + 1}` }}</div>
+              <div class="card-role">{{ getRoleName(sub.assigneeValue) || '未设置审批角色' }}</div>
+            </div>
+          </div>
+          <div class="sub-node-card add-card" @click="emit('add')">
+            <el-icon class="add-icon"><Plus /></el-icon>
+            <span>添加子节点</span>
+          </div>
+        </div>
+      </div>
+    </Transition>
+
+    <div class="sub-node-badge" :style="badgeStyle">
+      {{ subNodes.length }} 子节点
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
+import { Close, Plus } from '@element-plus/icons-vue'
+import type { Role } from '@/types/system'
+
+const props = defineProps<{
+  lf: any
+  node: any
+  nodeProps: Record<string, any>
+  selectedIndex: number | null
+  visible: boolean
+  roleList: Role[]
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:visible', visible: boolean): void
+  (e: 'select', index: number): void
+  (e: 'add'): void
+}>()
+
+const showOverlay = computed(() => {
+  return props.node && props.node.type === 'approval-node' && props.nodeProps.subNodesEnabled
+})
+
+const subNodes = computed(() => {
+  return props.nodeProps.subNodes || []
+})
+
+const position = ref({ x: 0, y: 0 })
+const drawerStyle = computed(() => ({
+  position: 'absolute' as const,
+  left: `${position.value.x}px`,
+  top: `${position.value.y}px`,
+  transform: 'translate(-50%, -100%) translateY(-12px)',
+}))
+
+const badgeStyle = computed(() => ({
+  position: 'absolute' as const,
+  left: `${position.value.x}px`,
+  top: `${position.value.y + 42}px`,
+  transform: 'translate(-50%, 0)',
+}))
+
+function getRoleName(roleCode?: string): string {
+  if (!roleCode) return ''
+  const role = props.roleList.find((r) => r.roleCode === roleCode)
+  return role ? role.roleName : roleCode
+}
+
+function computePosition() {
+  if (!props.lf || !props.node?.id) return
+  try {
+    const nodeData = props.lf.getNodeData(props.node.id)
+    if (!nodeData) return
+    const htmlPoint = props.lf.graphModel.transformModel.CanvasPointToHtmlPoint([
+      nodeData.x,
+      nodeData.y,
+    ])
+    position.value = { x: htmlPoint[0], y: htmlPoint[1] }
+  } catch {
+    // ignore
+  }
+}
+
+function registerListeners() {
+  if (!props.lf) return
+  removeListeners()
+  props.lf.on('graph:transform', computePosition)
+  props.lf.on('node:drag', computePosition)
+  props.lf.on('node:dragstop', computePosition)
+}
+
+function removeListeners() {
+  if (!props.lf) return
+  props.lf.off('graph:transform', computePosition)
+  props.lf.off('node:drag', computePosition)
+  props.lf.off('node:dragstop', computePosition)
+}
+
+let registeredLf: any = null
+
+watch(
+  () => [props.lf, props.node?.id, props.visible],
+  () => {
+    if (props.lf !== registeredLf) {
+      if (registeredLf) {
+        registeredLf.off('graph:transform', computePosition)
+        registeredLf.off('node:drag', computePosition)
+        registeredLf.off('node:dragstop', computePosition)
+      }
+      registeredLf = props.lf
+      registerListeners()
+    }
+    if (props.visible && showOverlay.value) {
+      nextTick(computePosition)
+    }
+  },
+  { immediate: true }
+)
+
+onMounted(() => {
+  window.addEventListener('resize', computePosition)
+  if (props.visible && showOverlay.value) {
+    nextTick(computePosition)
+  }
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', computePosition)
+  removeListeners()
+})
+</script>
+
+<style scoped>
+.sub-node-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  z-index: 20;
+}
+
+.sub-node-drawer {
+  width: 220px;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+  pointer-events: auto;
+  overflow: hidden;
+}
+
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 12px;
+  border-bottom: 1px solid #ebeef5;
+  background: #fafafa;
+}
+
+.drawer-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.drawer-close {
+  font-size: 14px;
+  color: #909399;
+  cursor: pointer;
+}
+
+.drawer-close:hover {
+  color: #409eff;
+}
+
+.drawer-body {
+  padding: 10px;
+  max-height: 320px;
+  overflow-y: auto;
+}
+
+.sub-node-card {
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  margin-bottom: 8px;
+  border-radius: 6px;
+  border: 1px solid #ebeef5;
+  background: #fff;
+  cursor: pointer;
+  transition: all 0.2s;
+  pointer-events: auto;
+}
+
+.sub-node-card:last-child {
+  margin-bottom: 0;
+}
+
+.sub-node-card:hover {
+  border-color: #c6e2ff;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.12);
+}
+
+.sub-node-card.active {
+  border-color: #409eff;
+  background: #ecf5ff;
+}
+
+.card-index {
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  background: #409eff;
+  color: #fff;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10px;
+  flex-shrink: 0;
+}
+
+.card-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.card-name {
+  font-size: 13px;
+  font-weight: 500;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-role {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.add-card {
+  justify-content: center;
+  color: #409eff;
+  border-style: dashed;
+  border-color: #a0cfff;
+  background: #f5f7fa;
+}
+
+.add-icon {
+  margin-right: 6px;
+}
+
+.sub-node-badge {
+  padding: 2px 10px;
+  border-radius: 12px;
+  background: #409eff;
+  color: #fff;
+  font-size: 12px;
+  pointer-events: auto;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
+}
+
+.drawer-enter-active,
+.drawer-leave-active {
+  transition: opacity 0.2s, transform 0.2s;
+}
+
+.drawer-enter-from,
+.drawer-leave-to {
+  opacity: 0;
+  transform: translate(-50%, -100%) translateY(-12px) scale(0.95);
+}
+</style>

+ 52 - 1
src/views/flow/designer/index.vue

@@ -11,10 +11,19 @@
 
     <DesignerCanvas
       :set-container="setContainer"
+      :lf="lfRef"
+      :selected-node="selectedNode"
+      :node-props="nodeProps"
+      :role-list="roleList"
+      :selected-sub-node-index="selectedSubNodeIndex"
+      :drawer-visible="drawerVisible"
       @drop="handleDrop"
       @save="handleSave"
       @clear="handleClear"
       @back="goBack"
+      @update:drawer-visible="drawerVisible = $event"
+      @select-sub-node="selectedSubNodeIndex = $event"
+      @add-sub-node="addSubNodeFromCanvas"
     />
 
     <DesignerPropertyPanel
@@ -27,6 +36,12 @@
       :on-default-change="onDefaultChange"
       :on-branch-blur="onBranchBlur"
       :generate-sub-node-id="generateSubNodeId"
+      :save-current-node-props="saveCurrentNodeProps"
+      :selected-sub-node-index="selectedSubNodeIndex"
+      :drawer-visible="drawerVisible"
+      @update:selected-sub-node-index="selectedSubNodeIndex = $event"
+      @update:drawer-visible="drawerVisible = $event"
+      @delete-node="deleteSelectedNode"
     />
 
     <DesignerSaveDialog
@@ -52,7 +67,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, watch } from 'vue'
 import type { ComponentPublicInstance } from 'vue'
 import { useFlowDesigner } from './useFlowDesigner'
 import DesignerCanvas from './DesignerCanvas.vue'
@@ -75,11 +90,14 @@ const {
   nodeProps,
   outgoingEdges,
   roleList,
+  lfRef,
   getEdgeTargetName,
   updateNodeName,
   onDefaultChange,
   onBranchBlur,
   generateSubNodeId,
+  saveCurrentNodeProps,
+  deleteSelectedNode,
   saveDialogVisible,
   optionDialogVisible,
   saveForm,
@@ -100,6 +118,39 @@ const {
   removeOption,
   saveOptions
 } = useFlowDesigner(lfContainer)
+
+const selectedSubNodeIndex = ref<number | null>(null)
+const drawerVisible = ref(false)
+
+function addSubNodeFromCanvas() {
+  if (!Array.isArray(nodeProps.subNodes)) {
+    nodeProps.subNodes = []
+  }
+  nodeProps.subNodes.push({
+    id: generateSubNodeId(),
+    name: '',
+    assigneeValue: '',
+    approveMode: 'or',
+    timeoutHours: undefined,
+    timeoutAction: 'remind',
+  })
+  selectedSubNodeIndex.value = nodeProps.subNodes.length - 1
+  drawerVisible.value = true
+  saveCurrentNodeProps()
+}
+
+// 选中节点时,若已启用子节点则自动展开画布抽屉
+watch(
+  () => selectedNode.value,
+  (node) => {
+    selectedSubNodeIndex.value = null
+    if (node && node.type === 'approval-node' && nodeProps.subNodesEnabled && nodeProps.subNodes?.length > 0) {
+      drawerVisible.value = true
+    } else {
+      drawerVisible.value = false
+    }
+  }
+)
 </script>
 
 <style scoped>

+ 17 - 14
src/views/flow/designer/useFlowDesigner.ts

@@ -24,6 +24,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
   const router = useRouter()
 
   let lf: LogicFlow | null = null
+  const lfRef = ref<any>(null)
 
   const mode = computed(() => (route.query.mode as string) || '')
   const flowId = computed(() => (route.query.id as string) || '')
@@ -164,7 +165,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
       lf.setProperties(selectedNode.value.id, props)
       // 同步子节点数量角标到节点文本
       if (type === 'approval-node') {
-        syncNodeTextWithSubNodeBadge(selectedNode.value.id, props.subNodesEnabled, props.subNodes)
+        syncNodeTextWithSubNodeBadge(selectedNode.value.id)
       }
     }
   }
@@ -180,13 +181,12 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     }
   }
 
-  function syncNodeTextWithSubNodeBadge(nodeId: string, enabled: boolean, subNodes: any[]) {
+  function syncNodeTextWithSubNodeBadge(nodeId: string) {
     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)
+    // 不再把子节点数量拼进节点文本,改为画布浮层角标
+    lf.setNodeText(nodeId, rawText)
   }
 
   function loadNodeProps(data: any) {
@@ -358,6 +358,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
         },
       } as any,
     })
+    lfRef.value = lf
 
     registerNodes()
 
@@ -418,8 +419,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     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)
+      syncNodeTextWithSubNodeBadge(node.id)
     }
   }
   const typeToBackend: Record<string, string> = {
@@ -518,9 +518,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
           branchName: conditionMap.branchName || '',
         }
         // 设置/清理连线文本
-        const displayText =
-          e.properties.branchName ||
-          (!e.properties.isDefault && e.properties.condition)
+        const displayText = e.properties.branchName || (!e.properties.isDefault && e.properties.condition)
         if (displayText) {
           e.text = { value: displayText }
         } else {
@@ -814,6 +812,13 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     }
   }
 
+  function deleteSelectedNode() {
+    if (!lf || !selectedNode.value) return
+    lf.deleteNode(selectedNode.value.id)
+    selectedNode.value = null
+    outgoingEdges.value = []
+  }
+
   function handleClear() {
     if (!lf) return
     lf.clearData()
@@ -869,6 +874,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     outgoingEdges,
     roleList,
     nodeProps,
+    lfRef,
     // property panel helpers
     loadOutgoingEdges,
     getEdgeTargetName,
@@ -878,6 +884,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     loadNodeProps,
     updateNodeName,
     generateSubNodeId,
+    deleteSelectedNode,
     // save dialog
     saveDialogVisible,
     saveForm,
@@ -904,9 +911,5 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     goBack,
     // drag/drop
     handleDrop,
-    // conversion
-    convertToBackendFormat,
-    convertToFrontendFormat,
-    validateFlow,
   }
 }

+ 10 - 10
src/views/flow/execute/InstanceDetail.vue

@@ -592,21 +592,21 @@ watch(() => props.instanceId, (id) => {
   }
 })
 
-// 切换到"通过"时加载下游节点列表
-watch(() => form.action, (action) => {
-  nextNodes.value = []
-  form.targetNodeId = undefined
-  if (action === 'pass' && myPendingTask.value) {
-    loadNextNodes(myPendingTask.value.id)
+// 切换到"通过"或当前待办任务变化时加载下游节点列表
+watch(
+  [() => form.action, () => myPendingTask.value?.id],
+  ([action, taskId]) => {
+    nextNodes.value = []
+    form.targetNodeId = undefined
+    if (action === 'pass' && taskId) {
+      loadNextNodes(taskId)
+    }
   }
-})
+)
 
 watch(() => props.modelValue, async (val) => {
   if (val && props.instanceId) {
     await loadProgress()
-    if (form.action === 'pass' && myPendingTask.value) {
-      loadNextNodes(myPendingTask.value.id)
-    }
     await loadTransferableUsers()
     loadAttachments()
   }