ソースを参照

流程进度展示优化

ye-zhaojia 2 週間 前
コミット
856f921bd4

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

+ 219 - 0
src/utils/flowLayout.ts

@@ -0,0 +1,219 @@
+/**
+ * 流程进度布局工具
+ *
+ * 后端进度接口(/flow/instance/{id}/progress)返回的是按 BFS 拓扑排序后的"拍平"节点列表,
+ * 分支(条件节点)会全部排在一条线上,不易看出分支结构。
+ *
+ * 这里根据流程定义的 modelJson(nodes + edges)重建真实拓扑,
+ * 将流程组织为若干"行"(FlowRow):
+ *   - start / end:起点/终点标记
+ *   - node / cc:主流程上的单个节点
+ *   - branch:条件分支区(含多个分支列,分支节点并行展示)
+ * 分支区之后若各分支汇聚到同一节点,则继续主流程。
+ */
+
+export interface FlowLayoutNode {
+  id: string
+  name: string
+  type: string // start / approval / cc / condition / end
+  status: 'completed' | 'current' | '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
+  nodes: FlowLayoutNode[]
+}
+
+export interface FlowRow {
+  kind: 'start' | 'node' | 'cc' | 'branch' | 'end'
+  /** 单节点行的节点列表(通常 1 个);分支行无此字段 */
+  nodes?: FlowLayoutNode[]
+  /** 分支区标题(条件节点名称) */
+  title?: string
+  branches?: FlowBranch[]
+}
+
+const MAX_DEPTH = 200
+
+/**
+ * 根据流程模型与进度数据构建布局行
+ * @param modelNodes 模型节点 [{id,type,name,properties}]
+ * @param modelEdges 模型连线 [{sourceNodeId,targetNodeId,condition:{condition,isDefault,branchName}}]
+ * @param progress   进度节点列表(仅审批节点,NodeProgress[])
+ * @param records    审批记录列表(ApprovalRecord[])
+ */
+export function buildFlowLayout(
+  modelNodes: any[],
+  modelEdges: any[],
+  progress: any[],
+  records: any[]
+): 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 startNode = modelNodes.find((n) => n.type === 'start')
+  const endNode = modelNodes.find((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 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,
+      nodeProgress: p,
+      records: recordsMap.get(n.id) || [],
+      branchLabel: branchMeta?.branchName,
+      conditionText: branchMeta?.condition,
+      isDefault: branchMeta?.isDefault,
+    }
+  }
+
+  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 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)] })
+      }
+      break
+    }
+
+    // 多出边 → 分支节点(条件节点等)
+    if (outs.length > 1) {
+      const branches: FlowBranch[] = []
+      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[] = []
+        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)) {
+            branchMerge = bCurId
+            break
+          }
+          // 分支标签已展示在分支列头部,节点卡片上不再重复
+          chain.push(mkNode(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
+        }
+        branches.push({
+          label: branchName || condition || (isDefault ? '默认分支' : '分支'),
+          condition,
+          isDefault,
+          nodes: chain,
+        })
+        if (branchMerge) {
+          if (mergeId === null) mergeId = branchMerge
+          else if (mergeId !== branchMerge) mergeId = null // 各分支汇聚点不一致
+        }
+      }
+      rows.push({ kind: 'branch', title: cur.name || cur.id, branches })
+      if (mergeId && nodeMap.has(mergeId)) {
+        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)] })
+      } else {
+        rows.push({ kind: 'node', nodes: [mkNode(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))] })
+  }
+  return rows
+}

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

@@ -320,10 +320,18 @@ const { selectedNode, outgoingEdges, roleList, nodeProps, selectedSubNodeIndex,
 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,
+      }
     }
   }
 })

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

@@ -188,7 +188,7 @@ export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
     const rawText = getNodeRawText(nodeId)
     if (!rawText) return
     // 不再把子节点数量拼进节点文本,改为画布浮层角标
-    lf.setNodeText(nodeId, rawText)
+    lf.updateText(nodeId, rawText)
   }
 
   function loadNodeProps(data: any) {
@@ -565,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)
   }
 
   // 保存流程
