# 审批中心 - UI 交互规范

> **文档版本**: v1.0  
> **创建日期**: 2026-01-02  
> **最后更新**: 2026-01-02  
> **作者**: FFOA 开发团队  
> **状态**: ✅ Approved

---

## 📋 目录

- [1. 设计原则](#1-设计原则)
- [2. 布局规范](#2-布局规范)
- [3. 组件规范](#3-组件规范)
- [4. 交互规范](#4-交互规范)
- [5. 状态反馈](#5-状态反馈)
- [6. 响应式设计](#6-响应式设计)
- [7. 无障碍访问](#7-无障碍访问)

---

## 1. 设计原则

### 1.1 核心原则

| 原则 | 说明 | 示例 |
|-----|------|------|
| **简洁** | 去除冗余信息，突出核心内容 | 列表只显示标题、提交人、时间 |
| **高效** | 减少点击次数，快速完成操作 | 列表点击直接展开详情，无需跳转 |
| **一致** | 交互模式、颜色、间距统一 | 所有 Tab 使用相同的布局 |
| **反馈** | 操作后即时反馈，加载状态明确 | 点击按钮显示 loading，操作后 toast |

### 1.2 设计参考

**参考产品**: 飞书审批中心、钉钉审批

**核心借鉴**:
- 📱 列表-详情分栏布局
- 🎨 扁平化设计，无过多阴影
- 🔵 蓝色为主色调
- ⚡ 快捷操作栏（底部固定）

---

## 2. 布局规范

### 2.1 整体布局

```
┌─────────────────────────────────────────────────────────┐
│  顶部导航栏 (h-16)                                       │
├─────────────────────────────────────────────────────────┤
│  Tab 栏 (h-14) + 筛选器                                  │
│  ┌──────┬──────┬──────┬──────┬──────┐  [搜索框]        │
│  │ 提交 │ 待办 │ 我提交 │ 我审批 │ 抄送 │                 │
│  └──────┴──────┴──────┴──────┴──────┘                 │
├───────────────┬─────────────────────────────────────────┤
│   列表区域     │         详情面板                         │
│   (w-[400px]) │         (flex-1)                        │
│               │                                         │
│  ┌─────────┐  │  ┌──────────────────────────────────┐  │
│  │审批项1   │  │  │ 详情头部 (标题+提交人+时间)       │  │
│  │[选中态]  │  │  └──────────────────────────────────┘  │
│  └─────────┘  │  ┌──────────────────────────────────┐  │
│  ┌─────────┐  │  │ 表单数据 (FormRenderer)          │  │
│  │审批项2   │  │  │                                  │  │
│  └─────────┘  │  └──────────────────────────────────┘  │
│  ┌─────────┐  │  ┌──────────────────────────────────┐  │
│  │审批项3   │  │  │ 审批历史 (ApprovalTimeline)      │  │
│  └─────────┘  │  │                                  │  │
│               │  └──────────────────────────────────┘  │
│               │  ┌──────────────────────────────────┐  │
│               │  │ 操作区 (审批意见+按钮)            │  │
│               │  └──────────────────────────────────┘  │
└───────────────┴─────────────────────────────────────────┘
```

### 2.2 尺寸规范

| 元素 | 尺寸 | 说明 |
|-----|------|------|
| **Tab 栏高度** | `h-14` (56px) | 标准高度，与飞书一致 |
| **列表宽度** | `w-[400px]` | 固定宽度，不随窗口变化 |
| **详情面板** | `flex-1` | 自适应剩余空间 |
| **列表项高度** | `min-h-[80px]` | 最小高度，内容自适应 |
| **间距 (gap)** | `gap-3` (12px), `gap-4` (16px) | 统一间距 |
| **内边距 (padding)** | `px-4`, `py-3`, `px-6`, `py-4` | 常用内边距 |
| **圆角 (border-radius)** | `rounded-lg` (8px) | 按钮、卡片统一圆角 |

### 2.3 颜色规范

#### 主题色

```css
/* 主色 */
--primary: #3370ff;        /* 飞书蓝 */
--primary-hover: #2b5dd4;  /* 深蓝 */

/* 成功 */
--success: #00b42a;        /* 绿色 */
--success-light: #e8f7ed;  /* 浅绿背景 */

/* 警告 */
--warning: #ff7d00;        /* 橙色 */
--warning-light: #fff3e8;  /* 浅橙背景 */

/* 错误 */
--error: #f53f3f;          /* 红色 */
--error-light: #fdeeee;    /* 浅红背景 */

/* 紫色 */
--purple: #722ed1;         /* 紫色 */
--purple-light: #f4eeff;   /* 浅紫背景 */

/* 中性色 */
--gray-50: #f7f8fa;        /* 背景 */
--gray-100: #f2f3f5;       /* 浅灰背景 */
--gray-200: #e5e6eb;       /* 边框 */
--gray-600: #646a73;       /* 次要文字 */
--gray-900: #1d2129;       /* 主要文字 */
```

#### 颜色用途

| 用途 | 颜色 | Tailwind Class |
|-----|------|----------------|
| 主按钮 | `#3370ff` | `bg-[#3370ff] text-white` |
| 链接 | `#3370ff` | `text-[#3370ff]` |
| 成功状态 | `#00b42a` | `text-[#00b42a]` |
| 警告状态 | `#ff7d00` | `text-[#ff7d00]` |
| 错误/拒绝 | `#f53f3f` | `text-[#f53f3f]` |
| 已处理 | `#722ed1` | `text-[#722ed1]` |
| 边框 | `#e5e6eb` | `border-[#e5e6eb]` |
| 背景 | `#f7f8fa` | `bg-[#f7f8fa]` |

---

## 3. 组件规范

### 3.1 Tab 标签栏

**高度**: `h-14` (56px)  
**背景**: 白色 `bg-white`  
**边框**: 底部 1px `border-b`

**Tab 项**:
```jsx
<button className={`
  px-4 py-3 
  flex items-center gap-2 
  transition-all
  ${isActive 
    ? 'text-[#3370ff] border-b-2 border-[#3370ff]' 
    : 'text-gray-600 hover:text-gray-900'
  }
`}>
  <Icon className="w-4 h-4" />
  <span className="text-sm font-medium">{label}</span>
  {count > 0 && (
    <span className="px-1.5 py-0.5 text-xs bg-red-500 text-white rounded-full">
      {count}
    </span>
  )}
</button>
```

**交互**:
- 鼠标悬停：文字颜色变深
- 选中状态：蓝色文字 + 底部蓝色边框
- 数量 Badge：红色背景，白色文字

### 3.2 列表项

**尺寸**: 宽度 `w-[400px]`，最小高度 `min-h-[80px]`  
**边框**: 底部 1px `border-b-[#e5e6eb]`  
**内边距**: `px-4 py-3`

**结构**:
```jsx
<div className={`
  px-4 py-3 
  border-b cursor-pointer 
  transition-all
  ${isSelected 
    ? 'bg-[#f0f5ff] border-l-4 border-l-[#3370ff]' 
    : 'hover:bg-gray-50'
  }
`}>
  {/* 标题 */}
  <h3 className="text-sm font-medium text-gray-900 mb-2">
    {item.title}
  </h3>
  
  {/* 提交人 + 时间 */}
  <div className="flex items-center justify-between text-xs text-gray-600">
    <div className="flex items-center gap-2">
      <div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center">
        {submitter.name.substring(0, 2)}
      </div>
      <span>{submitter.name}</span>
    </div>
    <span>{formatTime(submitTime)}</span>
  </div>
  
  {/* 当前节点 */}
  <div className="mt-2 text-xs text-gray-500">
    {currentNode}
  </div>
</div>
```

**交互**:
- 鼠标悬停：浅灰背景 `hover:bg-gray-50`
- 选中状态：浅蓝背景 `bg-[#f0f5ff]` + 左侧蓝色边框

### 3.3 详情面板

**背景**: 白色 `bg-white`  
**内边距**: `px-6 py-4`

**结构**:
```
┌──────────────────────────────────────┐
│ 详情头部                              │
│ - 标题 (text-lg font-semibold)       │
│ - 提交人 + 时间 (text-sm text-gray-600) │
├──────────────────────────────────────┤
│ 表单数据 (FormRenderer)              │
│ - 只读模式                           │
│ - 自动布局                           │
├──────────────────────────────────────┤
│ 审批历史 (ApprovalTimeline)          │
│ - 时间线垂直布局                      │
│ - 图标 + 文字 + 时间戳                │
├──────────────────────────────────────┤
│ 操作区 (仅待办时显示)                 │
│ - 审批意见输入框                      │
│ - 通过 / 拒绝按钮                     │
└──────────────────────────────────────┘
```

### 3.4 按钮

**主按钮**（通过）:
```jsx
<button className="
  px-4 py-2 
  bg-[#3370ff] text-white 
  rounded-lg 
  hover:bg-[#2b5dd4] 
  disabled:bg-gray-300 disabled:cursor-not-allowed
  transition-all
">
  {loading ? <Spinner /> : '通过'}
</button>
```

**次要按钮**（拒绝、撤回）:
```jsx
<button className="
  px-4 py-2 
  border border-gray-300 text-gray-700 
  rounded-lg 
  hover:bg-gray-50 
  disabled:opacity-50 disabled:cursor-not-allowed
  transition-all
">
  拒绝
</button>
```

**危险按钮**（强制终止）:
```jsx
<button className="
  px-4 py-2 
  bg-red-500 text-white 
  rounded-lg 
  hover:bg-red-600 
  disabled:opacity-50 disabled:cursor-not-allowed
  transition-all
">
  强制终止
</button>
```

### 3.5 输入框

**审批意见**:
```jsx
<textarea 
  className="
    w-full px-3 py-2 
    border border-gray-200 rounded-lg 
    focus:border-[#3370ff] focus:ring-1 focus:ring-[#3370ff] 
    resize-none
  " 
  rows={4}
  placeholder="请输入审批意见（选填）"
/>
```

### 3.6 审批历史时间线（ApprovalTimeline）

#### 设计原则

**参考**：钉钉/飞书审批历史设计

**核心规则**：
1. ❌ **不显示结束节点**（EndNode）- 结束节点是工作流引擎的技术实现，不应暴露给用户
2. ✅ **最后一个操作即终点** - 最后一个实际操作（通过/拒绝/撤回/终止）就是流程的终点
3. ✅ **整体状态在详情头部展示** - 通过右上角状态标签显示最终状态
4. 🎨 **视觉优化减少突兀感** - 最后一个操作添加结束分隔线

#### 结构示例

**场景：管理员代审批拒绝**（流程已结束）
```
• 发起人
  张员工 提交申请  2026-01-02 11:50
  
• 直属上级审批
  王经理 待审批
  ❌ IT Admin 代审批拒绝  2026-01-02 11:53
     💬 材料不完整
  ─────────────────────────────
  流程结束于 2026-01-02 11:53:42
```

**说明**：
- 只有当流程真正结束时，才显示"流程结束于"分隔线
- 流程结束的判断条件：节点状态为 `COMPLETED`

**场景：管理员重新分配**
```
• 发起人
  张员工 提交申请  2026-01-02 11:50
  
• 直属上级审批
  王经理 待审批
  ➡️ 管理员 IT Administrator 重新分配给 李经理  2026-01-02 22:38
     💬 C
  李经理 审批中
```

**说明**：
- 显示原审批人（王经理）和其状态（待审批）
- 显示管理员操作人、目标用户和原因
- 显示新审批人（李经理）和其状态（审批中）
- **不显示**"流程结束于"（因为流程还在进行中）

**场景：发起人撤回**（流程已结束）
```
• 发起人
  张员工 提交申请  2026-01-02 22:18
  
• 直属上级审批
  李经理 审批中

↻ 撤回申请  张员工  2026-01-02 23:25
   💬 写错了
   ─────────────────────────────
   流程结束于 2026-01-02 23:25:38
```

**说明**：
- **不显示**节点标题："撤回"（黑色圆点）
- **直接显示**撤回操作记录：橙色撤回图标 + "撤回申请" + 操作人 + 时间
- 显示撤回原因（comment）
- 流程已结束，显示"流程结束于"分隔线
- 撤回是 `PROCESS_ACTION` 节点类型，特殊处理：不显示节点标题，直接显示操作

**场景：正常通过**
```
• 发起人
  张员工 提交申请  2026-01-02 11:50
  
• 直属上级审批
  ✅ 王经理 审批通过  2026-01-02 12:00
     💬 同意
  
• 部门总监审批
  ✅ 李总监 审批通过  2026-01-02 14:00
     💬 批准
  ─────────────────────────────
  流程结束于 2026-01-02 14:00:15
```

#### 实现细节

**结束分隔线**：
- 仅在最后一个操作后显示
- 浅灰色上边框 `border-t border-gray-200`
- 文字颜色 `text-gray-400`
- 内边距 `mt-4 pt-3`

**底部空白**：
- 时间线容器添加 `pb-8`
- 提供足够的视觉呼吸空间

**代码示例**：
```jsx
{/* 最后一个操作添加结束分隔线 */}
{isVeryLastAction && (
  <div className="mt-4 pt-3 border-t border-gray-200">
    <div className="flex items-center gap-2 text-xs text-gray-400">
      <span>流程结束于</span>
      <span className="font-medium">
        {formatDateTime(new Date(action.actionTime), locale)}
      </span>
    </div>
  </div>
)}
```

---

### 3.7 管理员数据中心（Admin Analytics）

**入口**: `/approval-center?tab=admin&view=analytics`  
**可见性**: 仅 `approval:admin`

**结构**:
```
┌─────────────────────────────────────────────────────────┐
│ 筛选区：表单 / 时间范围 / 状态 / 发起人 / 组织/部门      │
├─────────────────────────────────────────────────────────┤
│ KPI 卡片：总提交 / 通过率 / 平均耗时 / 审批中             │
├─────────────────────────────────────────────────────────┤
│ 趋势图：提交趋势 / 通过-拒绝趋势                          │
├─────────────────────────────────────────────────────────┤
│ 明细表：实例ID / 表单名 / 发起人 / 时间 / 状态 / 当前节点 │
│ 导出按钮（右上）                                          │
└─────────────────────────────────────────────────────────┘
```

**导出设置与记录**:
- 右上角提供“导出设置”入口，可配置导出保留期（7-365天）
- 导出后展示最近导出记录（状态/创建时间/下载）

**筛选区组件**:
- Select（表单/状态/组织/部门）
- DateRangePicker（时间范围）
- UserSelector（发起人）
- SearchInput（关键字）

**KPI 卡片**:
- 高度 `min-h-[96px]`，`rounded-lg`，`border-[#e5e6eb]`
- 数字强调：`text-[#1d2129] font-semibold`
- 单位/说明：`text-[#646a73] text-xs`

**明细表**:
- 原生 `<table>`
- 表头背景：`#fafafa`，文字 `#666`
- 行 hover：`hover:bg-gray-50`

**明细详情抽屉**:
- 点击明细表行打开右侧抽屉，宽度 `480px`
- 抽屉内容分区：
  - 摘要：表单名/发起人/状态/提交时间/是否需要审批
  - 表单数据：使用 FormRenderer 只读展示
  - 审批历史：有审批实例时展示时间线，无审批实例显示提示
- 详情操作：
  - 有审批实例：提供“查看审批详情”（新标签页打开）
  - 任意实例：提供“查看表单实例”（新标签页打开）
- 关闭方式：右上角关闭按钮或点击遮罩

## 4. 交互规范

### 4.1 Tab 切换

**交互流程**:
1. 用户点击 Tab
2. Tab 下划线动画切换（200ms）
3. 列表区域显示 loading skeleton（150ms）
4. 加载完成后渐显列表（fade-in 200ms）
5. 清空右侧详情面板

**动画**:
```css
/* Tab 下划线 */
.tab-active {
  transition: border-color 200ms ease-in-out;
}

/* 列表渐显 */
.list-fade-in {
  animation: fadeIn 200ms ease-in-out;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}
```

### 4.2 列表项点击

**交互流程**:
1. 用户点击列表项
2. 列表项背景变为浅蓝 + 左侧蓝色边框
3. 详情面板显示 loading skeleton
4. 加载完成后渐显详情内容

**特殊处理**:
- 点击已选中的项：不重新加载
- 点击加载中的项：忽略

### 4.3 审批操作

**通过流程**:
1. 用户填写意见（可选）
2. 点击"通过"按钮
3. 按钮进入 loading 状态（禁用 + spinner）
4. API 请求成功：
   - 显示 toast: "审批成功" (绿色)
   - 清空意见输入框
   - 关闭详情面板
   - 刷新列表和数量
5. API 请求失败：
   - 显示 toast: 错误信息 (红色)
   - 按钮恢复正常

**拒绝流程**:
1. 用户填写意见（**必填**）
2. 点击"拒绝"按钮
3. 如果意见为空：
   - 显示 toast: "请填写拒绝原因" (红色)
   - 聚焦意见输入框
4. 如果意见已填：
   - 按钮进入 loading 状态
   - API 请求成功/失败处理同"通过"

**转交流程**:
1. 点击"转交"按钮
2. 弹出对话框（Dialog）
3. 选择目标用户（UserSelector）
4. 填写转交原因（可选）
5. 确认转交
6. 对话框关闭，列表刷新

### 4.4 撤回操作

**交互流程**:
1. 点击"撤回"按钮
2. 弹出确认对话框（AlertDialog）
3. 用户填写撤回原因（**必填**）
4. 确认撤回
5. 对话框关闭，列表刷新

**确认对话框**:
```jsx
<AlertDialog>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>确认撤回？</AlertDialogTitle>
      <AlertDialogDescription>
        撤回后流程将立即终止，审批人将无法继续处理。
      </AlertDialogDescription>
    </AlertDialogHeader>
    
    <textarea 
      placeholder="请填写撤回原因（必填）"
      className="w-full px-3 py-2 border rounded-lg"
    />
    
    <AlertDialogFooter>
      <AlertDialogCancel>取消</AlertDialogCancel>
      <AlertDialogAction onClick={handleConfirm}>
        确认撤回
      </AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>
```

---

### 4.5 管理员数据中心导出

**交互流程**:
1. 管理员选择筛选条件
2. 点击“导出”按钮
3. 创建导出任务并提示 toast
4. 导出内容与当前筛选条件一致
5. 导出完成后可在“导出记录”中下载

## 5. 状态反馈

### 5.1 加载状态

**列表加载**:
```jsx
{loading ? (
  <div className="space-y-3">
    {[1, 2, 3].map(i => (
      <Skeleton key={i} className="h-20 w-full" />
    ))}
  </div>
) : (
  <div>{renderList()}</div>
)}
```

**详情加载**:
```jsx
{detailLoading ? (
  <div className="space-y-4">
    <Skeleton className="h-8 w-2/3" />
    <Skeleton className="h-40 w-full" />
    <Skeleton className="h-60 w-full" />
  </div>
) : (
  <div>{renderDetail()}</div>
)}
```

**按钮加载**:
```jsx
<button disabled={approving}>
  {approving ? (
    <>
      <Spinner className="w-4 h-4 mr-2" />
      处理中...
    </>
  ) : (
    '通过'
  )}
</button>
```

### 5.2 Toast 通知

**成功**:
```typescript
toast.success('审批成功', {
  duration: 2000,
  position: 'top-center',
});
```

**错误**:
```typescript
toast.error('操作失败，请稍后重试', {
  duration: 3000,
  position: 'top-center',
});
```

**提示**:
```typescript
toast.info('请填写拒绝原因', {
  duration: 2000,
  position: 'top-center',
});
```

### 5.3 空状态

**列表为空**:
```jsx
{items.length === 0 && (
  <div className="flex flex-col items-center justify-center h-64 text-gray-500">
    <FileQuestion className="w-16 h-16 mb-4" />
    <p className="text-sm">暂无审批任务</p>
  </div>
)}
```

**详情为空**:
```jsx
{!selectedItem && (
  <div className="flex items-center justify-center h-full text-gray-400">
    <p className="text-sm">请选择左侧审批项查看详情</p>
  </div>
)}
```

---

## 6. 响应式设计

### 6.1 断点定义

| 断点 | 宽度 | 设备 |
|-----|------|------|
| `sm` | 640px | 小屏手机 |
| `md` | 768px | 平板 |
| `lg` | 1024px | 小屏桌面 |
| `xl` | 1280px | 标准桌面 |
| `2xl` | 1536px | 大屏桌面 |

### 6.2 桌面端（≥1024px）

**当前实现**:
- 列表-详情分栏布局
- 列表固定 400px
- 详情自适应

### 6.3 平板端（768px - 1024px）

**建议优化**:
- 列表宽度缩小至 320px
- 详情面板可折叠

### 6.4 移动端（<768px）

**建议优化**:
- 列表和详情切换显示（不同屏）
- 点击列表项全屏展开详情
- 返回按钮返回列表

---

## 7. 无障碍访问

### 7.1 键盘导航

| 按键 | 功能 |
|-----|------|
| `Tab` | 焦点移动到下一个可交互元素 |
| `Shift + Tab` | 焦点移动到上一个可交互元素 |
| `Enter` | 激活按钮或选中列表项 |
| `Esc` | 关闭对话框 |
| `↑ / ↓` | 在列表中上下移动（未来优化） |

### 7.2 ARIA 属性

**Tab 标签**:
```jsx
<button 
  role="tab"
  aria-selected={isActive}
  aria-controls="tab-panel"
>
  {label}
</button>
```

**列表**:
```jsx
<div 
  role="list"
  aria-label="审批列表"
>
  <div role="listitem">{item}</div>
</div>
```

**按钮**:
```jsx
<button 
  aria-label="通过审批"
  aria-disabled={approving}
>
  通过
</button>
```

### 7.3 颜色对比度

**WCAG 2.1 AA 标准**:
- 正常文字: 对比度 ≥ 4.5:1
- 大文字: 对比度 ≥ 3:1

**示例**:
- 主色 `#3370ff` on 白色: 4.74:1 ✅
- 灰色 `#646a73` on 白色: 5.14:1 ✅
- 成功 `#00b42a` on 白色: 3.17:1 ⚠️ (需要加深或加粗)

---

## 8. 操作注释前缀规范

| 操作类型 | 前缀 |
|---------|------|
| APPROVE | 审批意见： |
| REJECT | 拒绝原因： |
| FORWARD | 转交原因： |
| DELEGATE | 委托原因： |
| RETURN | 退回原因： |
| WITHDRAW | 撤回原因： |
| ADD_SIGN | 加签原因： |
| ADMIN_APPROVE | 代审批原因： |
| ADMIN_REJECT | 拒绝原因： |
| ADMIN_TERMINATE | 终止原因： |

---

## 9. determineFinalAction 优先级表

当一个节点有多个操作时，显示优先级最高的：

| 优先级 | 操作 |
|--------|------|
| 1 | FAILED |
| 2 | ADMIN_TERMINATE |
| 3 | REJECT, ADMIN_REJECT |
| 4 | RETURN |
| 5 | APPROVE, ADMIN_APPROVE |
| 6 | AUTO_APPROVE, AUTO_REJECT |
| 7 | FORWARD, DELEGATE, ADMIN_REASSIGN |
| 8 | ADD_SIGN, ESCALATE |
| 9 | APPROVER_WITHDRAW |
| 10 | CLAIM, UNCLAIM |

---

## 10. END 节点渲染规则

| instanceStatus | 图标 | 显示内容 |
|---------------|------|---------|
| APPROVED | ✓绿 | 仅结束时间（审批详情已在中间节点显示） |
| REJECTED | ✗红 | 仅结束时间 |
| WITHDRAWN | ⊘灰 | 操作人 + 撤回原因 + 时间（无中间节点，需完整显示） |
| TERMINATED | ⛔红 | 管理员 + 终止原因 + 时间（无中间节点） |
| FAILED | 💥红 | 失败原因 + 时间 |

---

## 11. 防重复提交

- 已实现：approval-center/submit 页面有三重防护（时间戳守卫 2000ms + useRef 标志 + 立即标记）
- 未修复：`frontend/src/app/(engines)/forms/instances/[id]/page.tsx` 缺少防护，风险 🔴 HIGH
- 标准模式：提取为 `useDebounceAction` hook

---

## 附录 A: 设计资源

### Figma 设计稿

（待补充）

### 设计系统

参考: [shadcn/ui](https://ui.shadcn.com/)

### 图标库

Lucide Icons: https://lucide.dev/

---

**文档结束**

**维护者**: FFOA 开发团队 + UI/UX 设计师  
**最后更新**: 2026-01-02  
**版本**: v1.0
