# 审批流程时间线增强 - 技术实施方案

> 创建日期：2026-01-06  
> 设计文档：`docs/design/approval-timeline-enhancement.md`  
> 实施原则：零技术债务

---

## 📋 实施概述

### 核心变更
1. ✅ 数据层：获取完整流程定义（不仅是已执行历史）
2. ✅ 映射层：支持未执行状态（pending, skipped）
3. ✅ 展示层：渲染未执行节点和虚线连接
4. ✅ 交互层：悬浮提示和视觉反馈

### 影响范围
- **类型定义**：`frontend/src/types/approval.ts`
- **工具函数**：`frontend/src/features/approval/utils/status-mapper.ts`
- **组件**：`frontend/src/features/approval/designer/LarkProcessPreview.tsx`
- **页面**：`frontend/src/app/(modules)/approval-center/page.tsx`
- **国际化**：`frontend/src/locales/approvals/*.ts`
- **样式**：添加 CSS 样式

---

## 🔧 详细实施步骤

### 步骤1：更新类型定义

#### 1.1 扩展 NodeDisplayStatus

```typescript
// frontend/src/types/approval.ts

export type NodeDisplayStatus = 
  | 'pending'    // 待执行 ⭐ 新增
  | 'active'     // 执行中
  | 'completed'  // 已通过
  | 'rejected'   // 已拒绝
  | 'returned'   // 已退回
  | 'skipped'    // 已跳过 ⭐ 新增
  | 'cancelled'  // 已取消
  | 'failed';    // 失败
```

#### 1.2 添加节点状态计算依赖

```typescript
// frontend/src/types/approval.ts

/**
 * 节点状态计算上下文
 * 用于判断未执行节点的显示状态
 */
export interface NodeStatusContext {
  /** 流程实例状态 */
  instanceStatus: InstanceStatus;
  
  /** 流程定义的所有节点 */
  allNodes: ProcessNode[];
  
  /** 已执行的节点ID集合 */
  executedNodeIds: Set<string>;
  
  /** 当前活跃节点ID（如果有） */
  activeNodeId?: string;
}
```

---

### 步骤2：增强状态映射工具

#### 2.1 新增完整流程映射函数

```typescript
// frontend/src/features/approval/utils/status-mapper.ts

/**
 * 将流程定义和审批历史合并映射为完整的节点状态
 * 
 * @param processModel 流程定义模型
 * @param approvalHistory 审批历史数据
 * @param instanceStatus 流程实例状态
 * @returns 完整的节点状态映射
 */
export function mapCompleteProcessStatuses(
  processModel: ProcessModel,
  approvalHistory: HistoryItem[],
  instanceStatus: InstanceStatus
): Record<string, NodeStatusMapping> {
  // 1. 先映射已执行的节点
  const executedStatuses = mapApprovalHistoryToStatuses(approvalHistory);
  const executedNodeIds = new Set(Object.keys(executedStatuses));
  
  // 2. 创建完整的节点状态映射
  const completeStatuses: Record<string, NodeStatusMapping> = {};
  
  // 3. 遍历流程定义中的所有节点
  for (const node of processModel.nodes) {
    if (executedStatuses[node.id]) {
      // 节点已执行，使用实际状态
      completeStatuses[node.id] = executedStatuses[node.id];
    } else {
      // 节点未执行，根据流程状态判断显示状态
      completeStatuses[node.id] = determineUnexecutedNodeStatus(
        node,
        instanceStatus,
        executedNodeIds
      );
    }
  }
  
  return completeStatuses;
}

/**
 * 判断未执行节点的显示状态
 */
function determineUnexecutedNodeStatus(
  node: ProcessNode,
  instanceStatus: InstanceStatus,
  executedNodeIds: Set<string>
): NodeStatusMapping {
  // 1. 流程已终止（拒绝/撤回/终止），后续节点标记为"跳过"
  if (instanceStatus === 'REJECTED' || 
      instanceStatus === 'WITHDRAWN' || 
      instanceStatus === 'TERMINATED') {
    return {
      status: 'skipped',
      completedAt: '',
      operator: '',
      comment: '',
    };
  }
  
  // 2. 流程运行中
  if (instanceStatus === 'RUNNING') {
    // 2.1 检查是否是下一个待执行节点
    // （简化实现：如果流程在运行，所有未执行节点都标记为 pending）
    return {
      status: 'pending',
      completedAt: '',
      operator: '',
      comment: '',
    };
  }
  
  // 3. 流程已完成，但节点未执行（可能是条件分支跳过）
  if (instanceStatus === 'COMPLETED') {
    return {
      status: 'skipped',
      completedAt: '',
      operator: '',
      comment: '',
    };
  }
  
  // 4. 默认：待执行
  return {
    status: 'pending',
    completedAt: '',
    operator: '',
    comment: '',
  };
}
```

