Selaa lähdekoodia

流程进度展示重做

ye-zhaojia 2 viikkoa sitten
vanhempi
commit
dcf2f1d658

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

+ 128 - 72
src/utils/flowLayout.ts

@@ -1,37 +1,37 @@
 /**
  * 流程进度布局工具
  *
- * 后端进度接口(/flow/instance/{id}/progress)返回的是按 BFS 拓扑排序后的"拍平"节点列表,
- * 分支(条件节点)会全部排在一条线上,不易看出分支结构。
- *
- * 这里根据流程定义的 modelJson(nodes + edges)重建真实拓扑,
- * 将流程组织为若干"行"(FlowRow):
+ * 根据流程定义的 modelJson(nodes + edges)重建真实拓扑,将流程组织为若干"行"(FlowRow):
  *   - start / end:起点/终点标记
- *   - node / cc:主流程上的单个节点
- *   - branch:条件分支区(含多个分支列,分支节点并行展示)
- * 分支区之后若各分支汇聚到同一节点,则继续主流程。
+ *   - 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' | 'pending' | 'neutral'
+  status: 'completed' | 'current' | 'passed' | 'pending' | 'neutral'
   isMyTurn: boolean
   /** 审批节点的完整进度数据(tasks / subNodes 等) */
   nodeProgress?: any
   /** 该节点下的审批记录 */
   records: any[]
-  /** 所在分支的名称(分支节点内展示) */
-  branchLabel?: string
-  conditionText?: string
-  isDefault?: boolean
 }
 
 export interface FlowBranch {
   label: string
   condition: string
   isDefault: boolean
+  /** 该分支是否被实际选择(分支内存在已处理任务/记录,或当前节点位于该分支) */
+  isChosen: boolean
   nodes: FlowLayoutNode[]
 }
 
@@ -44,7 +44,7 @@ export interface FlowRow {
   branches?: FlowBranch[]
 }
 
-const MAX_DEPTH = 200
+const MAX_DEPTH = 300
 
 /**
  * 根据流程模型与进度数据构建布局行
@@ -52,12 +52,14 @@ const MAX_DEPTH = 200
  * @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[]
+  records: any[],
+  currentNodeId?: string | null
 ): FlowRow[] {
   if (!Array.isArray(modelNodes) || modelNodes.length === 0) return []
 
@@ -88,111 +90,154 @@ export function buildFlowLayout(
     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 endNode = modelNodes.find((n) => n.type === 'end')
+  const endNodes = modelNodes.filter((n) => n.type === 'end')
   const startId = startNode?.id
-  const endId = endNode?.id
 
-  const mkNode = (n: any, branchMeta?: { branchName?: string; condition?: string; isDefault?: boolean }): FlowLayoutNode => {
+  /** 节点是否有实际活动(任务或记录) */
+  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)
-    let status: FlowLayoutNode['status'] = 'neutral'
-    let isMyTurn = false
-    if (p) {
-      status = p.status
-      isMyTurn = !!p.isMyTurn
-    } else if (n.type === 'cc') {
-      // 抄送节点自动推进,视为已完成
-      status = 'completed'
-    }
     return {
       id: n.id,
       name: n.name || n.id,
       type: n.type,
-      status,
-      isMyTurn,
+      status: status ?? (n.type === 'start' || n.type === 'end' || n.type === 'condition' ? 'neutral' : 'pending'),
+      isMyTurn: !!p?.isMyTurn,
       nodeProgress: p,
       records: recordsMap.get(n.id) || [],
-      branchLabel: branchMeta?.branchName,
-      conditionText: branchMeta?.condition,
-      isDefault: branchMeta?.isDefault,
     }
   }
 
+  /** 主线节点状态:优先进度,其次"祖先=已走过",否则待处理 */
+  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>()
-  let curId: string | undefined = startId
 
-  // 起点
   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' || curId === endId) {
-        rows.push({ kind: 'end', nodes: [mkNode(cur)] })
-      } else {
-        // 游离节点(不应出现),兜底展示
-        rows.push({ kind: 'node', nodes: [mkNode(cur)] })
-      }
+      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 branches: FlowBranch[] = []
+      // 第一遍:收集每条分支的节点链(原始模型节点,不含汇聚点/终点)
+      const rawChains: { edge: any; nodes: any[]; merge: string | null }[] = []
       let mergeId: string | null = null
       for (const e of outs) {
-        const cond = e.condition || {}
-        const branchName = cond.branchName || ''
-        const condition = cond.condition || ''
-        const isDefault = !!cond.isDefault
-        const chain: FlowLayoutNode[] = []
+        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 && chain.length > 0)) {
+          if (bn.type === 'end' || bin > 1) {
             branchMerge = bCurId
             break
           }
-          // 分支标签已展示在分支列头部,节点卡片上不再重复
-          chain.push(mkNode(bn))
+          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
-            }
+            if (bNext.length === 1 && nextIn > 1) branchMerge = bNext[0].targetNodeId
             break
           }
           bCurId = bNext[0].targetNodeId
         }
-        branches.push({
-          label: branchName || condition || (isDefault ? '默认分支' : '分支'),
-          condition,
-          isDefault,
-          nodes: chain,
-        })
+        rawChains.push({ edge: e, nodes: chainNodes, merge: branchMerge })
         if (branchMerge) {
           if (mergeId === null) mergeId = branchMerge
-          else if (mergeId !== branchMerge) mergeId = null // 各分支汇聚点不一致
+          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)) {
+      // 所有分支汇聚到同一非终点节点则继续主线;否则流程已在分支内结束
+      if (mergeId && nodeMap.has(mergeId) && mergeId !== endNodes[0]?.id) {
         curId = mergeId
       } else {
-        curId = undefined // 分支后流程结束(各分支分别结束)
+        curId = undefined
       }
       continue
     }