@@ -739,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) {
@@ -772,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) {

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

@@ -0,0 +1,241 @@
+<template>
+  <div class="flow-timeline">
+    <template v-for="(row, idx) in rows" :key="rowKey(row, idx)">
+      <!-- 起点 / 终点 -->
+      <div v-if="row.kind === 'start' || row.kind === 'end'" class="flow-endpoint" :class="row.kind">
+        <div class="endpoint-dot" :class="row.kind" />
+        <div class="endpoint-name">{{ row.nodes?.[0]?.name }}</div>
+      </div>
+
+      <!-- 分支区 -->
+      <div v-else-if="row.kind === 'branch'" class="flow-branch">
+        <div class="branch-junction">
+          <span class="junction-icon"><el-icon><Operation /></el-icon></span>
+          <span class="junction-text">{{ row.title || '条件分支' }}</span>
+        </div>
+        <div class="branch-columns">
+          <div v-for="(br, bi) in row.branches" :key="bi" class="branch-column">
+            <div class="branch-head">
+              <span class="branch-name">{{ 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>
+            </div>
+            <div v-if="br.nodes.length" class="branch-nodes">
+              <NodeCard
+                v-for="n in br.nodes"
+                :key="n.id"
+                :node="n"
+                :is-expanded="isNodeExpanded(n.id)"
+                @toggle-node="toggleNodeExpand"
+              />
+            </div>
+            <div v-else class="branch-empty">无审批节点</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 主流程节点 / 抄送节点 -->
+      <div v-else class="flow-main-row">
+        <NodeCard
+          v-for="n in row.nodes"
+          :key="n.id"
+          :node="n"
+          :is-expanded="isNodeExpanded(n.id)"
+          @toggle-node="toggleNodeExpand"
+        />
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { Operation } from '@element-plus/icons-vue'
+import { buildFlowLayout, type FlowRow } from '@/utils/flowLayout'
+import type { ProcessProgress } from '@/types/flow'
+import NodeCard from './NodeCard.vue'
+
+const props = defineProps<{
+  progress: ProcessProgress | null
+}>()
+
+const rows = computed<FlowRow[]>(() => {
+  const def = props.progress?.definition
+  if (!def?.flowJson) return []
+  try {
+    const model = JSON.parse(def.flowJson)
+    return buildFlowLayout(
+      model.nodes || [],
+      model.edges || [],
+      props.progress?.nodes || [],
+      props.progress?.records || []
+    )
+  } catch {
+    return []
+  }
+})
+
+function rowKey(row: FlowRow, idx: number): string {
+  return `${idx}-${row.kind}-${row.nodes?.[0]?.id || row.title || ''}`
+}
+
+// 展开/收起
+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-endpoint {
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 0;
+  z-index: 1;
+}
+.endpoint-dot {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  border: 3px solid #fff;
+  box-shadow: 0 0 0 2px #e4e7ed;
+  margin-left: -11px;
+  flex-shrink: 0;
+}
+.endpoint-dot.start {
+  background: #67c23a;
+}
+.endpoint-dot.end {
+  background: #f56c6c;
+}
+.endpoint-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: #606266;
+}
+
+/* 主流程行 */
+.flow-main-row {
+  position: relative;
+  padding: 4px 0;
+  z-index: 1;
+}
+
+/* 分支区 */
+.flow-branch {
+  position: relative;
+  margin: 8px 0;
+  z-index: 1;
+}
+.branch-junction {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  margin: 6px 0;
+  margin-left: -18px;
+  padding: 2px 10px 2px 6px;
+  background: #f4f4f5;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  font-size: 12px;
+  color: #909399;
+}
+.junction-icon {
+  display: inline-flex;
+  align-items: center;
+}
+.branch-columns {
+  display: flex;
+  gap: 12px;
+  align-items: stretch;
+  margin-left: 14px;
+  padding: 10px;
+  border-left: 2px solid #dcdfe6;
+  border-radius: 6px;
+  background: #fafafa;
+  overflow-x: auto;
+}
+.branch-column {
+  flex: 1;
+  min-width: 210px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.branch-head {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+  padding: 4px 6px;
+  border-bottom: 1px dashed #dcdfe6;
+}
+.branch-name {
+  font-size: 12px;
+  font-weight: 600;
+  color: #303133;
+}
+.branch-cond {
+  font-size: 11px;
+  color: #909399;
+  font-family: monospace;
+}
+.branch-nodes {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.branch-empty {
+  color: #c0c4cc;
+  font-size: 12px;
+  text-align: center;
+  padding: 12px 0;
+}
+</style>

+ 6 - 258
src/views/flow/execute/InstanceDetail.vue

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

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

@@ -0,0 +1,322 @@
+<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>
+      <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>
+      </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 '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.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-text {
+  font-size: 12px;
+}
+.status-text.completed {
+  color: #67c23a;
+}
+.status-text.current {
+  color: #e6a23c;
+}
+.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>

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