#### 2.2 添加辅助函数

```typescript
/**
 * 检查节点是否未执行
 */
export function isNodeUnexecuted(nodeStatus: NodeStatusMapping): boolean {
  return nodeStatus.status === 'pending' || nodeStatus.status === 'skipped';
}

/**
 * 获取未执行节点的提示文本
 */
export function getUnexecutedNodeTooltip(
  nodeStatus: NodeStatusMapping,
  t: any
): string {
  if (nodeStatus.status === 'skipped') {
    return t.approvals.timeline.nodeSkipped;
  }
  if (nodeStatus.status === 'pending') {
    return t.approvals.timeline.nodePending;
  }
  return '';
}
```

---

### 步骤3：更新前端数据获取逻辑

#### 3.1 修改审批中心页面

```typescript
// frontend/src/app/(modules)/approval-center/page.tsx

// 在 loadDetail 函数中
const loadDetail = useCallback(async (item: ApprovalItem) => {
  try {
    // ... 现有代码 ...
    
    // ⭐ 修改：使用完整流程定义 + 审批历史
    const model = detail.calculatedProcess?.model || detail.processVersion.model;
    const nodeStatuses = mapCompleteProcessStatuses(
      model,                    // 流程定义
      detail.history || [],     // 审批历史
      detail.status             // 流程实例状态
    );
    
    // 特殊处理 START 节点
    const startNode = model.nodes.find(n => n.type === 'START');
    if (startNode && detail.history && detail.history.length > 0) {
      nodeStatuses[startNode.id] = createStartNodeStatus(
        detail.submitTime,
        detail.submitter.name
      );
    }
    
    // 渲染时间线
    return <LarkProcessPreview
      model={model}
      nodeStatuses={nodeStatuses}
      instanceStatus={detail.status}  // ⭐ 新增：传递实例状态
      // ... 其他 props
    />;
    
  } catch (error) {
    // ...
  }
}, [/* ... */]);
```

---

### 步骤4：更新 LarkProcessPreview 组件

#### 4.1 扩展 Props

```typescript
// frontend/src/features/approval/designer/LarkProcessPreview.tsx

interface LarkProcessPreviewProps {
  model: ProcessModel;
  nodeStatuses?: Record<string, NodeStatusMapping>;
  instanceStatus?: InstanceStatus;  // ⭐ 新增
  className?: string;
  showInitiator?: boolean;
  initiatorName?: string;
  showEndNode?: boolean;
  compact?: boolean;
}
```

#### 4.2 更新 TimelineIcon 组件