@@ -200,20 +245,31 @@ export function buildFlowLayout(
     // 单出边 → 正常推进
     const nextId = outs[0].targetNodeId
     if (cur.type !== 'start') {
-      if (cur.type === 'cc') {
-        rows.push({ kind: 'cc', nodes: [mkNode(cur)] })
-      } else {
-        rows.push({ kind: 'node', nodes: [mkNode(cur)] })
-      }
+      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
   }
 
-  // 结束节点兜底:若未在遍历中输出,则补在末尾
-  if (endId && nodeMap.has(endId) && !rows.some((r) => r.kind === 'end' && r.nodes?.[0]?.id === endId)) {
-    rows.push({ kind: 'end', nodes: [mkNode(nodeMap.get(endId))] })
+  // 兜底:主遍历未覆盖到的节点追加到末尾,保证所有节点都可见
+  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
 }

+ 23 - 5
src/views/flow/execute/FlowTimeline.vue

@@ -14,12 +14,13 @@
           <span>{{ row.title || '条件分支' }}</span>
         </div>
         <div class="branch-routes">
-          <div v-for="(br, bi) in row.branches" :key="bi" class="branch-route">
+          <div v-for="(br, bi) in row.branches" :key="bi" class="branch-route" :class="{ chosen: br.isChosen }">
             <div class="branch-label">
               <span class="branch-mark">{{ bi === 0 ? '├─' : '└─' }}</span>
               <span class="branch-name" :class="{ default: br.isDefault }">{{ br.label }}</span>
               <span v-if="br.condition" class="branch-cond">{{ br.condition }}</span>
               <el-tag v-if="br.isDefault" size="small" type="info" effect="plain">默认</el-tag>
+              <el-tag v-if="br.isChosen" size="small" type="success" effect="light">✓ 已选择</el-tag>
             </div>
             <div class="branch-nodes">
               <template v-if="br.nodes.length">
@@ -36,11 +37,11 @@
                   <span class="chip-expand">{{ branchDetailId === n.id ? '收起' : '详情' }}</span>
                 </div>
               </template>
-              <span v-else class="branch-empty">无审批节点</span>
+              <span v-else class="branch-empty">直连下一节点</span>
             </div>
 
-            <!-- 分支节点详情(任务 + 审批记录) -->
-            <div v-if="branchDetailNode" class="branch-detail">
+            <!-- 分支节点详情(任务 + 审批记录),仅在该节点所属的分支内展示 -->
+            <div v-if="branchDetailNode && br.nodes.some(n => n.id === branchDetailId)" class="branch-detail">
               <template v-if="branchDetailNode.nodeProgress?.tasks?.length">
                 <div class="detail-title">任务</div>
                 <div v-for="t in branchDetailNode.nodeProgress.tasks" :key="t.id" class="task-item">
@@ -98,7 +99,8 @@ const rows = computed<FlowRow[]>(() => {
       model.nodes || [],
       model.edges || [],
       props.progress?.nodes || [],
-      props.progress?.records || []
+      props.progress?.records || [],
+      props.progress?.instance?.currentNode
     )
   } catch {
     return []
@@ -246,6 +248,14 @@ watch(
 .branch-route {
   border-left: 2px solid #e4e7ed;
   padding-left: 8px;
+  padding-bottom: 4px;
+  margin-bottom: 2px;
+  border-radius: 0 4px 4px 0;
+}
+.branch-route.chosen {
+  border-left-color: #67c23a;
+  background: #f0f9eb;
+  padding: 4px 8px;
 }
 .branch-label {
   display: flex;
@@ -306,6 +316,11 @@ watch(
   background: #f5f7fa;
   color: #909399;
 }
+.branch-chip.passed {
+  border-color: #b3e19d;
+  background: #f0f9eb;
+  color: #529b2e;
+}
 .chip-dot {
   width: 8px;
   height: 8px;
@@ -321,6 +336,9 @@ watch(
 .chip-dot.pending {
   background: #c0c4cc;
 }
+.chip-dot.passed {
+  background: #67c23a;
+}
 .chip-name {
   font-weight: 500;
 }

+ 12 - 1
src/views/flow/execute/NodeCard.vue

@@ -8,7 +8,6 @@
         <el-icon><component :is="icon" /></el-icon>
       </span>
       <span class="node-card-name">{{ node.name }}</span>
-      <span v-if="node.branchLabel && !isEndpoint" class="node-branch-tag">{{ node.branchLabel }}</span>
       <template v-if="node.status !== 'neutral'">
         <span class="status-dot" :class="node.status" />
         <span class="status-text" :class="node.status">{{ statusText }}</span>
@@ -107,6 +106,8 @@ const statusText = computed(() => {
       return '已完成'
     case 'current':
       return '进行中'
+    case 'passed':
+      return '已走过'
     case 'pending':
       return '待处理'
     default:
@@ -149,6 +150,10 @@ function subStatusText(sub: { status: string }): string {
 .node-card.pending {
   border-left-color: #c0c4cc;
 }
+.node-card.passed {
+  border-left-color: #67c23a;
+  background: #f0f9eb;
+}
 .node-card.neutral {
   border-left-color: #909399;
 }
@@ -198,6 +203,9 @@ function subStatusText(sub: { status: string }): string {
 .status-dot.pending {
   background: #c0c4cc;
 }
+.status-dot.passed {
+  background: #67c23a;
+}
 .status-text {
   font-size: 12px;
 }
@@ -207,6 +215,9 @@ function subStatusText(sub: { status: string }): string {
 .status-text.current {
   color: #e6a23c;
 }
+.status-text.passed {
+  color: #67c23a;
+}
 .status-text.pending {
   color: #909399;
 }

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