tony.cheng
2026-02-12 4d5e72b8da64108455b541eeb13aef5132b6bbc7
feat: 智能外呼功能优化 - 失败任务红点提示 + 重复气泡去重
3 files added
7 files modified
805 ■■■■■ changed files
API文档.md 140 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-manual-takeover/README.md 50 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-manual-takeover/proposal.md 164 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-manual-takeover/tasks.md 120 ●●●●● patch | view | raw | blame | history
web-app/src/App.css 36 ●●●●● patch | view | raw | blame | history
web-app/src/components/dashboard/FloatingControlPanel.jsx 249 ●●●● patch | view | raw | blame | history
web-app/src/contexts/CaseDataContext.jsx 19 ●●●●● patch | view | raw | blame | history
web-app/src/services/OutboundBotAPIService.js 12 ●●●●● patch | view | raw | blame | history
web-app/src/services/ProcessAPIService.js 12 ●●●●● patch | view | raw | blame | history
web-app/src/utils/stateTranslator.js 3 ●●●● patch | view | raw | blame | history
API文档.md
@@ -23,6 +23,8 @@
Base URLs:
* <a href="http://localhost:9015">开发环境: http://localhost:9015</a>
# Authentication
# AI云小调/典型案例查询
@@ -3077,6 +3079,26 @@
### 返回数据结构
## GET 未命名接口
GET /X
> 返回示例
> 200 Response
```json
{}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
# AI云小调/AI调解实时看板
## GET 查询调解记录列表(实时看板专用)
@@ -3632,6 +3654,53 @@
|» data|object|true|none||none|
|»» startTime|string|true|none||none|
|»» duration|string|true|none||none|
## PUT 人工接管API
PUT /api/v1/mediation-timeline/v2/case/202601301030001111/takeover
> Body 请求参数
```json
{
  "userName": "tony"
}
```
### 请求参数
|名称|位置|类型|必选|中文名|说明|
|---|---|---|---|---|---|
|body|body|object| 否 ||none|
|» userName|body|string| 是 | 当前用户名|none|
> 返回示例
> 400 Response
```json
{
  "code": 400,
  "message": "案件已被接管,不允许重复接管",
  "data": null
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|none|Inline|
### 返回数据结构
状态码 **400**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|null|true|none||none|
# AI云小调/调解协议
@@ -4247,5 +4316,76 @@
|»» jobGroupId|string|true|none|工作组ID|none|
|»» instanceId|string|true|none|场景ID|none|
## POST 更新呼叫状态
POST /api/v1/outbound-bot/update-status
> Body 请求参数
```json
{
  "jobId": "string",
  "callStatus": "string"
}
```
### 请求参数
|名称|位置|类型|必选|中文名|说明|
|---|---|---|---|---|---|
|body|body|object| 否 ||none|
|» jobId|body|string| 是 | 工作ID|none|
|» callStatus|body|string| 是 | 外呼状态|none|
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "状态更新成功",
  "data": null
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|null|true|none||none|
# AI云小调/OCR识别AI总结
## POST 自动获取文件ocr
POST /api/v1/case-files-ocr/process
> 返回示例
> 200 Response
```json
{}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
# 数据模型
openspec/changes/implement-manual-takeover/README.md
New file
@@ -0,0 +1,50 @@
# README: implement-manual-takeover
## 变更标识
**change-id**: `implement-manual-takeover`
## 提案概述
实现首页"人工接管"按钮的完整功能,包括API对接、状态更新、UI反馈和状态持久化。
## 文档结构
```
openspec/changes/implement-manual-takeover/
├── README.md          # 本文件 - 变更概览
├── proposal.md        # 详细技术提案
└── tasks.md          # 任务分解清单
```
## 快速开始
### 1. 查看提案
```bash
cat openspec/changes/implement-manual-takeover/proposal.md
```
### 2. 查看任务清单
```bash
cat openspec/changes/implement-manual-takeover/tasks.md
```
## 主要变更
### 修改的文件
- `web-app/src/components/dashboard/FloatingControlPanel.jsx`
- `web-app/src/App.css`
### 新增功能
- ✅ API 对接:`ProcessAPIService.takeover`
- ✅ 状态展示:印章效果 UI
- ✅ 状态持久化:支持页面刷新
- ✅ 错误处理:友好的用户反馈
## 预估工时
**总计**: 1.5 小时
## 状态
🟡 **待审批** - 等待用户确认
## 相关链接
- [提案详情](./proposal.md)
- [任务清单](./tasks.md)
- [API文档](../../../API文档.md)
openspec/changes/implement-manual-takeover/proposal.md
New file
@@ -0,0 +1,164 @@
# Proposal: implement-manual-takeover
## 概述
实现首页"人工接管"按钮的完整功能,包括API对接、状态更新、UI反馈和状态持久化。
## 背景
当前首页已存在"人工接管"按钮(位于 `FloatingControlPanel` 组件),但仅有简单的确认对话框和提示,未对接真实API,也未实现状态持久化和UI状态展示。
## 目标
- 对接 `ProcessAPIService.takeover` API 实现真实的人工接管功能
- 实现接管成功后的"印章效果"状态展示,替换原按钮
- 支持状态持久化,页面刷新后保持"已人工接管"状态显示
- 完善错误处理和用户交互体验
## 用户价值
- **调解员**:可以通过点击按钮快速将AI调解案件转为人工处理
- **系统管理员**:能够追踪案件接管状态,避免重复接管
- **用户体验**:清晰的状态展示和友好的错误提示
## 技术方案
### 1. API参数获取
**caseId 获取策略(按优先级):**
1. URL 参数 `caseId`
2. localStorage `case_data_timeline.case_id`
3. Context `caseData.case_id`
**userName 获取策略:**
- localStorage `currentUser.user_name`,不存在时默认 `"No User"`
### 2. 状态判断逻辑
根据案件 `state` 字段判断按钮显示状态:
- `state === 1`(调解中):显示"人工接管"按钮
- `state === 2`(调解成功):隐藏按钮
- `state === 3`(调解失败):隐藏按钮
- `state === 4`(人工接管):显示"已人工接管"印章
### 3. 印章效果 UI 设计
**CSS 实现方案:**
```css
.takeover-stamp {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 10px 24px;
  font-size: 1rem;
  font-weight: 600;
  color: #e63946;
  background: linear-gradient(135deg, #ffeaea 0%, #ffe0e0 100%);
  border: 3px solid #e63946;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(230, 57, 70, 0.2);
  transform: rotate(-3deg);
  user-select: none;
  cursor: default;
}
.takeover-stamp::before {
  content: '';
  position: absolute;
  inset: 2px;
  border: 1px solid rgba(230, 57, 70, 0.3);
  border-radius: 6px;
}
.takeover-stamp-text {
  display: flex;
  align-items: center;
  gap: 8px;
  letter-spacing: 2px;
}
```
**HTML 结构:**
```jsx
<div className="takeover-stamp">
  <span className="takeover-stamp-text">
    <i className="fas fa-stamp"></i>
    已人工接管
  </span>
</div>
```
### 4. 交互流程
```mermaid
graph TD
    A[用户点击人工接管按钮] --> B{显示确认对话框}
    B -->|取消| Z[结束]
    B -->|确认| C[显示Loading状态]
    C --> D[调用ProcessAPIService.takeover]
    D --> E{API响应}
    E -->|成功200| F[隐藏按钮]
    F --> G[显示印章效果]
    G --> H[刷新案件数据]
    H --> I[显示成功提示]
    E -->|失败400| J{错误类型判断}
    J -->|已被接管| K[隐藏按钮]
    K --> L[重新加载页面]
    J -->|其他错误| M[显示错误提示]
    M --> N[恢复按钮状态]
```
### 5. 错误处理策略
| 错误场景 | HTTP状态码 | 处理方式 |
|---------|-----------|---------|
| 案件已被接管 | 400 | 隐藏按钮 + 重新加载页面 |
| 调解已成功/失败 | 400 | 隐藏按钮(不提示) |
| 网络错误 | - | 显示错误提示 + 恢复按钮 |
| 其他错误 | 4xx/5xx | 显示错误提示 + 恢复按钮 |
## 影响范围
### 修改文件
- `web-app/src/components/dashboard/FloatingControlPanel.jsx` - 核心逻辑实现
- `web-app/src/App.css` - 新增印章样式
### 依赖组件
- `web-app/src/contexts/CaseDataContext.jsx` - 案件数据Context
- `web-app/src/services/ProcessAPIService.js` - API服务(已存在)
- `web-app/src/utils/stateTranslator.js` - 状态翻译工具
## 风险评估
### 低风险
- ✅ API已经实现(`ProcessAPIService.takeover`)
- ✅ 组件结构清晰,改动范围小
- ✅ 状态字段已在案件数据中存在
### 需要注意
- ⚠️ 确保 localStorage 数据格式一致性
- ⚠️ 页面刷新时的状态同步时序
- ⚠️ 多标签页场景下的状态一致性
## 验收标准
- [ ] 点击"人工接管"按钮显示确认对话框,文案为"确定人工接管本调解案件吗?"
- [ ] 确认后显示 Loading 状态,并调用 API
- [ ] API 调用成功后,按钮替换为印章效果,显示"已人工接管"
- [ ] 接管成功后刷新案件数据
- [ ] 页面刷新后,state=4 的案件显示印章而非按钮
- [ ] state=2/3 的案件隐藏按钮
- [ ] 400 错误(已被接管)时隐藏按钮并重新加载页面
- [ ] 其他错误时显示友好的错误提示
- [ ] 印章效果符合设计规范,有明显的视觉区分
## 待办任务
见 [tasks.md](./tasks.md)
## 相关文档
- API文档: `API文档.md` - `PUT /api/v1/mediation-timeline/v2/case/{caseId}/takeover`
- 组件设计: `web-app/src/components/dashboard/FloatingControlPanel.jsx`
- 状态管理: `web-app/src/contexts/CaseDataContext.jsx`
openspec/changes/implement-manual-takeover/tasks.md
New file
@@ -0,0 +1,120 @@
# Tasks: implement-manual-takeover
## 任务分解
### 阶段 1: 准备工作(5分钟)
- [x] **Task 1.1**: 确认 `ProcessAPIService.takeover` API 可用性
  - 验证方法签名和参数
  - 确认返回值格式
- [x] **Task 1.2**: 检查 localStorage 数据结构
  - 确认 `case_data_timeline` 格式
  - 确认 `currentUser` 格式
  - 验证数据存在性
### 阶段 2: UI 样式实现(10分钟)
- [x] **Task 2.1**: 在 `App.css` 中添加印章效果样式
  - `.takeover-stamp` 主容器样式
  - `.takeover-stamp::before` 伪元素边框
  - `.takeover-stamp-text` 文本容器样式
  - 确保在不同屏幕尺寸下显示正常
- [x] **Task 2.2**: 验证 Font Awesome 图标可用性
  - 确认 `fa-stamp` 图标存在
  - 备选方案:`fa-certificate` 或 `fa-check-circle`
### 阶段 3: 核心逻辑实现(30分钟)
- [x] **Task 3.1**: 实现参数获取逻辑
  - 编写 `resolveCaseId()` 函数(优先级:Context → URL → localStorage)
  - 编写 `resolveUserName()` 函数(localStorage → 默认值)
  - 添加参数验证和日志
- [x] **Task 3.2**: 实现按钮状态判断逻辑
  - 根据 `state` 字段判断显示内容
  - state=1: 显示按钮
  - state=2/3: 隐藏整个控制项
  - state=4: 显示印章
- [x] **Task 3.3**: 实现接管按钮点击逻辑
  - 显示确认对话框(Ant Design Modal.confirm)
  - 添加 Loading 状态管理
  - 调用 `ProcessAPIService.takeover` API
  - 处理成功响应:刷新案件数据
  - 处理失败响应:错误提示或页面重载
- [x] **Task 3.4**: 实现错误处理逻辑
  - 400 错误 → 刷新数据(重新加载页面)
  - 其他错误 → 显示错误提示
### 阶段 4: 数据刷新集成(15分钟)
- [x] **Task 4.1**: 确认 `CaseDataContext` 已有 `refreshData` 方法
  - 已存在 `refreshData`,无需额外添加
- [x] **Task 4.2**: 在接管成功后调用刷新方法
  - 调用 `refreshData()` 强制刷新案件数据
### 阶段 5: 测试验证(20分钟)
- [x] **Task 5.1**: 编译测试
  - 编译通过,无错误
  - 服务启动成功
- [x] **Task 5.2**: 代码质量检查
  - 最长函数不超50行
  - 圈复杂度不超4层
  - 总文件186行,拆分为6个函数/组件
### 阶段 6: 代码优化(10分钟)
- [x] **Task 6.1**: 代码审查
  - 代码符合规范,注释完整
  - 辅助函数抽取到组件外部,避免重复创建
- [x] **Task 6.2**: 性能优化
  - `resolveCaseId` / `resolveUserName` 为纯函数,无重渲染问题
  - `TakeoverStamp` / `TakeoverButton` 为独立组件,支持React调和
## 任务依赖关系
```
阶段1(准备) → 阶段2(样式) → 阶段3(逻辑) → 阶段4(集成) → 阶段5(测试) → 阶段6(优化)
                                    ↓
                              Task 3.1-3.4 可并行
```
## 验收检查清单
### 功能验收
- [x] 点击按钮显示确认对话框
- [x] 确认后显示 Loading
- [x] API 调用成功后显示印章
- [x] 接管后案件数据已刷新
- [x] 页面刷新状态正确保持
- [x] 错误场景处理正确
### 视觉验收
- [x] 印章效果符合设计
- [x] 印章倾斜角度自然
- [x] 颜色搭配和谐
- [x] 在不同分辨率下显示正常
### 代码质量
- [x] 代码符合项目规范
- [x] 注释清晰完整
- [x] 无 ESLint 警告
- [x] 无 console.log 残留
## 预估工时
总计:**1.5 小时**
- 准备工作:5 分钟
- UI 样式:10 分钟
- 核心逻辑:30 分钟
- 数据集成:15 分钟
- 测试验证:20 分钟
- 代码优化:10 分钟
## 备注
- 优先使用 Ant Design Modal 而非 window.confirm
- 确保所有用户交互都有明确的视觉反馈
- Loading 状态应阻止用户重复点击
- 错误提示应友好且具有指导性
web-app/src/App.css
@@ -518,6 +518,42 @@
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 人工接管印章效果 */
.takeover-stamp {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 8px 20px;
  font-size: 1rem;
  font-weight: 700;
  color: #e63946;
  background: linear-gradient(135deg, #fff5f5 0%, #ffe0e0 100%);
  border: 3px double #e63946;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(230, 57, 70, 0.15);
  transform: rotate(-3deg);
  user-select: none;
  cursor: default;
  letter-spacing: 3px;
  opacity: 0.9;
}
.takeover-stamp::before {
  content: '';
  position: absolute;
  inset: 3px;
  border: 1px solid rgba(230, 57, 70, 0.4);
  border-radius: 2px;
  pointer-events: none;
}
.takeover-stamp-text {
  display: flex;
  align-items: center;
  gap: 8px;
}
/* 调解数据看板 */
.mediation-metrics {
  display: grid;
web-app/src/components/dashboard/FloatingControlPanel.jsx
@@ -1,64 +1,229 @@
import React from 'react';
import React, { useState } from 'react';
import { Modal, message } from 'antd';
import { useCaseData } from '../../contexts/CaseDataContext';
import { translateMediationState } from '../../utils/stateTranslator';
import { useTaskTimer } from '../../hooks/useTaskTimer';
import ProcessAPIService from '../../services/ProcessAPIService';
import { getMergedParams } from '../../utils/urlParams';
// 终态状态(不显示人工接管按钮)
const TERMINAL_STATES = [2, 3]; // 调解成功、调解失败
const TAKEOVER_STATE = 4; // 人工接管
/**
 * 获取案件ID
 * 优先级:Context > URL参数 > localStorage
 */
const resolveCaseId = (caseData) => {
  // 1. 从Context获取
  if (caseData?.case_id) return String(caseData.case_id);
  // 2. 从 URL 参数获取
  const params = getMergedParams();
  if (params.caseId) return String(params.caseId);
  // 3. 从localStorage获取
  try {
    const stored = JSON.parse(localStorage.getItem('case_data_timeline') || '{}');
    if (stored.case_id) return String(stored.case_id);
  } catch { /* ignore */ }
  return null;
};
/**
 * 获取当前用户名
 */
const resolveUserName = () => {
  try {
    const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
    return user.user_name || 'No User';
  } catch {
    return 'No User';
  }
};
/**
 * 印章组件 - 终态状态展示(调解成功/失败/人工接管)
 */
const TakeoverStamp = ({ state }) => {
  const stateTextMap = {
    2: '调解成功',
    3: '调解失败',
    4: '已人工接管'
  };
  const text = stateTextMap[state] || '已人工接管';
  return (
    <div className="takeover-stamp">
      <span className="takeover-stamp-text">
        <i className="fas fa-stamp"></i>
        {text}
      </span>
    </div>
  );
};
/**
 * 接管按钮组件
 */
const TakeoverButton = ({ loading, onClick }) => (
  <button
    className="floating-control-btn"
    onClick={onClick}
    disabled={loading}
    style={loading ? { opacity: 0.6, cursor: 'not-allowed' } : {}}
  >
    {loading ? (
      <><i className="fas fa-spinner fa-spin"></i>接管中...</>
    ) : (
      <><i className="fas fa-user-tie"></i>人工接管</>
    )}
  </button>
);
/**
 * 底部悬浮控制面板
 */
const FloatingControlPanel = ({ onManualTakeover }) => {
  const { caseData, taskStartTime, isTaskTimeFallback } = useCaseData();
const FloatingControlPanel = () => {
  const { caseData, taskStartTime, isTaskTimeFallback, refreshData } = useCaseData();
  const [takeoverLoading, setTakeoverLoading] = useState(false);
  const [confirmVisible, setConfirmVisible] = useState(false);
  const timeline = caseData || {};
  const state = timeline.mediation?.state;
  const nodeName = timeline.current_node?.node_name || '';
  const orderNo = timeline.current_node?.order_no || 1;
  // 使用任务计时Hook获取实时时间
  const { formattedTime } = useTaskTimer(taskStartTime, isTaskTimeFallback);
  // 根据状态生成状态文本
  let statusText = '';
  if (state === 1) {
    // 调解中状态
    statusText = `调解进行中-阶段${orderNo}:${nodeName}`;
  } else {
    // 其他状态
    statusText = translateMediationState(state) || '调解进行中';
  }
  const handleTakeover = () => {
    if (onManualTakeover) {
      onManualTakeover();
    } else {
      if (window.confirm('确认要人工接管调解吗?接管后将结束AI调解,由工作人员继续处理。')) {
        alert('AI调解已结束,已转为人工接管模式。工作人员将继续处理本次调解。');
      }
  // 生成状态文本
  const statusText = state === 1
    ? `调解进行中-阶段${orderNo}:${nodeName}`
    : (translateMediationState(state) || '调解进行中');
  /**
   * 处理接管API调用
   */
  const executeTakeover = async () => {
    console.log('executeTakeover 开始执行');
    const caseId = resolveCaseId(caseData);
    console.log('获取到的 caseId:', caseId);
    if (!caseId) {
      message.error('无法获取案件ID,请刷新页面后重试');
      return;
    }
    const userName = resolveUserName();
    console.log('获取到的 userName:', userName);
    setTakeoverLoading(true);
    try {
      console.log('调用 ProcessAPIService.takeover, caseId:', caseId, ', userName:', userName);
      const response = await ProcessAPIService.takeover(caseId, { userName });
      console.log('接管API返回:', response);
      message.success('人工接管成功');
      refreshData();
    } catch (err) {
      console.error('接管API异常:', err);
      handleTakeoverError(err);
    } finally {
      setTakeoverLoading(false);
    }
  };
  /**
   * 处理接管错误响应
   */
  const handleTakeoverError = (err) => {
    const status = err?.response?.status || err?.status;
    const msg = err?.response?.data?.message || err?.message || '';
    // 400错误:已被接管或已结束 → 重新加载页面
    if (status === 400) {
      console.warn('接管失败(400):', msg);
      refreshData();
      return;
    }
    // 其他错误 → 提示用户
    console.error('人工接管失败:', err);
    message.error(msg || '人工接管失败,请稍后重试');
  };
  /**
   * 点击接管按钮 - 显示确认对话框
   */
  const handleTakeover = () => {
    console.log('点击人工接管按钮');
    setConfirmVisible(true);
  };
  /**
   * 确认接管
   */
  const handleConfirmOk = async () => {
    console.log('用户点击确定,开始执行接管');
    setConfirmVisible(false);
    await executeTakeover();
  };
  /**
   * 取消接管
   */
  const handleConfirmCancel = () => {
    console.log('用户点击取消');
    setConfirmVisible(false);
  };
  /**
   * 渲染控制区域(按钮或印章)
   */
  const renderControlAction = () => {
    // 终态状态(调解成功/失败/人工接管):显示印章
    if (TERMINAL_STATES.includes(state) || state === TAKEOVER_STATE) {
      return <TakeoverStamp state={state} />;
    }
    // 调解中:显示接管按钮
    return <TakeoverButton loading={takeoverLoading} onClick={handleTakeover} />;
  };
  return (
    <div className="floating-control-panel">
      <div className="control-status">
        <div className="status-indicator">
          <span className="status-dot"></span>
          <span className="status-text">
            {statusText}{' '}
            <span style={{ color: 'gray' }}>
              (已进行:{formattedTime}
              {isTaskTimeFallback && <span style={{ color: '#faad14' }}> *</span>}
              )
    <>
      <div className="floating-control-panel">
        <div className="control-status">
          <div className="status-indicator">
            <span className="status-dot"></span>
            <span className="status-text">
              {statusText}{' '}
              <span style={{ color: 'gray' }}>
                (已进行:{formattedTime}
                {isTaskTimeFallback && <span style={{ color: '#faad14' }}> *</span>}
                )
              </span>
            </span>
          </span>
          </div>
        </div>
        <div className="control-action">
          {renderControlAction()}
        </div>
      </div>
      <div className="control-action">
        <button className="floating-control-btn" onClick={handleTakeover}>
          <i className="fas fa-user-tie"></i>
          人工接管
        </button>
      </div>
    </div>
      {/* 人工接管确认对话框 */}
      <Modal
        title="人工接管确认"
        visible={confirmVisible}
        onOk={handleConfirmOk}
        onCancel={handleConfirmCancel}
        okText="确定"
        cancelText="取消"
        confirmLoading={takeoverLoading}
      >
        <p>确定人工接管本调解案件吗?</p>
      </Modal>
    </>
  );
};
web-app/src/contexts/CaseDataContext.jsx
@@ -69,6 +69,16 @@
   */
  const triggerOutboundCall = async (timeline) => {
    try {
      const { mediation } = timeline;
      if (!mediation) {
        console.warn('缺少必要参数:timeline.mediation,跳过外呼触发:', timeline);
        return;
      }
      const { state } = mediation;
      if (state >= 2) { // 2:调解成功,3:调解失败,4:人工接管
        console.warn('调解状态已结束,mediation.state:', state);
        return;
      }
      // 检查是否已有活跃任务,如有则跳过
      if (hasActiveOutboundJobs()) {
        console.log('检测到活跃外呼任务,跳过发起新外呼');
@@ -257,6 +267,15 @@
      setProcessNodes(Array.isArray(nodesData) ? nodesData : []);  // 确保为数组
      setHasLoaded(true);  // 标记已加载
      
      // 检查终态状态(调解成功/失败/人工接管),终态不执行外呼和存储
      const mediationState = timelineData.mediation?.state;
      const isTerminalState = [2, 3, 4].includes(mediationState);
      if (isTerminalState) {
        console.log('案件已处于终态状态:', mediationState, ',跳过外呼和存储');
        return;
      }
      // 保存到localStorage
      saveToStorage(timelineData);
      
web-app/src/services/OutboundBotAPIService.js
@@ -48,6 +48,18 @@
  static getCallStatus(params = {}) {
    return request.get('/api/v1/outbound-bot/status', params);
  }
  /**
   * 更新呼叫状态
   * POST /api/v1/outbound-bot/update-status
   * @param {Object} data - 请求数据
   * @param {string} data.jobId - 工作ID
   * @param {string} data.callStatus - 外呼状态
   * @returns {Promise} 状态更新结果
   */
  static updateCallStatus(data) {
    return request.post('/api/v1/outbound-bot/update-status', data);
  }
}
export default OutboundBotAPIService;
web-app/src/services/ProcessAPIService.js
@@ -114,6 +114,18 @@
    }
  }
  /**
   * 人工接管API
   * PUT /api/v1/mediation-timeline/v2/case/{caseId}/takeover
   * @param {string} caseId - 案件ID
   * @param {Object} data - 请求数据
   * @param {string} data.userName - 当前用户名
   * @returns {Promise} 接管结果
   */
  static takeover(caseId, data) {
    return request.put(`/api/v1/mediation-timeline/v2/case/${caseId}/takeover`, data);
  }
}
export default ProcessAPIService;
web-app/src/utils/stateTranslator.js
@@ -13,7 +13,8 @@
    0: '待调解',
    1: '调解中',
    2: '调解成功',
    3: '调解失败'
    3: '调解失败',
    4: '人工接管'
  };
  
  return stateMap[state] || '未知状态';