Ver Fonte

Merge branch 'feature/childNodeList'

wuwenyi há 2 semanas atrás
pai
commit
e9055821fc

+ 1 - 1
README.md

@@ -32,7 +32,7 @@ Docker 构建时如遇缓存问题,修改 `Dockerfile` 中的 `ARG BUILD_BUSTE
 
 ## 默认账号
 
-密码均为 `123456a`:
+密码均为 `admin123`:
 
 | 账号 | 可见菜单 |
 |------|---------|

+ 6 - 6
package-lock.json

@@ -8068,9 +8068,9 @@
       }
     },
     "node_modules/ws": {
-      "version": "8.21.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
-      "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+      "version": "8.21.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+      "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
       "dev": true,
       "engines": {
         "node": ">=10.0.0"
@@ -13805,9 +13805,9 @@
       }
     },
     "ws": {
-      "version": "8.21.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
-      "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+      "version": "8.21.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+      "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
       "dev": true,
       "requires": {}
     },

+ 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'],
+          },
         },
       ],
     },

+ 33 - 0
src/types/flow.ts

@@ -57,6 +57,8 @@ export interface FlowTask {
   instanceNo?: string
   nodeName: string
   nodeType?: string
+  subNodeId?: string
+  subNodeName?: string
   assigneeId?: number
   assigneeType?: string
   assigneeName?: string
@@ -83,6 +85,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 +130,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 +145,7 @@ export interface NodeProgress {
   status: 'completed' | 'current' | 'pending'
   tasks: FlowTask[]
   isMyTurn: boolean
+  subNodes?: SubNodeProgress[]
 }
 
 export interface ProcessProgress {

+ 14 - 2
src/utils/flow.ts

@@ -37,11 +37,23 @@ export function taskStatusType(status?: number): string {
 }
 
 export function recordActionText(result?: string): string {
-  const map: Record<string, string> = { PASS: '通过', REJECT: '拒绝', RETURN: '回退', TRANSFER: '转办' }
+  const map: Record<string, string> = {
+    PASS: '通过',
+    REJECT: '拒绝',
+    RETURN: '回退',
+    TRANSFER: '转办',
+    EDIT: '修改表单',
+  }
   return map[result || ''] || result || '-'
 }
 
 export function recordActionType(result?: string): string {
-  const map: Record<string, string> = { PASS: 'success', REJECT: 'danger', RETURN: 'warning', TRANSFER: 'info' }
+  const map: Record<string, string> = {
+    PASS: 'success',
+    REJECT: 'danger',
+    RETURN: 'warning',
+    TRANSFER: 'info',
+    EDIT: 'warning',
+  }
   return map[result || ''] || 'info'
 }

+ 275 - 0
src/utils/flowLayout.ts

@@ -0,0 +1,275 @@
+/**
+ * 流程进度布局工具
+ *
+ * 根据流程定义的 modelJson(nodes + edges)重建真实拓扑,将流程组织为若干"行"(FlowRow):
+ *   - start / end:起点/终点标记
+ *   - node / cc:主流程上的单个节点(线性主线)
+ *   - branch:条件分支区(在该处列出全部可能的分支路线,并标记实际选择的分支)
+ *
+ * 状态语义:
+ *   - completed:节点已有处理任务或审批记录(历史已处理)
+ *   - current:节点处于进行中(有待处理任务,或为实例当前节点)
+ *   - passed:节点位于当前节点之前且无任务/记录(导入数据直接跳到中间时,前面的流程视为已走过)
+ *   - pending:节点尚未到达 / 未被选择的分支
+ *   - neutral:起点/终点/条件节点等非审批节点
+ */
+
+export interface FlowLayoutNode {
+  id: string
+  name: string
+  type: string // start / approval / cc / condition / end
+  status: 'completed' | 'current' | 'passed' | 'pending' | 'neutral'
+  isMyTurn: boolean
+  /** 审批节点的完整进度数据(tasks / subNodes 等) */
+  nodeProgress?: any
+  /** 该节点下的审批记录 */
+  records: any[]
+}
+
+export interface FlowBranch {
+  label: string
+  condition: string
+  isDefault: boolean
+  /** 该分支是否被实际选择(分支内存在已处理任务/记录,或当前节点位于该分支) */
+  isChosen: boolean
+  nodes: FlowLayoutNode[]
+}
+
+export interface FlowRow {
+  kind: 'start' | 'node' | 'cc' | 'branch' | 'end'
+  /** 单节点行的节点列表(通常 1 个);分支行无此字段 */
+  nodes?: FlowLayoutNode[]
+  /** 分支区标题(条件节点名称) */
+  title?: string
+  branches?: FlowBranch[]
+}
+
+const MAX_DEPTH = 300
+
+/**
+ * 根据流程模型与进度数据构建布局行
+ * @param modelNodes 模型节点 [{id,type,name,properties}]
+ * @param modelEdges 模型连线 [{sourceNodeId,targetNodeId,condition:{condition,isDefault,branchName}}]
+ * @param progress   进度节点列表(仅审批节点,NodeProgress[])
+ * @param records    审批记录列表(ApprovalRecord[])
+ * @param currentNodeId 当前节点 ID(用于判断"导入跳过"的历史节点与已选择分支)
+ */
+export function buildFlowLayout(
+  modelNodes: any[],
+  modelEdges: any[],
+  progress: any[],
+  records: any[],
+  currentNodeId?: string | null
+): FlowRow[] {
+  if (!Array.isArray(modelNodes) || modelNodes.length === 0) return []
+
+  const nodeMap = new Map<string, any>()
+  for (const n of modelNodes) {
+    if (n && n.id) nodeMap.set(n.id, n)
+  }
+
+  const outgoing = new Map<string, any[]>()
+  const incomingCount = new Map<string, number>()
+  for (const e of modelEdges || []) {
+    if (!e || !e.sourceNodeId || !e.targetNodeId) continue
+    if (!nodeMap.has(e.targetNodeId)) continue
+    if (!outgoing.has(e.sourceNodeId)) outgoing.set(e.sourceNodeId, [])
+    outgoing.get(e.sourceNodeId)!.push(e)
+    incomingCount.set(e.targetNodeId, (incomingCount.get(e.targetNodeId) || 0) + 1)
+  }
+
+  const progressMap = new Map<string, any>()
+  for (const p of progress || []) {
+    if (p && p.nodeId) progressMap.set(p.nodeId, p)
+  }
+
+  const recordsMap = new Map<string, any[]>()
+  for (const r of records || []) {
+    if (!r || !r.nodeId) continue
+    if (!recordsMap.has(r.nodeId)) recordsMap.set(r.nodeId, [])
+    recordsMap.get(r.nodeId)!.push(r)
+  }
+
+  // 计算"当前节点的祖先"(从当前节点沿反向边可达的节点)——用于识别导入跳过的主线历史节点
+  const ancestors = new Set<string>()
+  if (currentNodeId && nodeMap.has(currentNodeId)) {
+    const reverse: Map<string, string[]> = new Map()
+    for (const e of modelEdges || []) {
+      if (!e?.sourceNodeId || !e?.targetNodeId) continue
+      if (!nodeMap.has(e.targetNodeId)) continue
+      if (!reverse.has(e.targetNodeId)) reverse.set(e.targetNodeId, [])
+      reverse.get(e.targetNodeId)!.push(e.sourceNodeId)
+    }
+    const stack = [currentNodeId]
+    while (stack.length) {
+      const nid = stack.pop()!
+      for (const pre of reverse.get(nid) || []) {
+        if (pre && pre !== currentNodeId && !ancestors.has(pre)) {
+          ancestors.add(pre)
+          stack.push(pre)
+        }
+      }
+    }
+  }
+
+  const startNode = modelNodes.find((n) => n.type === 'start')
+  const endNodes = modelNodes.filter((n) => n.type === 'end')
+  const startId = startNode?.id
+
+  /** 节点是否有实际活动(任务或记录) */
+  const isNodeActive = (n: any): boolean => {
+    const p = progressMap.get(n.id)
+    if (p && p.status && p.status !== 'pending') return true
+    if ((recordsMap.get(n.id) || []).length) return true
+    return false
+  }
+
+  const mkNode = (n: any, status?: FlowLayoutNode['status']): FlowLayoutNode => {
+    const p = progressMap.get(n.id)
+    return {
+      id: n.id,
+      name: n.name || n.id,
+      type: n.type,
+      status: status ?? (n.type === 'start' || n.type === 'end' || n.type === 'condition' ? 'neutral' : 'pending'),
+      isMyTurn: !!p?.isMyTurn,
+      nodeProgress: p,
+      records: recordsMap.get(n.id) || [],
+    }
+  }
+
+  /** 主线节点状态:优先进度,其次"祖先=已走过",否则待处理 */
+  const mainLineStatus = (n: any): FlowLayoutNode['status'] => {
+    const p = progressMap.get(n.id)
+    if (p) {
+      if (p.status === 'current') return 'current'
+      if (p.status === 'completed') return 'completed'
+      return 'pending'
+    }
+    if (n.id === currentNodeId) return 'current'
+    if (ancestors.has(n.id)) return 'passed'
+    if (n.type === 'cc') return 'completed'
+    return 'pending'
+  }
+
+  const rows: FlowRow[] = []
+  const visited = new Set<string>()
+
+  if (startId && nodeMap.has(startId)) {
+    rows.push({ kind: 'start', nodes: [mkNode(nodeMap.get(startId))] })
+    visited.add(startId)
+  }
+
+  let curId: string | undefined = startId
+  let guard = 0
+  while (curId && nodeMap.has(curId) && guard++ < MAX_DEPTH) {
+    const cur = nodeMap.get(curId)
+    const outs = (outgoing.get(curId) || []).filter((e) => e.targetNodeId && nodeMap.has(e.targetNodeId))
+
+    if (cur.type === 'end' || outs.length === 0) {
+      if (cur.type === 'end') rows.push({ kind: 'end', nodes: [mkNode(cur)] })
+      else rows.push({ kind: 'node', nodes: [mkNode(cur, mainLineStatus(cur))] })
+      break
+    }
+
+    // 多出边 → 分支节点
+    if (outs.length > 1) {
+      // 第一遍:收集每条分支的节点链(原始模型节点,不含汇聚点/终点)
+      const rawChains: { edge: any; nodes: any[]; merge: string | null }[] = []
+      let mergeId: string | null = null
+      for (const e of outs) {
+        const chainNodes: any[] = []
+        let bCurId = e.targetNodeId
+        let bGuard = 0
+        let branchMerge: string | null = null
+        while (bCurId && nodeMap.has(bCurId) && bGuard++ < MAX_DEPTH) {
+          const bn = nodeMap.get(bCurId)
+          const bin = incomingCount.get(bCurId) || 0
+          if (bn.type === 'end' || bin > 1) {
+            branchMerge = bCurId
+            break
+          }
+          chainNodes.push(bn)
+          const bNext = (outgoing.get(bCurId) || []).filter((be) => be.targetNodeId && nodeMap.has(be.targetNodeId))
+          const nextIn = bNext.length === 1 ? incomingCount.get(bNext[0].targetNodeId) || 0 : 0
+          if (bNext.length !== 1 || nextIn > 1 || bn.type === 'condition') {
+            if (bNext.length === 1 && nextIn > 1) branchMerge = bNext[0].targetNodeId
+            break
+          }
+          bCurId = bNext[0].targetNodeId
+        }
+        rawChains.push({ edge: e, nodes: chainNodes, merge: branchMerge })
+        if (branchMerge) {
+          if (mergeId === null) mergeId = branchMerge
+          else if (mergeId !== branchMerge) mergeId = null
+        }
+      }
+
+      // 第二遍:判定各分支是否被选择,再解析分支节点状态
+      const branches: FlowBranch[] = rawChains.map((rc) => {
+        const cond = rc.edge.condition || {}
+        const branchName = cond.branchName || ''
+        const condition = cond.condition || ''
+        const isDefault = !!cond.isDefault
+        // 分支被选择:分支内有活动节点(已处理/进行中/有记录),或当前节点就在该分支
+        const isChosen = rc.nodes.some((n) => isNodeActive(n) || n.id === currentNodeId)
+        const nodes = rc.nodes.map((n) => {
+          if (n.id === currentNodeId) return mkNode(n, 'current')
+          const p = progressMap.get(n.id)
+          if (p) {
+            if (p.status === 'current') return mkNode(n, 'current')
+            if (p.status === 'completed') return mkNode(n, 'completed')
+            return mkNode(n, 'pending')
+          }
+          // 无任务/记录:所在分支被选择视为已走过(历史),否则为未选择
+          return mkNode(n, isChosen ? 'passed' : 'pending')
+        })
+        return {
+          label: branchName || condition || (isDefault ? '默认分支' : '分支'),
+          condition,
+          isDefault,
+          isChosen,
+          nodes,
+        }
+      })
+
+      rows.push({ kind: 'branch', title: cur.name || cur.id, branches })
+      // 所有分支汇聚到同一非终点节点则继续主线;否则流程已在分支内结束
+      if (mergeId && nodeMap.has(mergeId) && mergeId !== endNodes[0]?.id) {
+        curId = mergeId
+      } else {
+        curId = undefined
+      }
+      continue
+    }
+
+    // 单出边 → 正常推进
+    const nextId = outs[0].targetNodeId
+    if (cur.type !== 'start') {
+      if (cur.type === 'cc') rows.push({ kind: 'cc', nodes: [mkNode(cur, 'completed')] })
+      else rows.push({ kind: 'node', nodes: [mkNode(cur, mainLineStatus(cur))] })
+    }
+    if (visited.has(nextId)) break // 防环
+    visited.add(nextId)
+    curId = nextId
+  }
+
+  // 兜底:主遍历未覆盖到的节点追加到末尾,保证所有节点都可见
+  const emittedIds = new Set<string>()
+  for (const r of rows) {
+    r.nodes?.forEach((n) => emittedIds.add(n.id))
+    r.branches?.forEach((b) => b.nodes.forEach((n) => emittedIds.add(n.id)))
+  }
+  for (const n of modelNodes) {
+    if (n.type === 'start' || n.type === 'end' || n.type === 'condition') continue
+    if (emittedIds.has(n.id)) continue
+    rows.push({ kind: n.type === 'cc' ? 'cc' : 'node', nodes: [mkNode(n, mainLineStatus(n))] })
+  }
+
+  // 结束节点兜底:补上所有尚未展示的终点
+  for (const en of endNodes) {
+    if (en && nodeMap.has(en.id) && !emittedIds.has(en.id)) {
+      rows.push({ kind: 'end', nodes: [mkNode(en)] })
+    }
+  }
+  return rows
+}

+ 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)

+ 6 - 6
src/views/flow/definition/index.vue

@@ -13,10 +13,10 @@
 
       <el-form :inline="true" :model="queryParams" class="search-form">
         <el-form-item label="流程编码">
-          <el-input v-model="queryParams.code" placeholder="请输入流程编码" clearable />
+          <el-input v-model="queryParams.processCode" placeholder="请输入流程编码" clearable />
         </el-form-item>
         <el-form-item label="流程名称">
-          <el-input v-model="queryParams.name" placeholder="请输入流程名称" clearable />
+          <el-input v-model="queryParams.processName" placeholder="请输入流程名称" clearable />
         </el-form-item>
         <el-form-item label="状态">
           <el-select v-model="queryParams.status" placeholder="全部" clearable style="width: auto; min-width: 120px;">
@@ -269,8 +269,8 @@ const total = ref(0)
 const queryParams = reactive({
   pageNum: 1,
   pageSize: 10,
-  code: undefined as string | undefined,
-  name: undefined as string | undefined,
+  processCode: undefined as string | undefined,
+  processName: undefined as string | undefined,
   status: undefined as number | undefined
 })
 
@@ -410,8 +410,8 @@ function handleSizeChange() {
 }
 
 function resetQuery() {
-  queryParams.code = undefined
-  queryParams.name = undefined
+  queryParams.processCode = undefined
+  queryParams.processName = undefined
   queryParams.status = undefined
   handleQuery()
 }

+ 28 - 2
src/views/flow/designer/DesignerCanvas.vue

@@ -23,6 +23,19 @@
         <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')"
+        @remove="$emit('removeSubNode', $event)"
+      />
     </div>
   </div>
 </template>
@@ -30,16 +43,28 @@
 <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<{
+const props = defineProps<{
   setContainer: (el: Element | ComponentPublicInstance | null) => void
+  lf: any
+  selectedNode: any
+  nodeProps: Record<string, any>
+  roleList: Role[]
+  selectedSubNodeIndex: number | null
+  drawerVisible: boolean
 }>()
 
-defineEmits<{
+const emit = defineEmits<{
   drop: [e: DragEvent]
   save: []
   clear: []
   back: []
+  'update:drawerVisible': [visible: boolean]
+  selectSubNode: [index: number]
+  addSubNode: []
+  removeSubNode: [index: number]
 }>()
 
 const nodeTypes = [
@@ -108,6 +133,7 @@ function handleDragStart(e: DragEvent, node: any) {
   flex: 1;
   position: relative;
   background: #f5f7fa;
+  overflow: hidden;
 }
 .lf-container {
   width: 100%;

+ 508 - 69
src/views/flow/designer/DesignerPropertyPanel.vue

@@ -9,71 +9,215 @@
         <el-input v-model="nodeName" @blur="updateNodeName" />
       </el-form-item>
       <template v-if="selectedNode.type === 'approval-node'">
-        <el-form-item label="审批对象类型">
-          <el-select
-            v-model="nodeProps.assigneeType"
-            placeholder="请选择审批对象类型"
-            style="width: 100%"
-            @change="nodeProps.assigneeValue = ''"
-          >
-            <el-option label="审批角色" value="ROLE" />
-            <el-option label="发起人自己" value="SELF" />
-          </el-select>
-        </el-form-item>
-        <el-form-item v-if="nodeProps.assigneeType === 'ROLE'" label="审批角色">
-          <el-select
-            v-model="nodeProps.assigneeValue"
-            filterable
-            clearable
-            placeholder="请选择审批角色"
-            style="width: 100%"
-          >
-            <el-option
-              v-for="role in roleList"
-              :key="role.id"
-              :label="role.roleName"
-              :value="role.roleCode"
+        <template v-if="!nodeProps.subNodesEnabled">
+          <el-form-item label="审批对象类型">
+            <el-select
+              v-model="nodeProps.assigneeType"
+              placeholder="请选择审批对象类型"
+              style="width: 100%"
+              @change="nodeProps.assigneeValue = ''"
+            >
+              <el-option label="审批角色" value="ROLE" />
+              <el-option label="发起人自己" value="SELF" />
+            </el-select>
+          </el-form-item>
+          <el-form-item v-if="nodeProps.assigneeType === 'ROLE'" label="审批角色">
+            <el-select
+              v-model="nodeProps.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.approveMode" placeholder="请选择" style="width: 100%">
+              <el-option label="或签(一人通过即可)" value="or" />
+              <el-option label="会签(全部通过)" value="and" />
+            </el-select>
+          </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.timeoutHours"
+              :min="1"
+              :max="720"
+              :precision="0"
+              placeholder="不填表示无限制"
+              controls-position="right"
+              style="width: 100%"
             />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="审批方式">
-          <el-select v-model="nodeProps.approveMode" placeholder="请选择" style="width: 100%">
-            <el-option label="或签(一人通过即可)" value="or" />
-            <el-option label="会签(全部通过)" value="and" />
-          </el-select>
-        </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.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.timeoutAction" placeholder="请选择" style="width: 100%">
-            <el-option label="提醒" value="remind" />
-            <el-option label="无" value="" />
-          </el-select>
-        </el-form-item>
-        <el-form-item>
-          <template #label>
-            审批时间字段
-            <el-tooltip placement="top" content="导入时按此字段的值作为该节点的审批时间,Excel 列名需与此一致">
-              <el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
-            </el-tooltip>
-          </template>
-          <el-input v-model="nodeProps.approveTimeField" placeholder="如:审批时间" />
+          </el-form-item>
+          <el-form-item label="超时动作">
+            <el-select v-model="nodeProps.timeoutAction" placeholder="请选择" style="width: 100%">
+              <el-option label="提醒" value="remind" />
+              <el-option label="无" value="" />
+            </el-select>
+          </el-form-item>
+          <el-form-item>
+            <template #label>
+              审批时间字段
+              <el-tooltip placement="top" content="导入时按此字段的值作为该节点的审批时间,Excel 列名需与此一致">
+                <el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
+              </el-tooltip>
+            </template>
+            <el-input v-model="nodeProps.approveTimeField" placeholder="如:审批时间" />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px;">
+            <template #title>
+              已启用子节点,该节点的审批由子节点完成,无需配置审批对象。
+            </template>
+          </el-alert>
+        </template>
+
+        <!-- 子节点配置 -->
+        <el-form-item label="启用子节点">
+          <el-switch v-model="nodeProps.subNodesEnabled" active-text="是" inactive-text="否" @change="onSubNodesEnabledChange" />
         </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 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">
+                    {{ getSubNodeAssigneeDisplay(sub) }}
+                    <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].assigneeType"
+                  placeholder="请选择审批对象类型"
+                  style="width: 100%"
+                  @change="nodeProps.subNodes[selectedSubNodeIndex].assigneeValue = ''"
+                >
+                  <el-option label="审批角色" value="ROLE" />
+                  <el-option label="发起人自己" value="SELF" />
+                </el-select>
+              </el-form-item>
+              <el-form-item v-if="nodeProps.subNodes[selectedSubNodeIndex].assigneeType === 'ROLE'" 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>
+              </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>
+              </el-form-item>
+            </template>
+          </div>
+        </template>
       </template>
       <template v-if="selectedNode.type === 'condition-node'">
         <div class="panel-title">分支配置</div>
@@ -120,6 +264,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>
@@ -128,8 +291,8 @@
 </template>
 
 <script setup lang="ts">
-import { computed, toRefs } from 'vue'
-import { QuestionFilled } 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<{
@@ -141,19 +304,155 @@ const props = defineProps<{
   updateNodeName: () => void
   onDefaultChange: (edge: any) => void
   onBranchBlur: (edge: any) => void
+  generateSubNodeId: () => string
+  selectedSubNodeIndex: number | null
+  drawerVisible: boolean
+  saveCurrentNodeProps?: () => void
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:selectedSubNodeIndex', index: number | null): void
+  (e: 'update:drawerVisible', visible: boolean): void
+  (e: 'deleteNode'): void
 }>()
 
-const { selectedNode, outgoingEdges, roleList, nodeProps } = toRefs(props)
-const { getEdgeTargetName, updateNodeName, onDefaultChange, onBranchBlur } = props
+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 || '',
+  get: () => {
+    const t = selectedNode.value?.text
+    // LogicFlow 节点文本可能是 { value, x, y } 对象,也可能是纯字符串,两种都兼容
+    return typeof t === 'object' && t !== null ? t.value || '' : t || ''
+  },
   set: (val: string) => {
     if (selectedNode.value) {
-      selectedNode.value.text = { ...selectedNode.value.text, value: val }
+      const cur = selectedNode.value.text
+      selectedNode.value.text = {
+        ...(typeof cur === 'object' && cur !== null ? cur : {}),
+        value: val,
+      }
     }
   }
 })
+
+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 getSubNodeAssigneeDisplay(sub: any): string {
+  const type = sub.assigneeType || 'ROLE'
+  if (type === 'SELF') return '发起人自己'
+  if (type === 'USER') return sub.assigneeValue ? `指定用户:${sub.assigneeValue}` : '未设置指定用户'
+  return getRoleName(sub.assigneeValue) || '未设置审批角色'
+}
+
+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 = []
+  }
+  nodeProps.value.subNodes.push({
+    id: generateSubNodeId(),
+    name: '',
+    assigneeType: 'ROLE',
+    assigneeValue: '',
+    approveMode: 'or',
+    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>
 
 <style scoped>
@@ -163,6 +462,8 @@ const nodeName = computed({
   padding: 16px;
   background: #fafafa;
   overflow-y: auto;
+  display: flex;
+  flex-direction: column;
 }
 .property-panel.empty {
   display: flex;
@@ -181,4 +482,142 @@ const nodeName = computed({
   padding-left: 4px;
   padding-right: 4px;
 }
+
+.sub-node-section {
+  margin-bottom: 16px;
+}
+.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;
+}
+.form-tip {
+  font-size: 12px;
+  color: #909399;
+  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>

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

@@ -0,0 +1,365 @@
+<template>
+  <div v-if="showOverlay" class="sub-node-overlay">
+    <Transition name="drawer">
+      <div
+        v-show="visible"
+        class="drawer-position"
+        :style="drawerPositionStyle"
+      >
+        <div class="sub-node-drawer">
+          <div class="drawer-body">
+            <div
+              v-for="(sub, index) in subNodes"
+              :key="sub.id || index"
+              class="sub-node-card"
+              :class="{ active: selectedIndex === index }"
+              @click="onSelectSubNode(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">{{ getSubNodeAssigneeDisplay(sub) }}</div>
+              </div>
+              <div class="card-actions" @click.stop>
+                <el-icon class="delete-icon" @click="onRemove(index)">
+                  <Delete />
+                </el-icon>
+              </div>
+            </div>
+            <div class="sub-node-card add-card" @click="onAdd">
+              <el-icon class="add-icon"><Plus /></el-icon>
+              <span>添加子节点</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Transition>
+
+    <div class="sub-node-badge" :style="badgeStyle" @click="toggleVisible">
+      <el-icon><Document /></el-icon>
+      <span>子节点 {{ subNodes.length }}</span>
+      <el-icon class="badge-arrow" :class="{ open: visible }">
+        <CaretBottom v-if="visible" />
+        <CaretTop v-else />
+      </el-icon>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
+import { Plus, Delete, CaretTop, CaretBottom, Document } from '@element-plus/icons-vue'
+import type { Role } from '@/types/system'
+
+const props = defineProps<{
+  lf: any
+  node: any
+  nodeProps: Record<string, any>
+  roleList: Role[]
+  selectedIndex: number | null
+  visible: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:visible', visible: boolean): void
+  (e: 'select', index: number): void
+  (e: 'add'): void
+  (e: 'remove', index: number): void
+}>()
+
+const showOverlay = computed(() => {
+  return props.node && props.node.type === 'approval-node' && props.nodeProps.subNodesEnabled
+})
+
+const subNodes = computed(() => {
+  return props.nodeProps.subNodes || []
+})
+
+const CARD_HEIGHT = 58
+const CARD_GAP = 8
+const BADGE_HEIGHT = 26
+const VERTICAL_GAP = 6
+const NODE_HALF_HEIGHT = 30
+
+const position = ref({ x: 0, y: 0 })
+
+const drawerHeight = computed(() => {
+  const count = subNodes.value.length + 1 // +1 for add-card
+  return count * CARD_HEIGHT + Math.max(0, count - 1) * CARD_GAP + 16 // padding
+})
+
+const badgeTop = computed(() => {
+  return position.value.y - NODE_HALF_HEIGHT - BADGE_HEIGHT - VERTICAL_GAP
+})
+
+const drawerTop = computed(() => {
+  return badgeTop.value - VERTICAL_GAP - drawerHeight.value
+})
+
+const drawerPositionStyle = computed(() => ({
+  position: 'absolute' as const,
+  left: `${position.value.x}px`,
+  top: `${drawerTop.value}px`,
+  transform: 'translate(-50%, 0)',
+}))
+
+const badgeStyle = computed(() => ({
+  position: 'absolute' as const,
+  left: `${position.value.x}px`,
+  top: `${badgeTop.value}px`,
+  transform: 'translate(-50%, 0)',
+}))
+
+function toggleVisible() {
+  emit('update:visible', !props.visible)
+}
+
+function onSelectSubNode(index: number) {
+  emit('select', index)
+}
+
+function onAdd() {
+  emit('add')
+}
+
+function onRemove(index: number) {
+  emit('remove', index)
+}
+
+function getRoleName(roleCode?: string): string {
+  if (!roleCode) return ''
+  const role = props.roleList.find((r) => r.roleCode === roleCode)
+  return role ? role.roleName : roleCode
+}
+
+function getSubNodeAssigneeDisplay(sub: any): string {
+  const type = sub.assigneeType || 'ROLE'
+  if (type === 'SELF') return '发起人自己'
+  if (type === 'USER') return sub.assigneeValue ? `指定用户:${sub.assigneeValue}` : '未设置指定用户'
+  return getRoleName(sub.assigneeValue) || '未设置审批角色'
+}
+
+function computePosition() {
+  if (!props.node?.id) return
+  try {
+    // 通过选中的 DOM 元素计算节点中心位置(最可靠)
+    const nodeEl = document.querySelector('.lf-node-selected')
+    if (!nodeEl) return
+    const shapeEl = nodeEl.querySelector('.lf-basic-shape') || nodeEl
+    const rect = shapeEl.getBoundingClientRect()
+    const canvasEl = document.querySelector('.canvas-area')
+    const canvasRect = canvasEl ? canvasEl.getBoundingClientRect() : { left: 0, top: 0 }
+    position.value = {
+      x: rect.left + rect.width / 2 - canvasRect.left,
+      y: rect.top + rect.height / 2 - canvasRect.top,
+    }
+  } 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: 100;
+}
+
+.drawer-position {
+  z-index: 101;
+}
+
+.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-body {
+  padding: 12px;
+  max-height: 320px;
+  overflow-y: auto;
+}
+
+.sub-node-card {
+  display: flex;
+  align-items: center;
+  padding: 10px 12px;
+  margin-bottom: 8px;
+  border-radius: 8px;
+  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: 24px;
+  height: 24px;
+  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: 14px;
+  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;
+}
+
+.card-actions {
+  flex-shrink: 0;
+  margin-left: 8px;
+}
+
+.delete-icon {
+  color: #f56c6c;
+  font-size: 14px;
+  cursor: pointer;
+}
+
+.delete-icon:hover {
+  color: #ff4d4f;
+}
+
+.add-card {
+  justify-content: center;
+  color: #409eff;
+  border-style: dashed;
+  border-color: #a0cfff;
+  background: #f5f7fa;
+}
+
+.add-icon {
+  margin-right: 6px;
+}
+
+.sub-node-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 12px;
+  background: #409eff;
+  color: #fff;
+  font-size: 12px;
+  pointer-events: auto;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
+  z-index: 101;
+}
+
+.badge-arrow {
+  transition: transform 0.2s;
+  font-size: 10px;
+}
+
+.badge-arrow.open {
+  transform: rotate(180deg);
+}
+
+.drawer-enter-active,
+.drawer-leave-active {
+  transition: opacity 0.2s, transform 0.2s;
+}
+
+.drawer-enter-from,
+.drawer-leave-to {
+  opacity: 0;
+  transform: translate(-50%, 10px) scale(0.95);
+}
+</style>

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

@@ -11,10 +11,20 @@
 
     <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; drawerVisible = true"
+      @add-sub-node="addSubNodeFromCanvas"
+      @remove-sub-node="removeSubNodeFromCanvas"
     />
 
     <DesignerPropertyPanel
@@ -26,6 +36,13 @@
       :update-node-name="updateNodeName"
       :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
@@ -51,7 +68,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'
@@ -74,10 +91,15 @@ const {
   nodeProps,
   outgoingEdges,
   roleList,
+  lfRef,
   getEdgeTargetName,
   updateNodeName,
   onDefaultChange,
   onBranchBlur,
+  generateSubNodeId,
+  saveCurrentNodeProps,
+  deleteSelectedNode,
+  selectNodeById,
   saveDialogVisible,
   optionDialogVisible,
   saveForm,
@@ -98,6 +120,51 @@ 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: '',
+    assigneeType: 'ROLE',
+    assigneeValue: '',
+    approveMode: 'or',
+    timeoutHours: undefined,
+    timeoutAction: 'remind',
+  })
+  selectedSubNodeIndex.value = nodeProps.subNodes.length - 1
+  drawerVisible.value = true
+  saveCurrentNodeProps()
+}
+
+function removeSubNodeFromCanvas(index: number) {
+  if (!Array.isArray(nodeProps.subNodes)) return
+  nodeProps.subNodes.splice(index, 1)
+  if (selectedSubNodeIndex.value === index) {
+    selectedSubNodeIndex.value = null
+  } else if (selectedSubNodeIndex.value !== null && selectedSubNodeIndex.value > index) {
+    selectedSubNodeIndex.value = selectedSubNodeIndex.value - 1
+  }
+  saveCurrentNodeProps()
+}
+
+// 选中节点或子节点开关变化时,自动展开/收起画布抽屉
+watch(
+  () => [selectedNode.value, nodeProps.subNodesEnabled],
+  ([node]) => {
+    selectedSubNodeIndex.value = null
+    if (node && node.type === 'approval-node' && nodeProps.subNodesEnabled) {
+      drawerVisible.value = true
+    } else {
+      drawerVisible.value = false
+    }
+  }
+)
 </script>
 
 <style scoped>

+ 116 - 11
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) || '')
@@ -43,6 +44,9 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     timeoutAction: 'remind',
     approveTimeField: '',
     ccUsers: '',
+    subNodesEnabled: false,
+    subNodeMode: 'parallel',
+    subNodes: [] as any[],
   })
 
   // 条件节点出连线配置
@@ -118,6 +122,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 +135,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 +156,41 @@ 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)
+      }
+      // 通知画布刷新子节点 overlay
+      lf.emit('properties:changed', { nodeId: selectedNode.value.id })
+    }
+  }
+
+  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) {
+    if (!lf) return
+    const rawText = getNodeRawText(nodeId)
+    if (!rawText) return
+    // 不再把子节点数量拼进节点文本,改为画布浮层角标
+    lf.updateText(nodeId, rawText)
+  }
+
   function loadNodeProps(data: any) {
     const props = data.properties || getDefaultProps(data.type)
     const cloned = JSON.parse(JSON.stringify(props))
@@ -182,6 +221,19 @@ 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 || '',
+            assigneeType: s.assigneeType ?? 'ROLE',
+            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 ?? ''
     }
@@ -309,6 +361,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
         },
       } as any,
     })
+    lfRef.value = lf
 
     registerNodes()
 
@@ -347,6 +400,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 +414,17 @@ 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
+      syncNodeTextWithSubNodeBadge(node.id)
+    }
+  }
   const typeToBackend: Record<string, string> = {
     'start-node': 'start',
     'approval-node': 'approval',
@@ -390,6 +452,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
@@ -457,9 +521,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 {
@@ -478,7 +540,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
@@ -503,7 +565,10 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
 
   function updateNodeName() {
     if (!lf || !selectedNode.value) return
-    lf.setNodeText(selectedNode.value.id, selectedNode.value.text?.value || '')
+    const text = selectedNode.value.text
+    const value = typeof text === 'object' && text !== null ? text.value || '' : text || ''
+    // 使用 LogicFlow 公开 API updateText 更新节点文本
+    lf.updateText(selectedNode.value.id, value)
   }
 
   // 保存流程
@@ -605,9 +670,23 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
         const props = node.properties || {}
         const assigneeType = props.assigneeType ?? 'ROLE'
         const assigneeValue = props.assigneeValue || props.approver
-        if (assigneeType === 'ROLE' && !assigneeValue) {
+        if (assigneeType === 'ROLE' && !assigneeValue && props.subNodesEnabled !== true) {
           return `审批节点 "${node.text || node.name}" 未选择审批角色`
         }
+        if (props.subNodesEnabled === true) {
+          const subNodes = props.subNodes || []
+          if (subNodes.length === 0) {
+            return `审批节点 "${node.text || node.name}" 已启用子节点,请至少配置一个子节点`
+          }
+          for (let i = 0; i < subNodes.length; i++) {
+            const sub = subNodes[i]
+            const subAssigneeType = sub.assigneeType ?? 'ROLE'
+            const subAssigneeValue = sub.assigneeValue
+            if (subAssigneeType === 'ROLE' && !subAssigneeValue) {
+              return `审批节点 "${node.text || node.name}" 的子节点 ${i + 1} 未选择审批角色`
+            }
+          }
+        }
       }
     }
     // 检查孤立节点
@@ -663,6 +742,8 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
   async function handleSave() {
     if (!lf) return
     saveCurrentNodeProps()
+    // 先刷新属性面板中可能未失焦的节点名称修改,再读取画布数据
+    updateNodeName()
     const rawGraphData = lf.getGraphData()
     const error = validateFlow(rawGraphData)
     if (error) {
@@ -696,6 +777,8 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
   async function submitSave() {
     if (!lf) return
     saveCurrentNodeProps()
+    // 先刷新属性面板中可能未失焦的节点名称修改,再读取画布数据
+    updateNodeName()
     const rawGraphData = lf.getGraphData()
     const error = validateFlow(rawGraphData)
     if (error) {
@@ -753,6 +836,28 @@ 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 selectNodeById(nodeId: string) {
+    if (!lf) return
+    try {
+      const nodeData = lf.getNodeData(nodeId)
+      if (!nodeData) return
+      lf.selectElementById(nodeId)
+      saveCurrentNodeProps()
+      selectedNode.value = nodeData
+      loadNodeProps(nodeData)
+      loadOutgoingEdges(nodeData.id)
+    } catch {
+      // ignore
+    }
+  }
+
   function handleClear() {
     if (!lf) return
     lf.clearData()
@@ -808,6 +913,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     outgoingEdges,
     roleList,
     nodeProps,
+    lfRef,
     // property panel helpers
     loadOutgoingEdges,
     getEdgeTargetName,
@@ -816,6 +922,9 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     saveCurrentNodeProps,
     loadNodeProps,
     updateNodeName,
+    generateSubNodeId,
+    deleteSelectedNode,
+    selectNodeById,
     // save dialog
     saveDialogVisible,
     saveForm,
@@ -842,9 +951,5 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     goBack,
     // drag/drop
     handleDrop,
-    // conversion
-    convertToBackendFormat,
-    convertToFrontendFormat,
-    validateFlow,
   }
 }

+ 109 - 0
src/views/flow/execute/FlowTimeline.vue

@@ -0,0 +1,109 @@
+<template>
+  <div class="flow-timeline">
+    <!-- 线性展示:所有审批节点按流程拓扑顺序依次排列(分支节点同样顺序展示) -->
+    <div v-for="node in linearNodes" :key="node.id" class="flow-main-row">
+      <NodeCard
+        :node="node"
+        :is-expanded="isNodeExpanded(node.id)"
+        @toggle-node="toggleNodeExpand"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { buildFlowLayout, type FlowLayoutNode } from '@/utils/flowLayout'
+import type { ProcessProgress } from '@/types/flow'
+import NodeCard from './NodeCard.vue'
+
+const props = defineProps<{
+  progress: ProcessProgress | null
+}>()
+
+// 用布局算法解析状态,但输出"线性平铺"的节点列表(分支节点按顺序列出,不分组展示)
+const linearNodes = computed<FlowLayoutNode[]>(() => {
+  const def = props.progress?.definition
+  if (!def?.flowJson) return []
+  try {
+    const model = JSON.parse(def.flowJson)
+    const rows = buildFlowLayout(
+      model.nodes || [],
+      model.edges || [],
+      props.progress?.nodes || [],
+      props.progress?.records || [],
+      props.progress?.instance?.currentNode
+    )
+    const nodes: FlowLayoutNode[] = []
+    for (const row of rows) {
+      if (row.kind === 'start' || row.kind === 'end') continue // 线性视图不展示起止标记
+      if (row.kind === 'branch') {
+        // 分支行:按分支顺序平铺其中的节点
+        for (const br of row.branches || []) nodes.push(...br.nodes)
+      } else {
+        nodes.push(...(row.nodes || []))
+      }
+    }
+    return nodes
+  } catch {
+    return []
+  }
+})
+
+// 展开/收起(子节点)
+const expandedSet = ref<Set<string>>(new Set())
+function isNodeExpanded(id: string): boolean {
+  return expandedSet.value.has(id)
+}
+function toggleNodeExpand(id: string) {
+  const next = new Set(expandedSet.value)
+  if (next.has(id)) next.delete(id)
+  else next.add(id)
+  expandedSet.value = next
+}
+
+// 数据变化后自动展开"进行中"的节点及子节点
+watch(
+  () => props.progress,
+  () => {
+    if (!props.progress?.nodes) return
+    const next = new Set<string>()
+    for (const node of props.progress.nodes) {
+      if (node.subNodes?.length) {
+        const hasCurrent = node.status === 'current' || node.subNodes.some((s) => s.status === 'current')
+        if (hasCurrent) next.add(node.nodeId)
+      }
+    }
+    expandedSet.value = next
+    setTimeout(() => {
+      document
+        .querySelectorAll('.flow-timeline .sub-node-item.current-sub-node')
+        .forEach((el) => el.scrollIntoView({ behavior: 'smooth', block: 'center' }))
+    }, 60)
+  },
+  { immediate: true }
+)
+</script>
+
+<style scoped>
+.flow-timeline {
+  position: relative;
+  padding-left: 18px;
+}
+.flow-timeline::before {
+  content: '';
+  position: absolute;
+  left: 7px;
+  top: 8px;
+  bottom: 8px;
+  width: 2px;
+  background: #e4e7ed;
+}
+
+/* 线性主流程行 */
+.flow-main-row {
+  position: relative;
+  padding: 4px 0;
+  z-index: 1;
+}
+</style>

+ 31 - 155
src/views/flow/execute/InstanceDetail.vue

@@ -82,54 +82,10 @@
       <el-divider />
 
       <div class="detail-body">
-        <!-- 左侧:流程节点时间线 -->
+        <!-- 左侧:流程进度(线性主线 + 分支路线) -->
         <div class="timeline-section">
           <h4>流程进度</h4>
-          <el-timeline>
-            <el-timeline-item
-              v-for="node in progress?.nodes"
-              :key="node.nodeId"
-              :type="timelineType(node.status)"
-              :icon="timelineIcon(node.status)"
-              :color="timelineColor(node.status)"
-            >
-              <div class="node-item" :class="{ 'current-node': node.status === 'current' }">
-                <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>
-                </div>
-                <!-- 任务分配信息 -->
-                <div v-if="node.tasks?.length" class="node-tasks">
-                  <div
-                    v-for="task in node.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 v-if="getNodeRecords(node.nodeId).length" class="node-records">
-                  <div
-                    v-for="record in getNodeRecords(node.nodeId)"
-                    :key="record.id"
-                    class="record-item"
-                  >
-                    <span class="record-operator">{{ record.operatorName || '用户' + record.operatorId }}</span>
-                    <el-tag :type="recordActionType(record.actionResult)" size="small">
-                      {{ recordActionText(record.actionResult) }}
-                    </el-tag>
-                    <span v-if="record.comment" class="record-comment">{{ record.comment }}</span>
-                    <span class="record-time">{{ record.createTime }}</span>
-                  </div>
-                </div>
-              </div>
-            </el-timeline-item>
-          </el-timeline>
+          <FlowTimeline :key="timelineVersion" :progress="progress" />
         </div>
 
         <!-- 右侧:审批操作区 -->
@@ -217,13 +173,14 @@ import { approveTask, rejectTask, returnTask, addSignTask, getTransferableUsers,
 import type { NextNode } from '@/types/flow'
 import { beforeFileUpload, getFileUrl, getFileName, parseAttachments, collectAttachmentUrls } from '@/utils/file'
 import { useFileUpload } from '@/composables/useFileUpload'
-import { instanceStatusText, instanceStatusTagType, taskStatusText, taskStatusType, recordActionText, recordActionType } from '@/utils/flow'
+import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
 import FilePreview from '@/components/FilePreview/index.vue'
 import FormDataDisplay from '@/components/FormDataDisplay/index.vue'
 import FlowFormFields from '@/components/FlowFormFields/index.vue'
 import type { ProcessProgress, FlowTask, ApprovalAction, Attachment } from '@/types/flow'
 import type { User } from '@/types/system'
-import { CircleCheck, Clock, Document } from '@element-plus/icons-vue'
+import { Document } from '@element-plus/icons-vue'
+import FlowTimeline from './FlowTimeline.vue'
 
 const props = defineProps<{
   modelValue: boolean
@@ -242,6 +199,8 @@ const visible = computed({
 
 const loading = ref(false)
 const progress = ref<ProcessProgress | null>(null)
+// 每次加载进度成功后自增,用于强制重建流程进度组件,保证审批后进度刷新
+const timelineVersion = ref(0)
 
 type ApprovalActionType = 'pass' | 'reject' | 'rollback'
 
@@ -319,33 +278,22 @@ 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
 })
 
-function timelineType(status: string) {
-  if (status === 'completed') return 'success'
-  if (status === 'current') return 'warning'
-  return 'info'
-}
-
-function timelineIcon(status: string) {
-  if (status === 'completed') return CircleCheck
-  if (status === 'current') return Clock
-  return undefined
-}
-
-function getNodeRecords(nodeId: string) {
-  if (!progress.value?.records) return []
-  return progress.value.records.filter(r => r.nodeId === nodeId)
-}
-
-function timelineColor(status: string) {
-  if (status === 'completed') return '#67C23A'
-  if (status === 'current') return '#E6A23C'
-  return '#909399'
-}
-
 async function loadProgress() {
   if (!props.instanceId) return
   loading.value = true
@@ -353,6 +301,8 @@ async function loadProgress() {
     // 静默加载进度,无权限时不在详情页弹全局错误提示
     const res = await getProgress(props.instanceId, { silent: true })
     progress.value = res
+    // 强制重建进度组件,确保审批操作后进度立即刷新
+    timelineVersion.value++
   } catch {
     progress.value = null
   } finally {
@@ -494,21 +444,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()
   }
@@ -578,80 +528,6 @@ watch(() => props.modelValue, async (val) => {
   font-size: 15px;
   color: #303133;
 }
-.node-item {
-  padding: 4px 0;
-}
-.node-item.current-node {
-  background: #fdf6ec;
-  padding: 8px;
-  border-radius: 4px;
-}
-.node-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 6px;
-}
-.node-name {
-  font-weight: bold;
-  color: #303133;
-}
-.node-tasks {
-  padding-left: 8px;
-}
-.task-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 13px;
-  color: #606266;
-  margin: 4px 0;
-}
-.task-comment {
-  color: #909399;
-}
-.record-list {
-  max-height: 300px;
-  overflow-y: auto;
-}
-.node-records {
-  margin-top: 8px;
-  padding-left: 12px;
-  border-left: 2px solid #e4e7ed;
-}
-.record-item {
-  padding: 6px 0;
-}
-.record-item:last-child {
-  border-bottom: none;
-}
-.record-header {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  margin-bottom: 6px;
-}
-.record-operator {
-  font-weight: bold;
-  color: #303133;
-}
-.record-time {
-  font-size: 12px;
-  color: #909399;
-}
-.record-comment {
-  font-size: 13px;
-  color: #606266;
-  background: #f5f7fa;
-  padding: 6px 10px;
-  border-radius: 4px;
-  margin-bottom: 6px;
-}
-.record-attachments {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-}
 .section-title {
   display: flex;
   justify-content: space-between;

+ 333 - 0
src/views/flow/execute/NodeCard.vue

@@ -0,0 +1,333 @@
+<template>
+  <div
+    class="node-card"
+    :class="[nodeClass, node.type === 'cc' ? 'node-cc' : '']"
+  >
+    <div class="node-card-head">
+      <span class="node-card-icon" :style="{ background: iconBg }">
+        <el-icon><component :is="icon" /></el-icon>
+      </span>
+      <span class="node-card-name">{{ node.name }}</span>
+      <template v-if="node.status !== 'neutral'">
+        <span class="status-dot" :class="node.status" />
+        <span class="status-text" :class="node.status">{{ statusText }}</span>
+      </template>
+      <span v-if="node.isMyTurn" class="my-turn-tag">待我处理</span>
+    </div>
+
+    <div v-if="node.type === 'cc'" class="cc-summary">抄送 · 已送达</div>
+
+    <!-- 子节点列表 -->
+    <template v-if="hasSubNodes">
+      <div v-if="isExpanded" class="sub-node-list">
+        <div
+          v-for="(sub, si) in node.nodeProgress.subNodes"
+          :key="sub.subNodeId"
+          class="sub-node-item"
+          :class="{ 'current-sub-node': sub.status === 'current' }"
+        >
+          <div class="sub-node-title">
+            <span class="sub-node-index">{{ si + 1 }}</span>
+            <span class="sub-node-name">{{ sub.subNodeName }}</span>
+            <span class="status-text" :class="sub.status">{{ subStatusText(sub) }}</span>
+            <span v-if="sub.isMyTurn" class="my-turn-tag">待我处理</span>
+          </div>
+          <div v-if="sub.tasks?.length" class="node-card-tasks">
+            <div v-for="t in sub.tasks" :key="t.id" class="task-item">
+              <span class="task-assignee">{{ t.assigneeName || '用户' + t.assigneeId }}</span>
+              <span class="task-status" :class="taskStatusType(t.status)">{{ taskStatusText(t.status) }}</span>
+              <span v-if="t.comment" class="task-comment">{{ t.comment }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="node-expand-btn" @click="emit('toggle-node', node.id)">
+        {{ isExpanded ? '收起' : '展开' }} 子节点 ({{ node.nodeProgress.subNodes.length }})
+      </div>
+    </template>
+
+    <!-- 任务分配信息(非子节点节点) -->
+    <div v-if="!hasSubNodes && node.nodeProgress?.tasks?.length" class="node-card-tasks">
+      <div v-for="t in node.nodeProgress.tasks" :key="t.id" class="task-item">
+        <span class="task-assignee">{{ t.assigneeName || '用户' + t.assigneeId }}</span>
+        <span class="task-status" :class="taskStatusType(t.status)">{{ taskStatusText(t.status) }}</span>
+        <span v-if="t.comment" class="task-comment">{{ t.comment }}</span>
+      </div>
+    </div>
+
+    <!-- 审批记录 -->
+    <div v-if="nodeRecords.length" class="node-card-records">
+      <div v-for="r in nodeRecords" :key="r.id" class="record-item">
+        <span class="record-operator">{{ r.operatorName || '用户' + r.operatorId }}</span>
+        <span class="record-action" :class="recordActionType(r.actionResult)">{{ recordActionText(r.actionResult) }}</span>
+        <span v-if="r.comment" class="record-comment">{{ r.comment }}</span>
+        <span v-if="r.createTime" class="record-time">{{ r.createTime }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { CircleCheck, Clock, VideoPlay, User, Message } from '@element-plus/icons-vue'
+import { taskStatusText, taskStatusType, recordActionText, recordActionType } from '@/utils/flow'
+import type { FlowLayoutNode } from '@/utils/flowLayout'
+
+const props = defineProps<{
+  node: FlowLayoutNode
+  isExpanded: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'toggle-node', id: string): void
+}>()
+
+const isEndpoint = computed(() => props.node.type === 'start' || props.node.type === 'end')
+const isStart = computed(() => props.node.type === 'start')
+const isEnd = computed(() => props.node.type === 'end')
+
+const icon = computed(() => {
+  if (props.node.type === 'start') return VideoPlay
+  if (props.node.type === 'end') return CircleCheck
+  if (props.node.type === 'cc') return Message
+  return User
+})
+
+const iconBg = computed(() => {
+  if (isStart.value) return '#67C23A'
+  if (isEnd.value) return '#F56C6C'
+  if (props.node.type === 'cc') return '#E6A23C'
+  return '#409EFF'
+})
+
+const statusText = computed(() => {
+  switch (props.node.status) {
+    case 'completed':
+      return '已完成'
+    case 'current':
+      return '进行中'
+    case 'passed':
+      return '已走过'
+    case 'pending':
+      return '待处理'
+    default:
+      return ''
+  }
+})
+
+const nodeClass = computed(() => props.node.status)
+
+const hasSubNodes = computed(() => !!props.node.nodeProgress?.subNodes?.length)
+const nodeRecords = computed(() => props.node.records || [])
+
+function subStatusText(sub: { status: string }): string {
+  if (sub.status === 'completed') return '已完成'
+  if (sub.status === 'current') return '进行中'
+  return '待处理'
+}
+</script>
+
+<style scoped>
+.node-card {
+  background: #fff;
+  border: 1px solid #ebeef5;
+  border-left: 4px solid #909399;
+  border-radius: 6px;
+  padding: 8px 10px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+  transition: box-shadow 0.2s;
+}
+.node-card:hover {
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+.node-card.completed {
+  border-left-color: #67c23a;
+}
+.node-card.current {
+  border-left-color: #e6a23c;
+  background: #fdf6ec;
+}
+.node-card.pending {
+  border-left-color: #c0c4cc;
+}
+.node-card.passed {
+  border-left-color: #67c23a;
+  background: #f0f9eb;
+}
+.node-card.neutral {
+  border-left-color: #909399;
+}
+.node-card-head {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+.node-card-icon {
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  font-size: 12px;
+  flex-shrink: 0;
+}
+.node-card-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: #303133;
+  flex: 1;
+  min-width: 0;
+}
+.node-branch-tag {
+  font-size: 11px;
+  color: #909399;
+  background: #f4f4f5;
+  padding: 0 6px;
+  border-radius: 3px;
+}
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.status-dot.completed {
+  background: #67c23a;
+}
+.status-dot.current {
+  background: #e6a23c;
+}
+.status-dot.pending {
+  background: #c0c4cc;
+}
+.status-dot.passed {
+  background: #67c23a;
+}
+.status-text {
+  font-size: 12px;
+}
+.status-text.completed {
+  color: #67c23a;
+}
+.status-text.current {
+  color: #e6a23c;
+}
+.status-text.passed {
+  color: #67c23a;
+}
+.status-text.pending {
+  color: #909399;
+}
+.my-turn-tag {
+  font-size: 11px;
+  color: #fff;
+  background: #f56c6c;
+  border-radius: 3px;
+  padding: 0 6px;
+  line-height: 18px;
+}
+.cc-summary {
+  margin-top: 4px;
+  font-size: 12px;
+  color: #909399;
+}
+.node-card-tasks {
+  margin-top: 6px;
+  padding-left: 4px;
+}
+.task-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #606266;
+  padding: 2px 0;
+}
+.task-assignee {
+  font-weight: 600;
+}
+.task-status {
+  font-size: 11px;
+}
+.task-comment {
+  color: #909399;
+}
+.node-card-records {
+  margin-top: 6px;
+  padding-left: 4px;
+  border-top: 1px dashed #ebeef5;
+}
+.record-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #606266;
+  padding: 3px 0;
+  flex-wrap: wrap;
+}
+.record-operator {
+  font-weight: 600;
+}
+.record-action {
+  font-size: 11px;
+}
+.record-comment {
+  color: #909399;
+}
+.record-time {
+  color: #c0c4cc;
+  margin-left: auto;
+}
+.sub-node-list {
+  margin-top: 8px;
+  padding-left: 6px;
+  border-left: 2px solid #e4e7ed;
+}
+.sub-node-item {
+  padding: 6px 8px;
+  margin-bottom: 6px;
+  background: #fafafa;
+  border-radius: 4px;
+}
+.sub-node-item.current-sub-node {
+  background: #fdf6ec;
+  border: 1px solid #f5dab1;
+}
+.sub-node-title {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+.sub-node-index {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  background: #409eff;
+  color: #fff;
+  font-size: 10px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.sub-node-name {
+  font-size: 12px;
+  font-weight: 600;
+  color: #303133;
+}
+.node-expand-btn {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #409eff;
+  cursor: pointer;
+  user-select: none;
+}
+.node-expand-btn:hover {
+  color: #79bbff;
+}
+</style>

+ 3 - 3
src/views/flow/instance/mine.vue

@@ -71,7 +71,7 @@
         <el-table-column label="操作" width="200">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleDetail(row)">查看详情</el-button>
-            <el-button v-if="row.status === 0 || row.status === 1 || row.status === 4" link type="warning" @click="handleRevoke(row)">撤回</el-button>
+            <el-button v-if="row.status === 0 || row.status === 1" link type="warning" @click="handleRevoke(row)">撤回</el-button>
             <el-button v-if="row.status === 6" link type="danger" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
@@ -205,7 +205,7 @@ async function handleDelete(row: FlowInstance) {
 function getRevocableIds(): number[] {
   return selectedIds.value.filter(id => {
     const row = tableData.value.find(r => r.id === id)
-    return row && (row.status === 0 || row.status === 1 || row.status === 4)
+    return row && (row.status === 0 || row.status === 1)
   })
 }
 
@@ -219,7 +219,7 @@ function getDeletableIds(): number[] {
 async function handleBatchRevoke() {
   const ids = getRevocableIds()
   if (ids.length === 0) {
-    ElMessage.warning('请选择可撤回的流程(运行中或已回退)')
+    ElMessage.warning('请选择可撤回的流程(待接收或运行中)')
     return
   }
   try {

+ 5 - 1
src/views/flow/task/handled.vue

@@ -20,7 +20,11 @@
       <el-table v-loading="loading" :data="tableData" border>
         <el-table-column type="index" label="序号" width="60" />
         <el-table-column prop="definitionName" label="流程名称" />
-        <el-table-column prop="nodeName" label="处理节点" />
+        <el-table-column label="处理节点" show-overflow-tooltip>
+          <template #default="{ row }">
+            {{ row.subNodeName ? row.nodeName + ' / ' + row.subNodeName : row.nodeName }}
+          </template>
+        </el-table-column>
         <el-table-column prop="action" label="操作结果" width="100">
           <template #default="{ row }">
             <el-tag :type="actionTagType(row.action)">

+ 4 - 2
src/views/flow/task/todo.vue

@@ -82,9 +82,11 @@
             </template>
           </el-table-column>
 
-          <el-table-column label="当前节点" width="140" show-overflow-tooltip>
+          <el-table-column label="当前节点" width="160" show-overflow-tooltip>
             <template #default="{ row }">
-              <el-tag type="warning" size="small"><strong>{{ row.nodeName }}</strong></el-tag>
+              <el-tag type="warning" size="small">
+                <strong>{{ row.subNodeName ? row.nodeName + ' / ' + row.subNodeName : row.nodeName }}</strong>
+              </el-tag>
             </template>
           </el-table-column>
 

+ 130 - 0
tests/unit/flowLayout.test.ts

@@ -0,0 +1,130 @@
+import { describe, it, expect } from 'vitest'
+import { buildFlowLayout } from '@/utils/flowLayout'
+
+const diamond = {
+  nodes: [
+    { id: 'start', type: 'start' },
+    { id: 'A', type: 'approval', name: '审批A' },
+    { id: 'cond', type: 'condition', name: '条件' },
+    { id: 'B1', type: 'approval', name: '大额审批' },
+    { id: 'B2', type: 'approval', name: '默认审批' },
+    { id: 'C', type: 'approval', name: '审批C' },
+    { id: 'end', type: 'end' },
+  ],
+  edges: [
+    { sourceNodeId: 'start', targetNodeId: 'A' },
+    { sourceNodeId: 'A', targetNodeId: 'cond' },
+    { sourceNodeId: 'cond', targetNodeId: 'B1', condition: { condition: 'amount>=1000', isDefault: false } },
+    { sourceNodeId: 'cond', targetNodeId: 'B2', condition: { condition: '', isDefault: true } },
+    { sourceNodeId: 'B1', targetNodeId: 'C' },
+    { sourceNodeId: 'B2', targetNodeId: 'C' },
+    { sourceNodeId: 'C', targetNodeId: 'end' },
+  ],
+}
+
+const allIds = (rows: any[]) =>
+  rows.flatMap((r) => {
+    const main = (r.nodes || []).map((n: any) => n.id)
+    const branch = (r.branches || []).flatMap((b: any) => b.nodes.map((n: any) => n.id))
+    return main.concat(branch).concat(r.kind === 'branch' ? [`${r.title}`] : [])
+  })
+
+describe('buildFlowLayout', () => {
+  it('线性流程全部展示且不重复', () => {
+    const linear = {
+      nodes: [
+        { id: 'start', type: 'start' },
+        { id: 'A', type: 'approval', name: 'A' },
+        { id: 'B', type: 'approval', name: 'B' },
+        { id: 'end', type: 'end' },
+      ],
+      edges: [
+        { sourceNodeId: 'start', targetNodeId: 'A' },
+        { sourceNodeId: 'A', targetNodeId: 'B' },
+        { sourceNodeId: 'B', targetNodeId: 'end' },
+      ],
+    }
+    const rows = buildFlowLayout(linear.nodes, linear.edges, [
+      { nodeId: 'A', status: 'completed' },
+      { nodeId: 'B', status: 'current' },
+    ], [])
+    const ids = allIds(rows)
+    expect(ids.filter((x) => x === 'A')).toHaveLength(1)
+    expect(ids.filter((x) => x === 'B')).toHaveLength(1)
+    expect(rows.filter((r) => r.kind === 'branch')).toHaveLength(0)
+  })
+
+  it('菱形分支:两条分支都展示,后续节点也展示,无重复', () => {
+    const rows = buildFlowLayout(diamond.nodes, diamond.edges, [
+      { nodeId: 'A', status: 'completed' },
+      { nodeId: 'B1', status: 'completed' },
+      { nodeId: 'C', status: 'current' },
+    ], [{ nodeId: 'B1' }], 'C')
+    const branchRow = rows.find((r) => r.kind === 'branch')
+    expect(branchRow).toBeTruthy()
+    expect(branchRow.branches).toHaveLength(2)
+    expect(branchRow.branches[0].nodes[0].id).toBe('B1')
+    expect(branchRow.branches[1].nodes[0].id).toBe('B2')
+    // 后续节点 C、end 在主线上
+    expect(rows.some((r) => r.kind === 'node' && r.nodes?.[0]?.id === 'C')).toBe(true)
+    expect(rows.some((r) => r.kind === 'end')).toBe(true)
+    // 无重复
+    const ids = allIds(rows)
+    for (const id of ['A', 'B1', 'B2', 'C']) {
+      expect(ids.filter((x) => x === id)).toHaveLength(1)
+    }
+    // 已选择分支标记
+    expect(branchRow.branches[0].isChosen).toBe(true)
+    expect(branchRow.branches[1].isChosen).toBe(false)
+  })
+
+  it('直连合并点:默认分支直连下一节点,后续仍展示', () => {
+    const direct = {
+      nodes: [
+        { id: 'start', type: 'start' },
+        { id: 'A', type: 'approval', name: 'A' },
+        { id: 'cond', type: 'condition', name: '条件' },
+        { id: 'B1', type: 'approval', name: '大额' },
+        { id: 'C', type: 'approval', name: 'C' },
+        { id: 'end', type: 'end' },
+      ],
+      edges: [
+        { sourceNodeId: 'start', targetNodeId: 'A' },
+        { sourceNodeId: 'A', targetNodeId: 'cond' },
+        { sourceNodeId: 'cond', targetNodeId: 'B1', condition: { condition: 'x', isDefault: false } },
+        { sourceNodeId: 'cond', targetNodeId: 'C', condition: { condition: '', isDefault: true } },
+        { sourceNodeId: 'B1', targetNodeId: 'C' },
+        { sourceNodeId: 'C', targetNodeId: 'end' },
+      ],
+    }
+    const rows = buildFlowLayout(direct.nodes, direct.edges, [
+      { nodeId: 'A', status: 'completed' },
+      { nodeId: 'C', status: 'current' },
+    ], [], 'C')
+    const branchRow = rows.find((r) => r.kind === 'branch')
+    expect(branchRow).toBeTruthy()
+    expect(branchRow.branches).toHaveLength(2)
+    // C 在分支后主线上出现一次
+    expect(rows.filter((r) => r.nodes?.some((n: any) => n.id === 'C'))).toHaveLength(1)
+    expect(rows.some((r) => r.kind === 'end')).toBe(true)
+  })
+
+  it('导入型:前面主线节点标记为已走过(passed)', () => {
+    const rows = buildFlowLayout(diamond.nodes, diamond.edges, [
+      { nodeId: 'C', status: 'current' },
+    ], [], 'C')
+    const aRow = rows.find((r) => r.nodes?.[0]?.id === 'A')
+    expect(aRow?.nodes?.[0]?.status).toBe('passed')
+  })
+
+  it('分支内节点状态:未选择分支为 pending', () => {
+    const rows = buildFlowLayout(diamond.nodes, diamond.edges, [
+      { nodeId: 'A', status: 'completed' },
+      { nodeId: 'B1', status: 'completed' },
+      { nodeId: 'C', status: 'current' },
+    ], [], 'C')
+    const branchRow = rows.find((r) => r.kind === 'branch')
+    expect(branchRow.branches[0].nodes[0].status).toBe('completed')
+    expect(branchRow.branches[1].nodes[0].status).toBe('pending')
+  })
+})