```typescript
function TimelineIcon({ status, type }: TimelineIconProps) {
  const baseClasses = 'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 transition-all';
  
  const statusConfig: Record<NodeDisplayStatus, {
    className: string;
    icon: React.ReactNode;
  }> = {
    completed: {
      className: `${baseClasses} bg-[#00b96b] shadow-sm ring-4 ring-[#00b96b]/10`,
      icon: <Check className="w-4 h-4 text-white stroke-[2.5]" />,
    },
    rejected: {
      className: `${baseClasses} bg-[#f5222d] shadow-sm ring-4 ring-[#f5222d]/10`,
      icon: <X className="w-4 h-4 text-white stroke-[2.5]" />,
    },
    active: {
      className: `${baseClasses} bg-[#1890ff] shadow-sm ring-4 ring-[#1890ff]/20 animate-pulse`,
      icon: <Clock className="w-4 h-4 text-white stroke-[2.5]" />,
    },
    returned: {
      className: `${baseClasses} bg-[#ff7d00] shadow-sm ring-4 ring-[#ff7d00]/10`,
      icon: <CornerUpLeft className="w-4 h-4 text-white stroke-[2.5]" />,
    },
    // ⭐ 新增：未执行状态
    pending: {
      className: `${baseClasses} bg-[#d9d9d9] shadow-sm ring-4 ring-gray-200/50 group-hover:ring-gray-300/80`,
      icon: <div className="w-2 h-2 rounded-full bg-[#8c8c8c]" />,
    },
    skipped: {
      className: `${baseClasses} bg-[#f5f5f5] shadow-none ring-4 ring-gray-100/30 opacity-40 group-hover:opacity-60`,
      icon: <Minus className="w-3 h-3 text-[#bfbfbf]" />,
    },
    cancelled: {
      className: `${baseClasses} bg-gray-300 shadow-sm ring-4 ring-gray-200/30`,
      icon: <Ban className="w-4 h-4 text-white stroke-[2]" />,
    },
    failed: {
      className: `${baseClasses} bg-[#f5222d] shadow-sm ring-4 ring-[#f5222d]/10`,
      icon: <AlertCircle className="w-4 h-4 text-white stroke-[2]" />,
    },
  };
  
  const config = statusConfig[status];
  
  return (
    <div className={config.className}>
      {config.icon}
    </div>
  );
}
```

#### 4.3 更新 TimelineNode 组件

```typescript
function TimelineNode({ node, status, isLast, t, compact, instanceStatus }: TimelineNodeProps) {
  const { locale } = useTranslation();
  
  const typeLabel = getNodeTypeLabel(node.type, t);
  const configLabel = getApproverTypeLabel(node.config?.approverType, t);
  
  const approverDetails = node.config?.approverDetails;
  const hasApprovers = approverDetails && approverDetails.length > 0;
  const displayText = hasApprovers
    ? approverDetails.map((a: any) => a.name).join('、')
    : configLabel;
  
  // ⭐ 判断是否为未执行节点
  const isUnexecuted = isNodeUnexecuted(status);
  const tooltipText = isUnexecuted ? getUnexecutedNodeTooltip(status, t) : '';
  
  return (
    <div className="flex gap-4">
      {/* 左侧时间线 */}
      <div className="flex flex-col items-center relative group">
        <TimelineIcon status={status.status} type={node.type} />
        
        {/* ⭐ 悬浮提示（仅未执行节点） */}
        {isUnexecuted && tooltipText && (
          <div className="absolute left-full ml-4 px-3 py-1.5 bg-gray-900 text-white text-xs rounded-md shadow-lg whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
            <div className="absolute right-full top-1/2 -translate-y-1/2 border-4 border-transparent border-r-gray-900" />
            <span>{tooltipText}</span>
          </div>
        )}
        
        {/* ⭐ 连接线（实线/虚线） */}
        {!isLast && (
          <div className={`
            w-0.5 mt-2
            ${compact ? 'h-8' : 'h-12'}
            ${isUnexecuted ? 'connector-dashed' : 'connector-solid'}
          `} />
        )}
      </div>
      
      {/* 右侧内容 */}
      <div className={`flex-1 ${compact ? 'pb-4' : 'pb-6'} transition-opacity ${isUnexecuted ? 'opacity-40' : 'opacity-100'}`}>
        {/* 节点名称 */}
        <div className="flex items-center gap-2">
          <h4 className="text-sm font-medium text-gray-900">
            {node.name || typeLabel}
          </h4>
          {status.status === 'active' && (
            <span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">
              {t.approvals.larkPreview.currentNode}
            </span>
          )}
          {status.status === 'completed' && (
            <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
              {t.approvals.larkPreview.completed}
            </span>
          )}
        </div>
        
        {/* 审批人信息（仅已执行节点） */}
        {!isUnexecuted && (
          <p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
            <span className="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
            {displayText}
            {/* ... 审批模式提示 ... */}
          </p>
        )}
        
        {/* 操作历史（仅已执行节点） */}
        {!isUnexecuted && status.operator && (
          <div className="mt-2 text-sm">
            <span className="text-gray-600">{status.operator}</span>
            {status.completedAt && (
              <span className="text-gray-400 text-xs ml-2">
                {new Date(status.completedAt).toLocaleString(locale === 'zh' ? 'zh-CN' : 'en-US')}
              </span>
            )}
          </div>
        )}
        
        {/* 评论（仅已执行节点） */}
        {!isUnexecuted && status.comment && (
          <div className="mt-1 p-2 bg-gray-50 rounded text-sm text-gray-600 border-l-2 border-gray-300">
            {status.comment}
          </div>
        )}
      </div>
    </div>
  );
}
```

---

### 步骤5：添加 CSS 样式

#### 5.1 创建样式文件

```typescript
// frontend/src/features/approval/designer/LarkProcessPreview.module.css

.connector-solid {
  background: linear-gradient(
    to bottom,
    #e5e7eb 0%,
    #d1d5db 50%,
    #e5e7eb 100%
  );
}

.connector-dashed {
  background-image: linear-gradient(
    to bottom,
    #d9d9d9 0%,
    #d9d9d9 50%,
    transparent 50%,
    transparent 100%
  );
  background-size: 2px 8px;
  background-repeat: repeat-y;
  opacity: 0.4;
}
```

或者使用 Tailwind CSS 的 JIT 模式：

```tsx
// 在组件中直接使用
<div className={`
  w-0.5 mt-2 h-12
  ${isUnexecuted 
    ? 'bg-gradient-to-b from-gray-300 via-gray-300 to-transparent bg-[length:2px_8px] bg-repeat-y opacity-40' 
    : 'bg-gradient-to-b from-gray-200 via-gray-300 to-gray-200'
  }
`} />
```

---

### 步骤6：添加国际化文本

#### 6.1 中文翻译

```typescript
// frontend/src/locales/approvals/zh.ts

export const approvalsZh = {
  // ... 现有翻译 ...
  
  timeline: {
    nodePending: '待执行',
    nodeSkipped: '流程已终止，此节点未执行',
    processTerminated: '流程已终止',
    statusLabels: {
      pending: '待执行',
      active: '进行中',
      completed: '已完成',
      rejected: '已拒绝',
      returned: '已退回',
      skipped: '未执行',
      cancelled: '已取消',
      failed: '失败',
    },
  },
};
```

#### 6.2 英文翻译

```typescript
// frontend/src/locales/approvals/en.ts

export const approvalsEn = {
  // ... existing translations ...
  
  timeline: {
    nodePending: 'Pending',
    nodeSkipped: 'Process terminated, node not executed',
    processTerminated: 'Process terminated',
    statusLabels: {
      pending: 'Pending',
      active: 'Active',
      completed: 'Completed',
      rejected: 'Rejected',
      returned: 'Returned',
      skipped: 'Skipped',
      cancelled: 'Cancelled',
      failed: 'Failed',
    },
  },
};
```

---

### 步骤7：添加必要的图标

```typescript
// frontend/src/features/approval/designer/LarkProcessPreview.tsx

import {
  User,
  UserCheck,
  Users,
  Check,
  Clock,
  X,
  Minus,           // ⭐ 新增：用于 skipped 状态
  CornerUpLeft,    // ⭐ 新增：用于 returned 状态
  Ban,             // ⭐ 新增：用于 cancelled 状态
  AlertCircle,     // ⭐ 新增：用于 failed 状态
  ChevronRight,
} from 'lucide-react';
```

---

## 🧪 测试验证

### 测试用例清单

#### 1. 基础场景测试

```typescript
// Test Case 1: 审批通过
// 预期：所有节点绿色，实线连接，无灰色节点
{
  processStatus: 'COMPLETED',
  history: [
    { nodeId: 'start', status: 'COMPLETED', ... },
    { nodeId: 'node1', status: 'COMPLETED', action: 'APPROVE', ... },
    { nodeId: 'node2', status: 'COMPLETED', action: 'APPROVE', ... },
    { nodeId: 'end', status: 'COMPLETED', ... },
  ],
  expected: {
    allNodesShown: true,
    unexpectedNodesCount: 0,
  }
}

// Test Case 2: 审批拒绝（单级）
// 预期：拒绝节点红色，后续节点灰色，虚线连接
{
  processStatus: 'REJECTED',
  history: [
    { nodeId: 'start', status: 'COMPLETED', ... },
    { nodeId: 'node1', status: 'COMPLETED', action: 'REJECT', comment: '不符合要求', ... },
  ],
  expected: {
    allNodesShown: true,
    unexpectedNodesCount: 2, // node2, end
    rejectedNodeComment: '不符合要求',
  }
}

// Test Case 3: 审批拒绝（多级）
// 预期：前两个节点正常，第三个拒绝，后续灰色
{
  processStatus: 'REJECTED',
  history: [
    { nodeId: 'start', status: 'COMPLETED', ... },
    { nodeId: 'node1', status: 'COMPLETED', action: 'APPROVE', ... },
    { nodeId: 'node2', status: 'COMPLETED', action: 'APPROVE', ... },
    { nodeId: 'node3', status: 'COMPLETED', action: 'REJECT', comment: 'xxx', ... },
  ],
  expected: {
    allNodesShown: true,
    executedNodesCount: 4, // start + 3 nodes
    unexpectedNodesCount: 2, // node4, end
  }
}

// Test Case 4: 流程撤回
// 预期：类似拒绝场景
{
  processStatus: 'WITHDRAWN',
  history: [
    { nodeId: 'start', status: 'COMPLETED', ... },
    { nodeId: 'node1', status: 'COMPLETED', action: 'WITHDRAW', ... },
  ],
  expected: {
    allNodesShown: true,
    unexpectedNodesCount: 2,
  }
}

// Test Case 5: 流程进行中
// 预期：已执行节点正常，当前节点蓝色，后续节点灰色（pending而非skipped）
{
  processStatus: 'RUNNING',
  history: [
    { nodeId: 'start', status: 'COMPLETED', ... },
    { nodeId: 'node1', status: 'COMPLETED', action: 'APPROVE', ... },
    { nodeId: 'node2', status: 'ACTIVE', ... },
  ],
  expected: {
    allNodesShown: true,
    activeNodeId: 'node2',
    pendingNodesCount: 2, // node3, end
  }
}
```

#### 2. 交互测试

- [ ] 悬浮在灰色节点上，显示提示
- [ ] 提示文本正确（待执行 vs 未执行）
- [ ] 切换语言，提示文本更新
- [ ] 移动端：提示向下展开

#### 3. 样式测试

- [ ] 虚线连接器渲染正确
- [ ] 灰色节点透明度正确
- [ ] 悬浮时节点有视觉反馈
- [ ] 响应式布局正常

---

## 📈 性能优化

### 潜在性能问题

1. **大量节点**：如果流程有50+节点，渲染可能卡顿
2. **频繁悬浮**：tooltip 切换可能导致重渲染

### 优化方案

```typescript
// 1. 使用 memo 优化节点渲染
const TimelineNode = React.memo(TimelineNodeComponent);

// 2. 虚拟滚动（如果节点很多）
import { VirtualScroller } from '@/components/VirtualScroller';

// 3. 延迟加载tooltip
const [showTooltip, setShowTooltip] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();

const handleMouseEnter = () => {
  timeoutRef.current = setTimeout(() => setShowTooltip(true), 200);
};

const handleMouseLeave = () => {
  clearTimeout(timeoutRef.current);
  setShowTooltip(false);
};
```

---

## 🚨 风险与注意事项

### 技术风险

1. **数据依赖**：需要确保后端返回完整的流程定义
2. **状态判断**：复杂流程（条件分支、并行分支）的状态判断可能不准确
3. **向后兼容**：旧版本流程数据可能缺少字段

### 缓解措施

```typescript
// 1. 防御性编程
if (!processModel || !processModel.nodes) {
  console.warn('流程定义缺失，回退到仅显示已执行节点');
  return mapApprovalHistoryToStatuses(approvalHistory);
}

// 2. 渐进增强
try {
  return mapCompleteProcessStatuses(processModel, approvalHistory, instanceStatus);
} catch (error) {
  console.error('完整流程映射失败，回退到简化模式', error);
  return mapApprovalHistoryToStatuses(approvalHistory);
}

// 3. 版本兼容
const isNewVersion = processVersion && processVersion.version >= 2;
if (isNewVersion) {
  // 使用新逻辑
} else {
  // 使用旧逻辑（向后兼容）
}
```

---

## ✅ 实施检查清单

### 代码质量
- [ ] 所有新增代码有 TypeScript 类型
- [ ] 所有导出函数有 JSDoc 注释
- [ ] 无 ESLint 错误
- [ ] 无 TypeScript 编译错误

### 功能完整性
- [ ] 支持所有节点状态
- [ ] 支持所有流程状态
- [ ] 国际化完整
- [ ] 响应式适配

### 测试覆盖
- [ ] 单元测试（状态映射函数）
- [ ] 集成测试（组件渲染）
- [ ] E2E 测试（完整流程）
- [ ] 视觉回归测试

### 文档完善
- [ ] 更新架构文档
- [ ] 更新组件文档
- [ ] 添加使用示例
- [ ] 更新 CHANGELOG

---

## 🎯 预期工作量

### 时间估算
- **设计确认**: 0.5小时 ✅
- **类型定义**: 0.5小时
- **工具函数**: 1.5小时
- **组件更新**: 2小时
- **样式实现**: 1小时
- **国际化**: 0.5小时
- **测试验证**: 1.5小时
- **文档完善**: 0.5小时

**总计**: 约8小时

### 里程碑
1. **M1 (2小时)**: 类型和工具函数完成
2. **M2 (4小时)**: 组件和样式完成
3. **M3 (6小时)**: 国际化和基础测试完成
4. **M4 (8小时)**: 全面测试和文档完成

---

**附录：相关文件清单**

需要修改的文件：
1. `frontend/src/types/approval.ts`
2. `frontend/src/features/approval/utils/status-mapper.ts`
3. `frontend/src/features/approval/designer/LarkProcessPreview.tsx`
4. `frontend/src/app/(modules)/approval-center/page.tsx`
5. `frontend/src/locales/approvals/zh.ts`
6. `frontend/src/locales/approvals/en.ts`

新增文件：
1. `frontend/src/features/approval/designer/LarkProcessPreview.module.css` (可选)
2. `frontend/src/features/approval/utils/__tests__/status-mapper.test.ts` (推荐)

