shimai
2026-04-03 6bb08c2297be1b6415c8bc02e6917eba6ee355e5
Merge remote-tracking branch 'origin/test/tony.cheng/260312' into test/shimai.huang/260309
16 files added
12 files modified
2506 ■■■■■ changed files
openspec/changes/add-call-record-viewer/design.md 198 ●●●●● patch | view | raw | blame | history
openspec/changes/add-call-record-viewer/proposal.md 78 ●●●●● patch | view | raw | blame | history
openspec/changes/add-call-record-viewer/specs/call-record-viewer/spec.md 123 ●●●●● patch | view | raw | blame | history
openspec/changes/add-call-record-viewer/tasks.md 107 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-mediation-state-control/design.md 143 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-mediation-state-control/proposal.md 58 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-mediation-state-control/specs/mediation-state-control/spec.md 134 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-mediation-state-control/tasks.md 55 ●●●●● patch | view | raw | blame | history
web-app/package-lock.json 61 ●●●● patch | view | raw | blame | history
web-app/package.json 1 ●●●● patch | view | raw | blame | history
web-app/src/App.css 53 ●●●●● patch | view | raw | blame | history
web-app/src/components/call-record/AudioPlayer.css 189 ●●●●● patch | view | raw | blame | history
web-app/src/components/call-record/AudioPlayer.jsx 228 ●●●●● patch | view | raw | blame | history
web-app/src/components/call-record/CallRecordModal.css 105 ●●●●● patch | view | raw | blame | history
web-app/src/components/call-record/CallRecordModal.jsx 243 ●●●●● patch | view | raw | blame | history
web-app/src/components/call-record/ConversationList.css 189 ●●●●● patch | view | raw | blame | history
web-app/src/components/call-record/ConversationList.jsx 88 ●●●●● patch | view | raw | blame | history
web-app/src/components/call-record/index.js 3 ●●●●● patch | view | raw | blame | history
web-app/src/components/common/OutboundCallWidget.jsx 12 ●●●●● patch | view | raw | blame | history
web-app/src/components/dashboard/FloatingControlPanel.jsx 202 ●●●●● patch | view | raw | blame | history
web-app/src/components/dashboard/TabContainer.jsx 94 ●●●● patch | view | raw | blame | history
web-app/src/contexts/CaseDataContext.jsx 47 ●●●●● patch | view | raw | blame | history
web-app/src/services/OutboundBotAPIService.js 30 ●●●●● patch | view | raw | blame | history
web-app/src/services/ProcessAPIService.js 20 ●●●●● patch | view | raw | blame | history
web-app/src/services/request.js 1 ●●●● patch | view | raw | blame | history
web-app/src/setupProxy.js 14 ●●●●● patch | view | raw | blame | history
web-app/src/utils/stateTranslator.js 5 ●●●●● patch | view | raw | blame | history
web-app/yarn.lock 25 ●●●● patch | view | raw | blame | history
openspec/changes/add-call-record-viewer/design.md
New file
@@ -0,0 +1,198 @@
# Design Document: AI调解通话记录查看功能
## 上下文
### 背景
云小调系统通过AI调解员自动拨打当事人电话进行初步沟通,调解员需要在实时看板中了解通话详情以便进行后续人工调解。当前系统缺少通话记录的详细展示功能。
### 约束条件
- 前端项目使用React + Ant Design 4.24.12
- 数据来源为 `OutboundBotAPIService.getConversationLog` API
- 需要兼容多种外呼状态(已接通、未接通等)
- 录音文件为.wav格式
### 相关利益相关者
- 调解员:需要快速查看通话记录了解沟通情况
- 产品经理:关注用户体验和功能完整性
## 目标与非目标
### 目标
- 提供直观的通话记录查看入口
- 展示清晰的双方对话内容
- 支持录音文件播放
- 正确处理各种外呼状态
### 非目标
- 不实现通话记录的编辑功能
- 不实现通话记录的导出功能
- 不实现录音文件的下载功能(本期暂不实现)
## 技术决策
### 1. UI组件结构
**决策**:使用Ant Design Modal作为弹窗容器,自定义内部组件
**理由**:
- Modal组件与系统其他弹窗风格一致
- 内部需要高度自定义的聊天界面样式
- 便于后续扩展其他功能
**组件层级**:
```
MediationBoard
└── BoardItem (调解记录卡片)
    └── CallRecordButton (通话记录按钮)
        └── CallRecordModal (通话记录弹窗)
            ├── AudioPlayer (录音播放器)
            └── ConversationList (对话记录列表)
                └── ConversationItem (单条对话)
```
### 2. 数据获取策略
**决策**:按需加载,点击按钮时调用API
**理由**:
- 避免列表加载时请求数量过多
- 减少不必要的API调用
- 提升页面初始加载速度
**API调用流程**:
```
用户点击按钮
  → 获取caseId(URL参数 > localStorage)
  → 获取person_id和job_id(调解记录数据)
  → 调用getConversationLog API
  → 处理返回数据(取最后一条)
  → 渲染弹窗内容
```
### 3. 外呼状态处理
**决策**:根据callStatus字段区分展示内容
**状态分类**:
| 状态类型 | callStatus值 | 展示内容 |
|---------|-------------|---------|
| 已接通 | 1, 20-31 | 录音播放器 + 对话记录 |
| 未接通 | 0, 2-19, 32 | 提示"未接通,无通话记录" |
### 4. 对话记录展示设计
**决策**:采用类似微信聊天的左右布局
**设计规范**:
- **AI调解员消息**:
  - 位置:左侧
  - 头像:机器人图标
  - 背景:浅蓝色(#e3f2fd)
  - 名称显示:"AI调解员"
- **当事人消息**:
  - 位置:右侧
  - 头像:显示名字首字
  - 背景:浅绿色(#e8f5e9)
  - 名称显示:调解记录中的creator字段值
**时间戳格式**:`YYYY-MM-DD HH:mm`
### 5. 录音播放器设计
**决策**:使用HTML5原生audio标签
**理由**:
- 原生支持.wav格式
- 无需额外依赖
- 浏览器兼容性好
**样式设计**:
- 位置:弹窗顶部固定
- 背景:浅灰色(#f5f5f5)
- 高度:50px
- 宽度:100%
## 替代方案
### 方案1:使用第三方聊天UI库
- **优点**:开箱即用,功能完善
- **缺点**:引入额外依赖,样式可能不一致
- **决策**:不采用,保持项目简洁
### 方案2:在卡片内直接展示对话
- **优点**:无需弹窗,操作更直接
- **缺点**:占用空间大,影响列表展示
- **决策**:不采用,使用弹窗更合理
## 风险与权衡
### 风险1:录音文件URL无效
- **概率**:中
- **影响**:用户无法播放录音
- **缓解措施**:
  - 提供明确的错误提示
  - 支持用户手动重试
  - 记录错误日志便于排查
### 风险2:API响应缓慢
- **概率**:低
- **影响**:用户体验下降
- **缓解措施**:
  - 显示加载状态
  - 设置合理的超时时间(30秒)
  - 提供取消操作选项
### 风险3:对话内容过长
- **概率**:高
- **影响**:弹窗内容溢出
- **缓解措施**:
  - 弹窗设置最大高度
  - 内部区域支持滚动
  - 对话列表虚拟化(如内容过多)
## 迁移计划
### 实施步骤
1. 创建通话记录弹窗组件
2. 在MediationBoard组件中添加按钮和弹窗触发逻辑
3. 实现API调用和数据转换
4. 添加样式和交互效果
5. 处理各种边界情况
### 回滚方案
- 功能通过feature flag控制,出问题可直接禁用
- 按钮点击事件独立封装,不影响现有调解记录展示
## 开放问题
1. **录音文件存储周期**:需要确认录音文件的保存时长,避免播放失败
2. **并发限制**:是否需要限制同时打开的弹窗数量
3. **权限控制**:是否所有调解员都能查看通话记录
## 参考设计
### 对话记录UI设计稿
```
┌─────────────────────────────────────────────────────────────┐
│  AI调解员与申请人(刘树杰)的通话              任务ID: xxx  │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────────┐    │
│  │  🔊 录音播放器  [▶播放] ━━━━━━━●─────── 03:45/10:20 │    │
│  └─────────────────────────────────────────────────────┘    │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────┐  你好,刘树杰先生吗?我是白云区...               │
│  │  AI  │  2026-03-12 16:30                                │
│  └──────┘                                                   │
│                                                              │
│        呃,你刚才前面说你是谁?                    ┌──────┐ │
│        2026-03-12 16:33                          │  刘  │ │
│                                                   └──────┘ │
│                                                              │
│  ┌──────┐  哦,不好意思,我是白云区人和镇...                │
│  │  AI  │  2026-03-12 16:33                                │
│  └──────┘                                                   │
│                                                              │
└─────────────────────────────────────────────────────────────┘
```
openspec/changes/add-call-record-viewer/proposal.md
New file
@@ -0,0 +1,78 @@
# Proposal: 在AI调解实时看板增加通话记录查看功能
## Change ID
`add-call-record-viewer`
## Summary
在AI调解实时看板的每条调解记录中增加"通话记录"查看功能,允许调解员查看AI调解员与当事人(申请人/被申请人)的外呼通话详情,包括录音播放和文字对话记录。
## Why
当前AI调解实时看板仅展示调解记录的文字摘要,调解员无法了解AI调解员与当事人之间具体的通话内容和沟通过程。通过增加通话记录查看功能,调解员可以:
- 回顾AI调解员与当事人的完整对话过程
- 了解当事人的真实诉求和态度变化
- 为后续人工介入调解提供更全面的背景信息
- 作为调解过程的重要证据留存
## What Changes
- **前端UI变更**
  - 在每条调解记录的角色名称后增加"通话记录"胶囊按钮
  - 新增通话记录详情弹窗组件
  - 弹窗顶部显示录音文件播放器
  - 弹窗中间区域展示双方对话记录(类似微信聊天界面)
- **API集成**
  - 调用 `OutboundBotAPIService.getConversationLog` 获取通话记录
  - 处理多种外呼状态的展示逻辑
- **交互逻辑**
  - 已接通状态:显示完整通话记录和录音播放
  - 未接通状态:显示提示信息"未接通,无通话记录"
## Impact
- **受影响的规格**
  - 新增能力:`call-record-viewer`(通话记录查看)
- **受影响的代码**
  - `web-app/src/components/dashboard/TabContainer.jsx` - MediationBoard组件增加通话记录按钮和弹窗
  - `web-app/src/services/OutboundBotAPIService.js` - 确认getConversationLog接口参数
  - `web-app/src/components/dashboard/TabContainer.css` - 新增通话记录弹窗样式
## Stakeholders
- 调解员:主要使用者,需要通过通话记录了解调解过程细节
- 系统管理员:关注通话记录数据的安全性和准确性
- 产品负责人:确保功能符合业务需求
## Success Criteria
- 每条调解记录后正确显示"通话记录"胶囊按钮
- 点击按钮能正确弹出通话记录详情弹窗
- 弹窗标题正确显示"AI调解员与xxx(角色名)的通话"
- 已接通状态能正确播放录音和展示对话记录
- 未接通状态显示正确的提示信息
- 对话记录以类似微信聊天的样式展示,能区分双方
- API调用失败时有合理的错误提示
## Risks & Mitigations
- **风险**:录音文件加载失败或格式不支持
  - **缓解**:提供明确的错误提示,支持用户手动重试
- **风险**:大量通话记录数据导致加载缓慢
  - **缓解**:按需加载,仅在点击按钮时请求数据
- **风险**:不同浏览器音频播放兼容性
  - **缓解**:使用HTML5标准audio标签,提供格式兼容性检测
## Dependencies
- 已存在的 `OutboundBotAPIService.getConversationLog` API
- `ProcessAPIService.getProcessRecords` 返回的调解记录数据结构(需包含person_id和job_id)
- 案件数据Context中的caseId
## Timeline
预计开发时间:2个工作日
- UI设计与开发:1天
- API集成与联调:0.5天
- 测试与优化:0.5天
## Alternatives Considered
1. **在外呼气泡组件中查看通话记录**:与调解记录脱节,用户体验不连贯
2. **跳转到独立的通话记录页面**:增加页面跳转复杂度,不符合快速查看的场景
3. **仅在已接通记录显示按钮**:未接通状态用户无法了解情况,信息不完整
选择在调解记录卡片内直接查看的方式,保持用户操作的连贯性和上下文关联。
openspec/changes/add-call-record-viewer/specs/call-record-viewer/spec.md
New file
@@ -0,0 +1,123 @@
# Call Record Viewer Specification
## ADDED Requirements
### Requirement: 通话记录查看按钮
系统 SHALL 在AI调解实时看板的每条调解记录角色名称后显示通话记录查看按钮。
#### Scenario: 显示通话记录按钮
- **WHEN** 调解记录加载完成
- **THEN** 在每条记录的角色名称后显示胶囊形状的"通话记录"按钮
- **AND** 按钮包含电话图标和"通话记录"文字
#### Scenario: 点击通话记录按钮
- **WHEN** 用户点击通话记录按钮
- **THEN** 系统应打开通话记录详情弹窗
- **AND** 弹窗标题格式为"AI调解员与[角色名]的通话"
- **AND** 标题后显示任务ID(jobId)
### Requirement: 通话记录弹窗数据加载
系统 SHALL 在打开通话记录弹窗时调用API获取通话记录数据。
#### Scenario: 获取通话记录数据
- **WHEN** 用户点击通话记录按钮
- **THEN** 系统应调用OutboundBotAPIService.getConversationLog API
- **AND** API参数应包含:
  - caseId:从URL参数获取,若为空则从localStorage的case_data_timeline中获取case_id
  - personId:从调解记录的person_id字段获取
  - jobId:从调解记录的job_id字段获取
#### Scenario: API返回多条数据
- **WHEN** API返回的data数组包含多条记录
- **THEN** 系统应只使用最后一条记录进行展示
#### Scenario: API调用失败
- **WHEN** API调用失败或返回错误
- **THEN** 弹窗应显示错误提示信息
- **AND** 提供重试按钮
### Requirement: 外呼状态展示
系统 SHALL 根据外呼状态(callStatus)展示不同的内容。
#### Scenario: 已接通状态展示
- **WHEN** callStatus为已接通状态(值为1, 20-31之一)
- **THEN** 弹窗顶部应显示录音播放器
- **AND** 弹窗中间区域应显示双方对话记录
#### Scenario: 未接通状态展示
- **WHEN** callStatus为未接通状态(值为0, 2-19, 32之一)
- **THEN** 弹窗应显示提示信息"未接通,无通话记录"
- **AND** 不显示录音播放器和对话记录
### Requirement: 录音播放器
系统 SHALL 在已接通状态的通话记录弹窗中提供录音播放功能。
#### Scenario: 录音播放器展示
- **WHEN** 通话记录处于已接通状态且有recordUrl
- **THEN** 弹窗顶部应显示HTML5音频播放器
- **AND** 播放器应支持播放/暂停操作
- **AND** 播放器应显示当前播放进度和总时长
#### Scenario: 录音文件播放
- **WHEN** 用户点击播放按钮
- **THEN** 系统应播放recordUrl指定的.wav格式录音文件
- **AND** 录音文件URL可直接使用,无需拼接
#### Scenario: 录音加载失败
- **WHEN** 录音文件加载失败
- **THEN** 播放器应显示"录音文件加载失败"提示
- **AND** 提供重试按钮
### Requirement: 对话记录展示
系统 SHALL 以类似微信聊天的样式展示双方对话记录。
#### Scenario: 解析对话记录
- **WHEN** 获取到通话记录数据
- **THEN** 系统应将conversations JSON字符串解析为对象数组
- **AND** 每个对话项包含timestamp、speaker、script、action字段
#### Scenario: AI调解员消息展示
- **WHEN** 对话记录的speaker为"Robot"
- **THEN** 消息气泡应显示在左侧
- **AND** 头像应显示机器人图标
- **AND** 头像下方应显示"AI调解员"
- **AND** 消息背景色应为浅蓝色(#e3f2fd)
#### Scenario: 当事人消息展示
- **WHEN** 对话记录的speaker为"Contact"
- **THEN** 消息气泡应显示在右侧
- **AND** 头像应显示当事人名字的首字
- **AND** 头像下方应显示调解记录中的creator字段值
- **AND** 消息背景色应为浅绿色(#e8f5e9)
#### Scenario: 时间戳展示
- **WHEN** 对话记录包含有效timestamp
- **THEN** 应在消息气泡下方显示格式化时间
- **AND** 时间格式应为"YYYY-MM-DD HH:mm"
#### Scenario: 过滤无效对话
- **WHEN** 对话记录的script字段为null或空字符串
- **THEN** 系统不应展示该条对话记录
### Requirement: 对话记录弹窗样式
系统 SHALL 为通话记录弹窗提供清晰美观的样式设计。
#### Scenario: 弹窗尺寸
- **WHEN** 通话记录弹窗打开
- **THEN** 弹窗宽度应为600px
- **AND** 弹窗最大高度应为视口高度的80%
- **AND** 对话记录区域应支持垂直滚动
#### Scenario: 对话区域滚动
- **WHEN** 对话记录内容超过可视区域高度
- **THEN** 对话区域应显示垂直滚动条
- **AND** 滚动条样式应与系统整体风格一致
#### Scenario: 响应式适配
- **WHEN** 屏幕宽度小于600px
- **THEN** 弹窗宽度应自适应屏幕宽度
- **AND** 保持适当的左右边距
## Related Capabilities
- 外呼通话API接口规范 (outbound-call-api)
- AI调解实时看板 (mediation-dashboard)
openspec/changes/add-call-record-viewer/tasks.md
New file
@@ -0,0 +1,107 @@
# Tasks for add-call-record-viewer
## Task List
### Phase 1: 组件开发
- [x] 1.1 创建通话记录弹窗组件 `CallRecordModal.jsx`
  - 定义组件props接口(visible, onClose, record等)
  - 创建弹窗基本结构
  - 添加加载状态和错误状态处理
- [x] 1.2 实现录音播放器组件 `AudioPlayer.jsx`
  - 使用HTML5 audio标签
  - 添加播放/暂停控制
  - 显示播放进度和时长
  - 处理音频加载错误
- [x] 1.3 实现对话记录列表组件 `ConversationList.jsx`
  - 解析conversations JSON字符串
  - 实现左右布局(AI左侧,当事人右侧)
  - 添加时间戳格式化显示
  - 区分不同说话人的样式
### Phase 2: API集成
- [x] 2.1 确认getConversationLog API参数
  - 检查API service中的参数定义
  - 确认params字段名称(caseId/personId/jobId)
- [x] 2.2 实现API调用逻辑
  - 从URL参数或localStorage获取caseId
  - 从调解记录获取person_id和job_id
  - 调用getConversationLog API
  - 处理API返回数据(取最后一条记录)
- [x] 2.3 实现数据转换函数
  - 将conversations JSON字符串转为对象数组
  - 过滤无效对话记录(script为null)
  - 格式化时间戳显示
### Phase 3: UI集成
- [x] 3.1 在调解记录卡片中添加通话记录按钮
  - 在角色名称后添加胶囊按钮
  - 按钮样式:电话图标 + "通话记录"文字
  - 添加hover效果
- [x] 3.2 实现按钮点击逻辑
  - 点击按钮打开弹窗
  - 传递必要的参数(record数据)
  - 禁用重复点击
- [x] 3.3 实现外呼状态展示
  - 已接通状态:显示完整通话记录
  - 未接通状态:显示提示信息
  - 添加状态判断逻辑
### Phase 4: 样式完善
- [x] 4.1 添加通话记录弹窗样式
  - 设置弹窗尺寸(宽度600px,最大高度80vh)
  - 录音播放器样式
  - 对话记录列表样式
  - 滚动条样式优化
- [x] 4.2 添加对话气泡样式
  - AI调解员气泡(左侧蓝色)
  - 当事人气泡(右侧绿色)
  - 头像样式
  - 时间戳样式
- [x] 4.3 添加响应式适配
  - 小屏幕下弹窗宽度自适应
  - 对话内容过长时的省略处理
### Phase 5: 测试与优化
- [x] 5.1 功能测试
  - 测试已接通状态的通话记录展示
  - 测试未接通状态的提示信息
  - 测试录音播放功能
  - 测试对话记录滚动
- [x] 5.2 边界情况测试
  - API调用失败的错误提示
  - 录音文件加载失败的处理
  - 空对话记录的处理
  - 对话内容过长的情况
- [x] 5.3 性能优化
  - 弹窗内容按需渲染
  - 避免不必要的重渲染
  - 优化长列表滚动性能
### Phase 6: 文档更新
- [x] 6.1 更新相关OpenSpec文档
  - 确保design.md和proposal.md与实现一致
  - 更新项目功能树
## Dependencies
- Task 2.1 依赖 Task 1.1(需要先有组件结构)
- Task 3.1-3.3 依赖 Task 2.2(需要API调用逻辑)
- Task 4.1-4.3 依赖 Task 1.2-1.3(需要有组件才能添加样式)
- Task 5.1-5.3 依赖所有前置任务
## Validation Criteria
每个任务完成后需要满足:
- 代码通过ESLint检查
- 功能在本地开发环境中正常工作
- 不引入新的编译警告或错误
- 符合现有的代码风格和架构模式
- UI样式与系统整体风格一致
openspec/changes/implement-mediation-state-control/design.md
New file
@@ -0,0 +1,143 @@
# Design Document: AI调解状态控制功能
## 架构概述
本功能将在现有的调解看板界面中添加状态控制能力,允许用户暂停或恢复AI自动调解流程。设计遵循现有系统的组件结构和交互模式。
## 组件设计
### 1. 状态控制按钮组件
**位置**:位于FloatingControlPanel组件中,人工接管按钮左侧
**显示逻辑**:
```
const stateNum = Number(state);
if (stateNum === 1) {
  // 显示"终止"按钮,红色渐变样式
  buttonText = "终止";
  buttonClass = "state-control-btn--terminate";
} else if (stateNum === 5) {
  // 显示"恢复"按钮,绿色渐变样式
  buttonText = "恢复";
  buttonClass = "state-control-btn--resume";
} else {
  // 不显示按钮
  showButton = false;
}
```
### 2. 状态显示规则
**状态文本显示**:
- state=1: "调解进行中-阶段X:节点名称"
- state=5: "AI调解暂停中"
- 其他状态: 使用translateMediationState翻译
**状态圆点颜色**:
- state=5: 红色 (#e63946)
- 其他状态: 默认绿色 (var(--success-color))
### 2. 确认对话框设计
**触发条件**:用户点击状态控制按钮
**内容结构**:
- 标题:根据操作类型显示"确认终止调解"或"确认恢复调解"
- 描述文本:说明操作的影响
- 输入框:可选的备注信息
- 操作按钮:确认/取消
### 3. 状态管理集成
**数据来源**:从CaseDataContext获取案件状态
**更新机制**:API调用成功后重新加载案件数据
### 4. 外呼气泡联动关闭
**触发条件**:终止操作API调用成功后
**实现机制**:
1. FloatingControlPanel触发自定义事件`mediation-terminated`
2. OutboundCallWidget监听该事件
3. 事件处理:
   - 设置isVisible=false关闭气泡
   - 设置isMinimized=true最小化
   - 清空localStorage外呼任务数据
   - 清空组件状态中的通话列表
## API集成设计
### 请求流程
```
1. 用户点击按钮
2. 显示确认对话框
3. 用户确认操作
4. 调用ProcessAPIService.updateMediationState
5. 处理API响应
6. 成功:刷新页面数据
7. 失败:显示错误提示
```
### 错误处理策略
- 网络错误:显示通用网络错误提示
- 业务错误:显示具体的错误信息
- 状态冲突:提示当前状态不允许该操作
## UI/UX设计
### 视觉设计
**终止按钮**(红色渐变主题):
- 背景:linear-gradient(135deg, #e63946 0%, #c1121f 100%)
- 阴影:0 2px 8px rgba(230, 57, 70, 0.3)
- 文字颜色:白色
- 悬停效果:背景变为linear-gradient(135deg, #f04a57 0%, #d41926 100%)
- 悬停阴影:0 4px 16px rgba(230, 57, 70, 0.4)
**恢复按钮**(绿色渐变主题):
- 背景:linear-gradient(135deg, #52c41a 0%, #389e0d 100%)
- 阴影:0 2px 8px rgba(82, 196, 26, 0.3)
- 文字颜色:白色
- 悬停效果:背景变为linear-gradient(135deg, #5fd42b 0%, #42b417 100%)
- 悬停阴影:0 4px 16px rgba(82, 196, 26, 0.4)
**样式类名规范**:
- 基础类:`.state-control-btn`
- 终止变体:`.state-control-btn--terminate`
- 恢复变体:`.state-control-btn--resume`
- 与人工接管按钮样式(`.floating-control-btn`)完全隔离
### 交互设计
**加载状态**:
- 按钮显示loading状态
- 禁用其他相关操作
- 显示操作进度提示
**反馈机制**:
- 操作成功:显示成功消息,自动刷新数据
- 操作失败:显示错误消息,保持当前界面状态
## 技术实现考虑
### 性能优化
- 状态检查在组件渲染时进行,避免不必要的重渲染
- API调用使用现有的请求拦截器和错误处理机制
- 页面刷新采用增量更新而非全页面重载
### 安全考虑
- 操作前进行身份验证检查
- 记录操作日志用于审计
- 防止重复提交同一操作
### 可维护性
- 遵循现有的组件命名和结构规范
- 复用现有的样式和交互组件
- 保持与人工接管功能的一致性
## 测试策略
### 单元测试
- 按钮显示逻辑的边界条件测试
- API调用成功/失败场景测试
- 状态转换的正确性验证
### 集成测试
- 完整的用户操作流程测试
- 与现有功能的兼容性测试
- 异常场景的恢复能力测试
### 用户验收测试
- 实际调解场景下的功能验证
- 易用性和直观性评估
- 性能影响评估
openspec/changes/implement-mediation-state-control/proposal.md
New file
@@ -0,0 +1,58 @@
# Proposal: 实现AI调解状态控制功能(终止/恢复)
## Change ID
`implement-mediation-state-control`
## Summary
在调解看板页面增加"终止"或"恢复"功能按钮,允许用户对正在进行的AI自动调解进行暂停和恢复操作。按钮位于人工接管按钮右侧,根据案件当前状态动态显示不同的文本和样式。
## Motivation
当前系统缺乏对AI自动调解过程的灵活控制机制。当调解遇到特殊情况(如需要人工干预、当事人临时沟通等)时,调解员需要能够暂停AI调解流程,并在适当时机恢复调解。此功能将提升系统的实用性和用户体验。
## Requirements Overview
- 在调解看板页面添加状态控制按钮
- 根据案件状态动态显示按钮文本和样式
- 实现确认对话框机制
- 调用后端API进行状态变更
- 状态变更后自动刷新页面数据
## Stakeholders
- 调解员:主要使用者,需要灵活控制调解流程
- 系统管理员:关注功能稳定性和安全性
- 产品负责人:确保功能符合业务需求
## Success Criteria
- 按钮正确显示在人工接管按钮右侧
- 状态为1时显示"终止"按钮(红色渐变样式)
- 状态为5时显示"恢复"按钮(绿色渐变样式)
- 其他状态下不显示该按钮
- 点击按钮后正确显示确认对话框
- API调用成功后页面数据正确刷新
- API调用失败时显示相应错误提示
- 终止操作成功后自动关闭外呼气泡组件
- state=5时状态文本显示为"AI调解暂停中"
- state=5时状态圆点显示为红色
## Risks & Mitigations
- **风险**:频繁的状态变更可能影响调解流程的一致性
  - **缓解**:添加操作日志记录,便于追踪状态变更历史
- **风险**:并发操作可能导致状态不一致
  - **缓解**:在后端实现状态变更的原子性控制
## Dependencies
- 已存在的`ProcessAPIService.updateMediationState` API
- 案件状态管理机制
- 现有的人工接管功能实现
## Timeline
预计开发时间:2-3个工作日
- 设计和评审:0.5天
- 前端实现:1.5天
- 测试和验证:1天
## Alternatives Considered
1. **在独立页面管理调解状态**:增加了用户操作复杂度,不符合当前一体化界面设计理念
2. **通过右键菜单实现**:隐藏了重要功能,不利于用户发现和使用
3. **自动状态检测和恢复**:缺乏人工控制灵活性,可能在不适当的时候触发状态变更
选择在现有界面中直接添加按钮的方式,既保持了界面的一致性,又提供了直观的操作方式。
openspec/changes/implement-mediation-state-control/specs/mediation-state-control/spec.md
New file
@@ -0,0 +1,134 @@
# Mediation State Control Specification
## ADDED Requirements
### Requirement: 状态控制按钮显示逻辑
系统 SHALL 根据案件当前状态动态显示状态控制按钮。
#### Scenario: 案件处于进行中状态
Given 案件状态为1(进行中)
When 页面加载时
Then 应显示"终止"按钮,样式为红色渐变
#### Scenario: 案件处于暂停状态
Given 案件状态为5(暂停)
When 页面加载时
Then 应显示"恢复"按钮,样式为绿色渐变
#### Scenario: 案件处于其他状态
Given 案件状态为0(初始)、2(成功)、3(失败)、4(人工接管)或其他状态
When 页面加载时
Then 不应显示状态控制按钮
### Requirement: 确认对话框机制
用户点击状态控制按钮时 SHALL 显示确认对话框。
#### Scenario: 用户点击终止按钮
Given 用户看到"终止"按钮
When 用户点击该按钮
Then 应显示确认对话框,标题为"确认终止调解"
And 对话框应包含操作说明文本
And 应提供确认和取消按钮
#### Scenario: 用户点击恢复按钮
Given 用户看到"恢复"按钮
When 用户点击该按钮
Then 应显示确认对话框,标题为"确认恢复调解"
And 对话框应包含操作说明文本
And 应提供确认和取消按钮
### Requirement: API调用和状态更新
确认操作后 SHALL 调用API并更新页面状态。
#### Scenario: 成功终止调解
Given 用户确认终止操作
When 系统调用ProcessAPIService.updateMediationState({action: 0})
And API返回成功响应
Then 应显示成功消息"调解已终止"
And 应触发mediation-terminated事件关闭外呼气泡
And 应重新加载当前页面数据
And 按钮状态应相应更新
#### Scenario: 成功恢复调解
Given 用户确认恢复操作
When 系统调用ProcessAPIService.updateMediationState({action: 1})
And API返回成功响应
Then 应显示成功消息"调解已恢复"
And 应重新加载当前页面数据
And 按钮状态应相应更新
#### Scenario: API调用失败
Given 用户确认操作
When 系统调用API失败
Then 应显示相应的错误消息
And 页面状态应保持不变
And 按钮应恢复到可点击状态
### Requirement: 加载状态管理
操作过程中 SHALL 提供适当的加载状态反馈。
#### Scenario: API调用期间
Given 用户已确认操作
When 系统正在调用API
Then 状态控制按钮应显示loading状态
And 应禁用相关操作按钮
And 应显示操作进度提示
#### Scenario: 页面刷新期间
Given API调用成功
When 系统正在刷新页面数据
Then 应显示数据加载指示器
And 应暂时禁用用户交互
### Requirement: 状态显示规则
系统 SHALL 根据案件状态显示相应的状态文本和指示器颜色。
#### Scenario: 进行中状态显示
Given 案件状态为1(进行中)
When 页面显示状态信息时
Then 状态文本应显示为"调解进行中-阶段X:节点名称"
And 状态圆点应显示为绿色
#### Scenario: 暂停状态显示
Given 案件状态为5(暂停)
When 页面显示状态信息时
Then 状态文本应显示为"AI调解暂停中"
And 状态圆点应显示为红色(#e63946)
### Requirement: 外呼气泡联动关闭
终止操作成功后 SHALL 自动关闭外呼气泡组件。
#### Scenario: 终止成功后关闭外呼气泡
Given 用户成功执行终止操作
When API返回成功响应
Then 系统应触发自定义事件"mediation-terminated"
And OutboundCallWidget应监听该事件
And 外呼气泡应自动关闭(isVisible=false)
And 外呼任务数据应从localStorage中清除
## MODIFIED Requirements
### Requirement: 按钮组件位置调整
状态控制按钮 SHALL 位于FloatingControlPanel组件中。
#### Scenario: 按钮容器布局
Given 页面包含FloatingControlPanel组件
When 添加状态控制按钮后
Then 状态控制按钮和人工接管按钮应水平排列
And 状态控制按钮应位于人工接管按钮左侧
And 按钮间应有15px的间距
#### Scenario: 样式隔离
Given 页面同时包含状态控制按钮和人工接管按钮
When 渲染页面时
Then 状态控制按钮应使用独立的CSS类名(state-control-btn)
And 人工接管按钮应使用独立的CSS类名(floating-control-btn)
And 两种按钮样式应完全隔离互不影响
## REMOVED Requirements
## Related Capabilities
- 人工接管功能 (implement-manual-takeover)
- 案件状态管理 (case-state-management)
- API集成规范 (api-integration-spec)
openspec/changes/implement-mediation-state-control/tasks.md
New file
@@ -0,0 +1,55 @@
# Tasks for implement-mediation-state-control
## Task List
### Phase 1: 设计和准备工作
- [ ] 创建功能规格说明文档
- [ ] 确认UI设计细节(按钮样式、位置、交互效果)
- [ ] 评审技术实现方案
### Phase 2: 前端实现
- [x] 在TabContainer组件中添加状态控制按钮
- [x] 将按钮逻辑迁移到FloatingControlPanel组件
- [x] 实现按钮显示逻辑(根据案件状态动态显示)
- [x] 添加确认对话框组件
- [x] 实现API调用逻辑
- [x] 添加页面刷新机制
- [x] 实现错误处理和提示
- [x] 实现终止后外呼气泡联动关闭功能
### Phase 3: 样式和交互优化
- [x] 调整按钮样式(终止按钮红色渐变,恢复按钮绿色渐变)
- [x] 实现状态文本显示规则(state=5显示"AI调解暂停中")
- [x] 实现状态圆点颜色规则(state=5显示红色)
- [x] 优化确认对话框的用户体验
- [x] 添加加载状态指示
- [x] 确保响应式设计兼容性
- [x] 使用独立CSS类名实现样式隔离
### Phase 4: 测试和验证
- [x] 单元测试按钮显示逻辑
- [x] 集成测试API调用流程
- [x] 测试state=5状态显示(文本和圆点颜色)
- [x] 测试外呼气泡联动关闭功能
- [ ] 用户验收测试
- [ ] 性能测试(确保不会影响页面加载速度)
- [ ] 跨浏览器兼容性测试
### Phase 5: 文档和部署
- [ ] 更新用户手册
- [ ] 编写开发文档
- [ ] 部署到测试环境
- [ ] 生产环境部署
## Dependencies
- Task 2 依赖 Task 1 的完成
- Task 3 依赖 Task 2 的完成
- Task 4 依赖 Task 3 的完成
- Tasks 5-6 可以并行进行
## Validation Criteria
每个任务完成后需要满足:
- 代码通过ESLint检查
- 功能在本地开发环境中正常工作
- 不引入新的编译警告或错误
- 符合现有的代码风格和架构模式
web-app/package-lock.json
@@ -14,6 +14,7 @@
        "@testing-library/user-event": "^13.2.1",
        "antd": "4.24.12",
        "axios": "^1.13.4",
        "http-proxy-middleware": "^3.0.5",
        "react": "^19.2.3",
        "react-dom": "^19.2.3",
        "react-router-dom": "^6.22.3",
@@ -9064,27 +9065,20 @@
      }
    },
    "node_modules/http-proxy-middleware": {
      "version": "2.0.9",
      "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
      "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
      "version": "3.0.5",
      "resolved": "https://registry.npmmirror.com/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
      "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
      "license": "MIT",
      "dependencies": {
        "@types/http-proxy": "^1.17.8",
        "@types/http-proxy": "^1.17.15",
        "debug": "^4.3.6",
        "http-proxy": "^1.18.1",
        "is-glob": "^4.0.1",
        "is-plain-obj": "^3.0.0",
        "micromatch": "^4.0.2"
        "is-glob": "^4.0.3",
        "is-plain-object": "^5.0.0",
        "micromatch": "^4.0.8"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "peerDependencies": {
        "@types/express": "^4.17.13"
      },
      "peerDependenciesMeta": {
        "@types/express": {
          "optional": true
        }
        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
      }
    },
    "node_modules/https-proxy-agent": {
@@ -9585,7 +9579,7 @@
    },
    "node_modules/is-plain-obj": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
      "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
      "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
      "license": "MIT",
      "engines": {
@@ -9593,6 +9587,15 @@
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/is-plain-object": {
      "version": "5.0.0",
      "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz",
      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-potential-custom-element-name": {
@@ -17364,6 +17367,30 @@
        }
      }
    },
    "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": {
      "version": "2.0.9",
      "resolved": "https://registry.npmmirror.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
      "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
      "license": "MIT",
      "dependencies": {
        "@types/http-proxy": "^1.17.8",
        "http-proxy": "^1.18.1",
        "is-glob": "^4.0.1",
        "is-plain-obj": "^3.0.0",
        "micromatch": "^4.0.2"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "peerDependencies": {
        "@types/express": "^4.17.13"
      },
      "peerDependenciesMeta": {
        "@types/express": {
          "optional": true
        }
      }
    },
    "node_modules/webpack-manifest-plugin": {
      "version": "4.1.1",
      "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz",
web-app/package.json
@@ -9,6 +9,7 @@
    "@testing-library/user-event": "^13.2.1",
    "antd": "4.24.12",
    "axios": "^1.13.4",
    "http-proxy-middleware": "^3.0.5",
    "react": "^19.2.3",
    "react-dom": "^19.2.3",
    "react-router-dom": "^6.22.3",
web-app/src/App.css
@@ -518,6 +518,59 @@
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 状态控制按钮样式(终止/恢复) */
.state-control-btn {
  padding: 10px 24px;
  border: none;
  border-radius: var(--border-radius);
  font-weight: 600;
  font-size: 1rem;
  cursor: pointer;
  transition: all 0.2s ease;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: white;
}
.state-control-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.state-control-btn:active {
  transform: translateY(0);
}
.state-control-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  transform: none;
}
/* 终止按钮 - 红色渐变主题 */
.state-control-btn--terminate {
  background: linear-gradient(135deg, #e63946 0%, #c1121f 100%);
  box-shadow: 0 2px 8px rgba(230, 57, 70, 0.3);
}
.state-control-btn--terminate:hover {
  background: linear-gradient(135deg, #f04a57 0%, #d41926 100%);
  box-shadow: 0 4px 16px rgba(230, 57, 70, 0.4);
}
/* 恢复按钮 - 绿色主题 */
.state-control-btn--resume {
  background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
  box-shadow: 0 2px 8px rgba(82, 196, 26, 0.3);
}
.state-control-btn--resume:hover {
  background: linear-gradient(135deg, #5fd42b 0%, #42b417 100%);
  box-shadow: 0 4px 16px rgba(82, 196, 26, 0.4);
}
/* 人工接管印章效果 */
.takeover-stamp {
  position: relative;
web-app/src/components/call-record/AudioPlayer.css
New file
@@ -0,0 +1,189 @@
/* 录音播放器容器 */
.audio-player {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 20px;
  background: linear-gradient(135deg, #f0f7ff 0%, #e3f2fd 100%);
  border-bottom: 1px solid #e1f5fe;
  flex-shrink: 0;
}
/* 播放器加载中状态 */
.audio-player-loading {
  background: linear-gradient(135deg, #f0f7ff 0%, #e3f2fd 100%);
  border-bottom: 1px solid #e1f5fe;
  justify-content: center;
  padding: 16px 20px;
}
.loading-content {
  display: flex;
  align-items: center;
  gap: 12px;
}
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #d0e8ff;
  border-top-color: #1890ff;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
.loading-text {
  color: #666;
  font-size: 13px;
}
/* 播放器无录音文件状态 */
.audio-player-empty {
  background: linear-gradient(135deg, #f5f5f5 0%, #ebebeb 100%);
  border-bottom: 1px solid #e0e0e0;
  justify-content: center;
  padding: 16px 20px;
}
.empty-content {
  display: flex;
  align-items: center;
  gap: 8px;
}
.empty-icon {
  font-size: 18px;
}
.empty-text {
  color: #999;
  font-size: 13px;
}
/* 播放器错误状态 */
.audio-player-error {
  background: linear-gradient(135deg, #fff2f0 0%, #ffe7e3 100%);
  border-bottom: 1px solid #ffccc7;
  justify-content: center;
  padding: 16px 20px;
}
.audio-player-error .error-text {
  color: #ff4d4f;
  font-size: 13px;
  flex: 1;
}
/* 播放按钮 */
.audio-player .play-btn {
  font-size: 28px;
  color: #1890ff;
  padding: 0;
  transition: all 0.2s ease;
  cursor: pointer;
}
.audio-player .play-btn:hover {
  color: #40a9ff;
  transform: scale(1.1);
}
.audio-player .play-btn:active {
  transform: scale(0.95);
}
/* 进度条容器 */
.audio-player .progress-bar {
  flex: 1;
  height: 8px;
  background: #d0e8ff;
  border-radius: 4px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
}
/* 进度填充 */
.audio-player .progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #1890ff 0%, #40a9ff 100%);
  border-radius: 4px;
  transition: width 0.1s ease;
  position: relative;
}
/* 进度填充上的圆点 */
.audio-player .progress-fill::after {
  content: '';
  position: absolute;
  right: -6px;
  top: 50%;
  transform: translateY(-50%);
  width: 12px;
  height: 12px;
  background: #1890ff;
  border-radius: 50%;
  box-shadow: 0 2px 4px rgba(24, 144, 255, 0.3);
}
/* 时间显示 */
.audio-player .time-display {
  font-size: 12px;
  color: #666;
  min-width: 85px;
  text-align: right;
  font-family: 'Monaco', 'Menlo', monospace;
  background: rgba(255, 255, 255, 0.6);
  padding: 4px 8px;
  border-radius: 4px;
}
/* 下载按钮 */
.audio-player .download-btn {
  font-size: 18px;
  color: #666;
  padding: 4px;
  transition: all 0.2s ease;
  cursor: pointer;
}
.audio-player .download-btn:hover {
  color: #1890ff;
  transform: scale(1.1);
}
.audio-player .download-btn:active {
  transform: scale(0.95);
}
.audio-player .download-btn:disabled {
  color: #ccc;
  cursor: not-allowed;
}
/* 响应式 */
@media (max-width: 480px) {
  .audio-player {
    padding: 12px 16px;
    gap: 10px;
  }
  .audio-player .play-btn {
    font-size: 24px;
  }
  .audio-player .time-display {
    min-width: 70px;
    font-size: 11px;
  }
  .audio-player .download-btn {
    font-size: 16px;
  }
}
web-app/src/components/call-record/AudioPlayer.jsx
New file
@@ -0,0 +1,228 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button } from 'antd';
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined, DownloadOutlined } from '@ant-design/icons';
import './AudioPlayer.css';
/**
 * 录音播放器组件
 * @param {string} recordUrl - 录音文件相对路径(可选)
 * @param {Blob|null} audioBlob - 音频Blob对象(可选)
 * @param {Function} onLoadAudio - 加载音频的函数,返回Promise<Blob>
 * @param {boolean} loading - 是否正在加载音频
 * @param {string} loadingText - 加载中的提示文字
 */
const AudioPlayer = ({
  recordUrl,
  audioBlob,
  onLoadAudio,
  loading = false,
  loadingText = '加载中...'
}) => {
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [loadError, setLoadError] = useState(false);
  const [audioSrc, setAudioSrc] = useState(null);
  const audioRef = useRef(null);
  // 格式化时间显示
  const formatTime = (seconds) => {
    if (!seconds || isNaN(seconds)) return '00:00';
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  };
  // 处理播放/暂停
  const handlePlayPause = () => {
    if (!audioRef.current) return;
    if (isPlaying) {
      audioRef.current.pause();
    } else {
      audioRef.current.play().catch(() => {
        setLoadError(true);
      });
    }
    setIsPlaying(!isPlaying);
  };
  // 处理时间更新
  const handleTimeUpdate = () => {
    if (audioRef.current) {
      setCurrentTime(audioRef.current.currentTime);
    }
  };
  // 处理元数据加载
  const handleLoadedMetadata = () => {
    if (audioRef.current) {
      setDuration(audioRef.current.duration);
      setLoadError(false);
    }
  };
  // 处理加载错误
  const handleError = () => {
    setLoadError(true);
    setIsPlaying(false);
  };
  // 处理播放结束
  const handleEnded = () => {
    setIsPlaying(false);
    setCurrentTime(0);
  };
  // 重试加载
  const handleRetry = () => {
    setLoadError(false);
    if (onLoadAudio) {
      onLoadAudio();
    } else if (audioRef.current && audioSrc) {
      audioRef.current.load();
    }
  };
  // 处理进度条点击
  const handleProgressClick = (e) => {
    if (!audioRef.current || !duration) return;
    const progressBar = e.currentTarget;
    const rect = progressBar.getBoundingClientRect();
    const clickX = e.clientX - rect.left;
    const newTime = (clickX / rect.width) * duration;
    audioRef.current.currentTime = newTime;
    setCurrentTime(newTime);
  };
  // 处理下载音频
  const handleDownload = () => {
    if (!audioBlob) return;
    // 从recordUrl中提取文件名
    let fileName = '录音文件.wav';
    if (recordUrl) {
      const parts = recordUrl.split(/[/\\]/);
      if (parts.length > 0) {
        fileName = parts[parts.length - 1];
        if (!fileName.endsWith('.wav')) {
          fileName += '.wav';
        }
      }
    }
    // 创建下载链接
    const blobUrl = URL.createObjectURL(audioBlob);
    const link = document.createElement('a');
    link.href = blobUrl;
    link.download = fileName;
    link.style.display = 'none';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(blobUrl);
  };
  // 计算进度百分比
  const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
  // 当audioBlob变化时更新音频源
  useEffect(() => {
    if (audioBlob) {
      const url = URL.createObjectURL(audioBlob);
      setAudioSrc(url);
      setLoadError(false);
      return () => URL.revokeObjectURL(url);
    }
  }, [audioBlob]);
  // 加载中状态
  if (loading) {
    return (
      <div className="audio-player audio-player-loading">
        <div className="loading-content">
          <div className="loading-spinner"></div>
          <span className="loading-text">{loadingText}</span>
        </div>
      </div>
    );
  }
  // 无录音文件状态
  if (!recordUrl && !audioSrc) {
    return (
      <div className="audio-player audio-player-empty">
        <div className="empty-content">
          <span className="empty-icon">🎙️</span>
          <span className="empty-text">没有通话录音文件,无法播放</span>
        </div>
      </div>
    );
  }
  // 加载失败状态
  if (loadError) {
    return (
      <div className="audio-player audio-player-error">
        <span className="error-text">录音文件加载失败</span>
        <Button
          type="link"
          icon={<ReloadOutlined />}
          onClick={handleRetry}
        >
          重试
        </Button>
      </div>
    );
  }
  return (
    <div className="audio-player">
      {audioSrc && (
        <audio
          ref={audioRef}
          src={audioSrc}
          onTimeUpdate={handleTimeUpdate}
          onLoadedMetadata={handleLoadedMetadata}
          onError={handleError}
          onEnded={handleEnded}
          preload="metadata"
        />
      )}
      <Button
        type="text"
        className="play-btn"
        icon={isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
        onClick={handlePlayPause}
      />
      <div
        className="progress-bar"
        onClick={handleProgressClick}
      >
        <div
          className="progress-fill"
          style={{ width: `${progressPercent}%` }}
        />
      </div>
      <span className="time-display">
        {formatTime(currentTime)} / {formatTime(duration)}
      </span>
      <Button
        type="text"
        className="download-btn"
        icon={<DownloadOutlined />}
        onClick={handleDownload}
        disabled={!audioBlob}
        title="下载录音"
      />
    </div>
  );
};
export default AudioPlayer;
web-app/src/components/call-record/CallRecordModal.css
New file
@@ -0,0 +1,105 @@
/* 通话记录弹窗样式 */
.call-record-modal .ant-modal {
  border-radius: 12px;
  overflow: hidden;
  max-height: 90vh;
  top: 5vh;
}
.call-record-modal .ant-modal-content {
  border-radius: 12px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
.call-record-modal .ant-modal-header {
  background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
  border-bottom: none;
  padding: 16px 24px;
}
.call-record-modal .ant-modal-title {
  color: white;
  font-size: 16px;
  font-weight: 600;
}
.call-record-modal .ant-modal-close {
  color: rgba(255, 255, 255, 0.85);
}
.call-record-modal .ant-modal-close:hover {
  color: white;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 50%;
}
.call-record-modal .ant-modal-body {
  padding: 0;
  max-height: calc(90vh - 60px);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  height: auto;
}
.call-record-modal .modal-loading {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 300px;
  background: #fafbfc;
}
.call-record-modal .modal-error {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 300px;
  gap: 16px;
  background: #fafbfc;
  padding: 24px;
}
.call-record-modal .modal-error .error-text {
  color: #ff4d4f;
  margin: 0;
  font-size: 14px;
}
.call-record-modal .modal-not-connected {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  min-height: 300px;
  background: #fafbfc;
  color: #999;
  font-size: 14px;
  gap: 8px;
}
.call-record-modal .modal-not-connected::before {
  content: '📞';
  font-size: 48px;
  opacity: 0.3;
}
.call-record-modal .modal-content {
  display: flex;
  flex-direction: column;
  flex: 1;
  width: 100%;
  background: #fff;
  min-height: 0;
  max-height: calc(90vh - 120px);
  box-sizing: border-box;
}
/* 响应式适配 */
@media (max-width: 640px) {
  .call-record-modal .ant-modal {
    max-width: calc(100vw - 32px);
    margin: 16px auto;
  }
}
web-app/src/components/call-record/CallRecordModal.jsx
New file
@@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react';
import { Modal, Spin, Button, message } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import OutboundBotAPIService from '../../services/OutboundBotAPIService';
import AudioPlayer from './AudioPlayer';
import ConversationList from './ConversationList';
import './CallRecordModal.css';
// 已接通状态值(数字格式)
const CONNECTED_STATUSES_NUM = [1, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
// 已接通状态值(字符串格式,如API返回"Succeeded")
const CONNECTED_STATUSES_STR = ['Succeeded', 'succeeded', 'Success', 'success'];
/**
 * 判断是否为已接通状态
 * 兼容数字和字符串两种状态格式
 */
const isConnectedStatus = (callStatus) => {
  if (callStatus === null || callStatus === undefined) return false;
  // 如果是数字
  if (typeof callStatus === 'number') {
    return CONNECTED_STATUSES_NUM.includes(callStatus);
  }
  // 如果是字符串(可能是"Succeeded"等)
  if (typeof callStatus === 'string') {
    return CONNECTED_STATUSES_STR.includes(callStatus);
  }
  return false;
};
/**
 * 获取caseId
 */
const getCaseId = () => {
  // 优先从URL参数获取
  const urlParams = new URLSearchParams(window.location.search);
  const caseIdFromUrl = urlParams.get('caseId');
  if (caseIdFromUrl) return caseIdFromUrl;
  // 从localStorage获取
  try {
    const timelineData = localStorage.getItem('case_data_timeline');
    if (timelineData) {
      const parsed = JSON.parse(timelineData);
      return parsed.case_id || null;
    }
  } catch (e) {
    console.error('解析localStorage数据失败:', e);
  }
  return null;
};
/**
 * 解析conversations JSON字符串
 */
const parseConversations = (conversationsStr) => {
  if (!conversationsStr) return [];
  try {
    return JSON.parse(conversationsStr);
  } catch (e) {
    console.error('解析对话记录失败:', e);
    return [];
  }
};
/**
 * 通话记录弹窗组件
 */
const CallRecordModal = ({ visible, onClose, record }) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [callData, setCallData] = useState(null);
  const [audioBlob, setAudioBlob] = useState(null);
  const [audioLoading, setAudioLoading] = useState(false);
  // 获取通话记录数据
  const fetchCallRecord = async () => {
    if (!record) return;
    setLoading(true);
    setError(null);
    setAudioBlob(null);
    try {
      const caseId = getCaseId();
      if (!caseId) {
        throw new Error('未找到案件ID');
      }
      const params = {
        caseId,
        personId: record.person_id,
        jobId: record.job_id
      };
      const response = await OutboundBotAPIService.getConversationLog(params);
      if (response && response.data && response.data.length > 0) {
        // 取最后一条记录
        const lastRecord = response.data[response.data.length - 1];
        console.log('📞 通话记录数据:', lastRecord);
        console.log('📞 callStatus值:', lastRecord.callStatus, '类型:', typeof lastRecord.callStatus);
        setCallData(lastRecord);
        // 如果有recordUrl,加载音频文件
        const recordUrl = lastRecord.record_url || lastRecord.recordUrl;
        if (recordUrl) {
          loadAudioFile(recordUrl);
        }
      } else {
        throw new Error('未找到通话记录');
      }
    } catch (err) {
      console.error('获取通话记录失败:', err);
      setError(err.message || '获取通话记录失败');
    } finally {
      setLoading(false);
    }
  };
  // 加载音频文件
  const loadAudioFile = async (recordUrl) => {
    setAudioLoading(true);
    try {
      const blob = await OutboundBotAPIService.getAudioFile(recordUrl);
      setAudioBlob(blob);
    } catch (err) {
      console.error('加载音频文件失败:', err);
      message.error('加载录音文件失败');
    } finally {
      setAudioLoading(false);
    }
  };
  // 弹窗打开时加载数据
  useEffect(() => {
    if (visible && record) {
      fetchCallRecord();
    }
  }, [visible, record]);
  // 关闭弹窗时重置状态
  useEffect(() => {
    if (!visible) {
      setCallData(null);
      setError(null);
      setAudioBlob(null);
    }
  }, [visible]);
  // 生成弹窗标题
  const getModalTitle = () => {
    if (!record) return '通话记录';
    const creatorName = record.creator || '当事人';
    return `AI调解员与${creatorName}的通话`;
  };
  // 渲染加载状态
  const renderLoading = () => (
    <div className="modal-loading">
      <Spin tip="加载中..." />
    </div>
  );
  // 渲染错误状态
  const renderError = () => (
    <div className="modal-error">
      <p className="error-text">{error}</p>
      <Button
        type="primary"
        icon={<ReloadOutlined />}
        onClick={fetchCallRecord}
      >
        重试
      </Button>
    </div>
  );
  // 渲染未接通状态
  const renderNotConnected = () => (
    <div className="modal-not-connected">
      <p>未接通,无通话记录</p>
    </div>
  );
  // 渲染通话记录内容
  const renderContent = () => {
    if (!callData) {
      console.log('📞 renderContent: callData为空');
      return renderNotConnected();
    }
    const callStatus = callData.call_status ?? callData.callStatus;
    console.log('📞 renderContent: callStatus=', callStatus, 'isConnected=', isConnectedStatus(callStatus));
    // 未接通状态
    if (!isConnectedStatus(callStatus)) {
      return renderNotConnected();
    }
    // 已接通状态
    const conversations = parseConversations(callData.conversations);
    const recordUrl = callData.record_url || callData.recordUrl;
    const creatorName = record?.creator || '当事人';
    return (
      <div className="modal-content">
        {/* 录音播放器 - 根据recordUrl和audioLoading状态显示 */}
        <AudioPlayer
          recordUrl={recordUrl}
          audioBlob={audioBlob}
          loading={audioLoading}
          loadingText="正在加载录音..."
        />
        {/* 对话记录列表 */}
        <ConversationList
          conversations={conversations}
          contactName={creatorName}
        />
      </div>
    );
  };
  return (
    <Modal
      className="call-record-modal"
      title={getModalTitle()}
      open={visible}
      onCancel={onClose}
      footer={null}
      width={600}
      centered
      destroyOnClose
    >
      {loading && renderLoading()}
      {!loading && error && renderError()}
      {!loading && !error && renderContent()}
    </Modal>
  );
};
export default CallRecordModal;
web-app/src/components/call-record/ConversationList.css
New file
@@ -0,0 +1,189 @@
/* 对话列表容器 */
.conversation-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 16px 20px;
  width: 100%;
  flex: 1;
  overflow-y: auto;
  background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
  box-sizing: border-box;
}
/* 空状态 */
.conversation-empty {
  text-align: center;
  padding: 40px 20px;
  color: #999;
  font-size: 14px;
}
/* 单条对话项 */
.conversation-item {
  display: flex;
  gap: 10px;
  max-width: 100%;
  width: 100%;
  animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
/* AI调解员消息 - 左侧 */
.robot-item {
  align-self: flex-start;
}
/* 当事人消息 - 右侧 */
.contact-item {
  align-self: flex-end;
  flex-direction: row-reverse;
}
/* 头像通用样式 */
.avatar {
  width: 38px;
  height: 38px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  font-weight: 600;
  flex-shrink: 0;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* AI调解员头像 */
.robot-avatar {
  background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
  color: #1976d2;
  border: 2px solid #90caf9;
}
/* 当事人头像 */
.contact-avatar {
  background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
  color: #388e3c;
  border: 2px solid #a5d6a7;
}
/* 消息容器 */
.message-wrapper {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-width: 75%;
  min-width: 200px;
}
/* 消息头部(名字) */
.message-header {
  font-size: 12px;
  color: #666;
  padding: 0 4px;
}
.contact-item .message-header {
  text-align: right;
}
.speaker-name {
  font-weight: 600;
  font-size: 12px;
}
/* 消息气泡 */
.message-bubble {
  padding: 12px 16px;
  border-radius: 16px;
  font-size: 14px;
  line-height: 1.6;
  word-break: break-word;
  position: relative;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
  white-space: pre-wrap;
}
/* AI调解员气泡 */
.robot-bubble {
  background: linear-gradient(135deg, #e3f2fd 0%, #e1f5fe 100%);
  border-top-left-radius: 4px;
  color: #333;
}
/* 当事人气泡 */
.contact-bubble {
  background: linear-gradient(135deg, #e8f5e9 0%, #e8f5e9 100%);
  border-top-right-radius: 4px;
  color: #333;
}
/* 时间戳 */
.message-time {
  font-size: 11px;
  color: #999;
  padding: 2px 4px;
}
.contact-item .message-time {
  text-align: right;
}
/* 滚动条样式 */
.conversation-list::-webkit-scrollbar {
  width: 6px;
}
.conversation-list::-webkit-scrollbar-track {
  background: #f1f1f1;
  border-radius: 3px;
  margin: 4px 0;
}
.conversation-list::-webkit-scrollbar-thumb {
  background: linear-gradient(180deg, #c1c1c1, #a8a8a8);
  border-radius: 3px;
}
.conversation-list::-webkit-scrollbar-thumb:hover {
  background: linear-gradient(180deg, #a8a8a8, #909090);
}
/* 响应式 */
@media (max-width: 480px) {
  .conversation-list {
    padding: 12px;
    max-height: calc(60vh - 120px);
  }
  .conversation-item {
    width: 100%;
  }
  .message-wrapper {
    max-width: 80%;
    min-width: 150px;
  }
  .avatar {
    width: 32px;
    height: 32px;
    font-size: 12px;
  }
  .message-bubble {
    padding: 10px 12px;
    font-size: 13px;
  }
}
web-app/src/components/call-record/ConversationList.jsx
New file
@@ -0,0 +1,88 @@
import React from 'react';
import { RobotOutlined, UserOutlined } from '@ant-design/icons';
import './ConversationList.css';
/**
 * 单条对话组件
 * 展示单条对话消息
 */
const ConversationItem = ({ item, contactName }) => {
  const isRobot = item.speaker === 'Robot';
  // 格式化时间戳
  const formatTimestamp = (timestamp) => {
    if (!timestamp) return '';
    const date = new Date(timestamp);
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    const hours = String(date.getHours()).padStart(2, '0');
    const minutes = String(date.getMinutes()).padStart(2, '0');
    return `${year}-${month}-${day} ${hours}:${minutes}`;
  };
  // 获取头像首字
  const getAvatarText = () => {
    if (isRobot) return <RobotOutlined />;
    return contactName ? contactName.charAt(0) : <UserOutlined />;
  };
  return (
    <div className={`conversation-item ${isRobot ? 'robot-item' : 'contact-item'}`}>
      {/* 头像 */}
      <div className={`avatar ${isRobot ? 'robot-avatar' : 'contact-avatar'}`}>
        {getAvatarText()}
      </div>
      {/* 消息内容 */}
      <div className="message-wrapper">
        <div className="message-header">
          <span className="speaker-name">
            {isRobot ? 'AI调解员' : contactName}
          </span>
        </div>
        <div className={`message-bubble ${isRobot ? 'robot-bubble' : 'contact-bubble'}`}>
          {item.script}
        </div>
        <div className="message-time">
          {formatTimestamp(item.timestamp)}
        </div>
      </div>
    </div>
  );
};
/**
 * 对话记录列表组件
 * 展示双方对话记录,类似微信聊天界面
 */
const ConversationList = ({ conversations, contactName }) => {
  // 过滤无效对话记录
  const validConversations = conversations.filter(
    item => item.script && item.script.trim() !== ''
  );
  if (!validConversations || validConversations.length === 0) {
    return (
      <div className="conversation-empty">
        暂无对话记录
      </div>
    );
  }
  return (
    <div className="conversation-list">
      {validConversations.map((item, index) => (
        <ConversationItem
          key={index}
          item={item}
          contactName={contactName}
        />
      ))}
    </div>
  );
};
export default ConversationList;
web-app/src/components/call-record/index.js
New file
@@ -0,0 +1,3 @@
export { default as CallRecordModal } from './CallRecordModal';
export { default as AudioPlayer } from './AudioPlayer';
export { default as ConversationList } from './ConversationList';
web-app/src/components/common/OutboundCallWidget.jsx
@@ -357,10 +357,22 @@
    };
    window.addEventListener('outbound-jobs-updated', handleOutboundJobsUpdated);
    
    // 监听调解终止事件(关闭外呼气泡)
    const handleMediationTerminated = () => {
      console.log('收到调解终止事件,关闭外呼气泡');
      setIsVisible(false);
      setIsMinimized(true);
      // 清空localStorage中的外呼任务
      localStorage.removeItem(OUTBOUND_JOBS_KEY);
      setCalls([]);
    };
    window.addEventListener('mediation-terminated', handleMediationTerminated);
    // 清理函数
    return () => {
      clearInterval(interval);
      window.removeEventListener('outbound-jobs-updated', handleOutboundJobsUpdated);
      window.removeEventListener('mediation-terminated', handleMediationTerminated);
      isMountedRef.current = false;
    };
  }, [fetchCallStatus]);
web-app/src/components/dashboard/FloatingControlPanel.jsx
@@ -1,14 +1,17 @@
import React, { useState } from 'react';
import { Modal, message } from 'antd';
import { Modal, message, Input } 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 { TextArea } = Input;
// 终态状态(不显示人工接管按钮)
const TERMINAL_STATES = [2, 3]; // 调解成功、调解失败
const TAKEOVER_STATE = 4; // 人工接管
const PAUSED_STATE = 5; // 已终止/暂停状态
/**
 * 获取案件ID
@@ -89,6 +92,12 @@
  const { caseData, taskStartTime, isTaskTimeFallback, refreshData } = useCaseData();
  const [takeoverLoading, setTakeoverLoading] = useState(false);
  const [confirmVisible, setConfirmVisible] = useState(false);
  // 状态控制相关状态
  const [controlLoading, setControlLoading] = useState(false);
  const [controlConfirmVisible, setControlConfirmVisible] = useState(false);
  const [controlAction, setControlAction] = useState(null); // 'terminate' or 'resume'
  const [remark, setRemark] = useState('');
  const timeline = caseData || {};
  const state = timeline.mediation?.state;
@@ -98,9 +107,113 @@
  const { formattedTime } = useTaskTimer(taskStartTime, isTaskTimeFallback);
  // 生成状态文本
  const statusText = state === 1
    ? `调解进行中-阶段${orderNo}:${nodeName}`
    : (translateMediationState(state) || '调解进行中');
  const getStatusText = () => {
    const stateNum = Number(state);
    if (stateNum === 1) {
      return `调解进行中-阶段${orderNo}:${nodeName}`;
    }
    return translateMediationState(state) || '调解进行中';
  };
  const statusText = getStatusText();
  // ==================== 状态控制按钮逻辑 ====================
  /**
   * 判断是否显示状态控制按钮
   * 状态为0(未开始)、1(进行中)、5(已终止)时显示
   */
  const shouldShowControlButton = () => {
    const stateNum = Number(state);
    // 进行中(1)显示终止按钮,已终止(5)显示恢复按钮
    return stateNum === 1 || stateNum === 5;
  };
  /**
   * 获取状态控制按钮属性
   */
  const getControlButtonProps = () => {
    const stateNum = Number(state);
    if (stateNum === 1) {
      return {
        text: '终止',
        style: 'terminate',
        action: 'terminate'
      };
    } else if (stateNum === 5) {
      return {
        text: '恢复',
        style: 'resume',
        action: 'resume'
      };
    }
    return null;
  };
  /**
   * 处理状态控制按钮点击
   */
  const handleControlButtonClick = () => {
    const buttonProps = getControlButtonProps();
    if (!buttonProps) return;
    setControlAction(buttonProps.action);
    setControlConfirmVisible(true);
  };
  /**
   * 处理状态控制确认
   */
  const handleControlConfirmOk = async () => {
    if (!controlAction) return;
    setControlLoading(true);
    try {
      const params = getMergedParams();
      const actionCode = controlAction === 'terminate' ? 0 : 1;
      if (!params.caseId) {
        throw new Error('案件ID不能为空');
      }
      await ProcessAPIService.updateMediationState(params.caseId, {
        action: actionCode,
        userName: localStorage.getItem('userName') || '调解员',
        remark: remark || ''
      });
      message.success(controlAction === 'terminate' ? '调解已终止' : '调解已恢复');
      setControlConfirmVisible(false);
      setRemark('');
      setControlAction(null);
      // 如果是终止操作,触发事件关闭外呼气泡
      if (controlAction === 'terminate') {
        window.dispatchEvent(new CustomEvent('mediation-terminated'));
        console.log('调解终止,触发外呼气泡关闭事件');
      }
      refreshData();
    } catch (error) {
      console.error('状态更新失败:', error);
      message.error(error.message || '状态更新失败,请稍后重试');
    } finally {
      setControlLoading(false);
    }
  };
  /**
   * 处理状态控制取消
   */
  const handleControlConfirmCancel = () => {
    setControlConfirmVisible(false);
    setRemark('');
    setControlAction(null);
  };
  // ==================== 人工接管逻辑 ====================
  /**
   * 处理接管API调用
@@ -178,16 +291,59 @@
  };
  /**
   * 渲染状态控制按钮(终止/恢复)
   */
  const renderStateControlButton = () => {
    if (!shouldShowControlButton()) return null;
    const buttonProps = getControlButtonProps();
    if (!buttonProps) return null;
    const isTerminate = buttonProps.style === 'terminate';
    return (
      <button
        className={`state-control-btn ${isTerminate ? 'state-control-btn--terminate' : 'state-control-btn--resume'}`}
        onClick={handleControlButtonClick}
        disabled={controlLoading}
      >
        {controlLoading ? (
          <><i className="fas fa-spinner fa-spin"></i>处理中...</>
        ) : (
          <><i className={isTerminate ? "fas fa-pause-circle" : "fas fa-play-circle"}></i>{buttonProps.text}</>
        )}
      </button>
    );
  };
  /**
   * 渲染控制区域(按钮或印章)
   */
  const renderControlAction = () => {
    const stateNum = Number(state);
    // 终态状态(调解成功/失败/人工接管):显示印章
    if (TERMINAL_STATES.includes(state) || state === TAKEOVER_STATE) {
      return <TakeoverStamp state={state} />;
    if (TERMINAL_STATES.includes(stateNum) || stateNum === TAKEOVER_STATE) {
      return <TakeoverStamp state={stateNum} />;
    }
    // 调解中:显示接管按钮
    return <TakeoverButton loading={takeoverLoading} onClick={handleTakeover} />;
    // 已终止/暂停状态(5):显示恢复按钮和人工接管按钮
    if (stateNum === PAUSED_STATE) {
      return (
        <>
          {renderStateControlButton()}
          <TakeoverButton loading={takeoverLoading} onClick={handleTakeover} />
        </>
      );
    }
    // 调解中(1):显示终止按钮和人工接管按钮
    return (
      <>
        {renderStateControlButton()}
        <TakeoverButton loading={takeoverLoading} onClick={handleTakeover} />
      </>
    );
  };
  return (
@@ -195,7 +351,7 @@
      <div className="floating-control-panel">
        <div className="control-status">
          <div className="status-indicator">
            <span className="status-dot"></span>
            <span className="status-dot" style={Number(state) === 5 ? { background: '#e63946' } : {}}></span>
            <span className="status-text">
              {statusText}{' '}
              <span style={{ color: 'gray' }}>
@@ -211,6 +367,34 @@
        </div>
      </div>
      {/* 状态控制确认对话框(终止/恢复) */}
      <Modal
        title={controlAction === 'terminate' ? '确认终止调解' : '确认恢复调解'}
        visible={controlConfirmVisible}
        onOk={handleControlConfirmOk}
        onCancel={handleControlConfirmCancel}
        okText="确定"
        cancelText="取消"
        confirmLoading={controlLoading}
      >
        <p>
          {controlAction === 'terminate'
            ? '确定要终止当前AI调解流程吗?终止后调解将暂停,可在适当时机恢复。'
            : '确定要恢复AI调解流程吗?恢复后将从当前位置继续调解。'}
        </p>
        <div style={{ marginTop: 15 }}>
          <label style={{ display: 'block', marginBottom: 5, fontWeight: 500 }}>
            备注(可选):
          </label>
          <TextArea
            value={remark}
            onChange={(e) => setRemark(e.target.value)}
            placeholder="请输入操作备注..."
            rows={3}
          />
        </div>
      </Modal>
      {/* 人工接管确认对话框 */}
      <Modal
        title="人工接管确认"
web-app/src/components/dashboard/TabContainer.jsx
@@ -1,4 +1,4 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useCaseData } from '../../contexts/CaseDataContext';
import { formatDuration, formatSuccessRate, formatRoundCount } from '../../utils/stateTranslator';
import ProcessAPIService from '../../services/ProcessAPIService';
@@ -6,6 +6,8 @@
import MediationAgreementAPIService from '../../services/MediationAgreementAPIService';
import { getMergedParams } from '../../utils/urlParams';
import { message, Spin, Tag, Modal, Button, Input, Image } from 'antd';
import { PhoneOutlined } from '@ant-design/icons';
import { CallRecordModal } from '../call-record';
const { TextArea } = Input;
@@ -153,17 +155,36 @@
  const [records, setRecords] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // 通话记录弹窗状态
  const [callRecordVisible, setCallRecordVisible] = useState(false);
  const [currentRecord, setCurrentRecord] = useState(null);
  
  // 获取案件数据
  const { caseData } = useCaseData();
  const timeline = caseData || {};
  const caseState = timeline.mediation?.state;
  // 格式化时间戳为 YYYY-MM-DD HH:MM:SS
  const formatTimestamp = (timestamp) => {
    if (!timestamp) return '';
    const date = new Date(timestamp);
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    const hours = String(date.getHours()).padStart(2, '0');
    const minutes = String(date.getMinutes()).padStart(2, '0');
    const seconds = String(date.getSeconds()).padStart(2, '0');
    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
  };
  // person_type到avatar类型的映射
  // 1: 申请人, 2: 被申请人, 3: AI调解员, 4: 调解员
  const getAvatarType = (personType) => {
    const typeMap = {
      '1': 'ai',
      '2': 'applicant',
      '3': 'respondent',
      '1': 'applicant',
      '2': 'respondent',
      '3': 'ai',
      '4': 'mediator'
    };
    return typeMap[personType] || 'ai';
@@ -181,23 +202,25 @@
  };
  
  // 获取角色显示名称
  // 1: 申请人, 2: 被申请人, 3: AI调解员, 4: 调解员
  const getRoleDisplayName = (personType, creatorName) => {
    const roleMap = {
      '1': 'AI调解员',
      '2': `申请人(${creatorName})`,
      '3': `被申请人(${creatorName})`,
      '1': `申请人(${creatorName})`,
      '2': `被申请人(${creatorName})`,
      '3': 'AI调解员',
      '4': `调解员(${creatorName})`
    };
    return roleMap[personType] || creatorName;
  };
  
  // 数据格式化函数
  // 数据格式化函数(保留原始数据字段用于通话记录功能)
  const formatRecordData = (apiRecords) => {
    return apiRecords.map(record => ({
      ...record, // 保留原始数据字段(person_id, job_id, creator等)
      avatar: getAvatarType(record.person_type),
      name: getRoleDisplayName(record.person_type, record.creator),
      avatarText: record.creator?.charAt(0) || '',  // 头像显示名字第一个字
      time: record.create_time,
      avatarText: record.creator?.charAt(0) || '',
      time: formatTimestamp(record.create_time),
      content: record.result,
      tags: record.tagList?.map(tag => ({
        text: tag.tag_name,
@@ -393,7 +416,40 @@
                {getAvatarContent(item.avatar, item.avatarText)}
              </div>
              <div className="item-source">
                <div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--dark-color)', marginBottom: 2 }}>{item.name}</div>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}>
                  <span style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--dark-color)' }}>{item.name}</span>
                  {item.avatar !== 'ai' && item.avatar !== 'mediator' && (
                    <span
                      className="call-record-btn"
                      style={{
                        display: 'inline-flex',
                        alignItems: 'center',
                        gap: 4,
                        padding: '2px 8px',
                        fontSize: '0.75rem',
                        background: '#e3f2fd',
                        color: '#1890ff',
                        borderRadius: 12,
                        cursor: 'pointer',
                        transition: 'all 0.2s'
                      }}
                      onClick={(e) => {
                        e.stopPropagation();
                        setCurrentRecord(item);
                        setCallRecordVisible(true);
                      }}
                      onMouseEnter={(e) => {
                        e.target.style.background = '#bbdefb';
                      }}
                      onMouseLeave={(e) => {
                        e.target.style.background = '#e3f2fd';
                      }}
                    >
                      <PhoneOutlined style={{ fontSize: 12 }} />
                      通话记录
                    </span>
                  )}
                </div>
                <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)', display: 'flex', alignItems: 'center', gap: 6 }}>
                  <i className="far fa-clock"></i>
                  <span>{item.time}</span>
@@ -439,6 +495,13 @@
          </div>
        ))}
      </div>
      {/* 通话记录弹窗 */}
      <CallRecordModal
        visible={callRecordVisible}
        onClose={() => setCallRecordVisible(false)}
        record={currentRecord}
      />
    </>
  );
};
@@ -1658,3 +1721,10 @@
};
export default TabContainer;
web-app/src/contexts/CaseDataContext.jsx
@@ -369,20 +369,6 @@
        return;
      }
      EvidenceAPIService.processCaseFilesOcr(params.caseId).catch((ocrError) => {
        console.error('触发案件文件OCR失败:', ocrError);
      });
      try {
        await OutboundBotAPIService.syncStatusByCase({ caseId: params.caseId });
      } catch (syncError) {
        console.error('同步外呼状态失败:', syncError);
      }
      try {
        await OutboundBotAPIService.backfillConversationByCase({ caseId: params.caseId });
      } catch (backfillError) {
        console.error('回补通话记录失败:', backfillError);
      }
      // 调用API获取数据
      // 将URL中的auth_token转换为authorization传入API
@@ -397,6 +383,14 @@
      // 提取timeline数据
      const timelineData = response.timeline || response.data?.timeline || response;
      // 调试日志:输出提取的timeline数据
      console.log('===== API数据提取 =====');
      console.log('原始response:', response);
      console.log('提取的timelineData:', timelineData);
      console.log('timelineData.mediation:', timelineData.mediation);
      console.log('timelineData.mediation?.state:', timelineData.mediation?.state);
      console.log('========================');
      
      // 提取nodes数据(确保为数组),兼容 nodeList 和 nodes 两种字段名
      const nodesData = response.data?.nodeList || response.data?.nodes || response.nodes || [];
@@ -420,7 +414,7 @@
      
      // 检查终态状态(调解成功/失败/人工接管),终态不执行外呼和存储
      const mediationState = timelineData.mediation?.state;
      const isTerminalState = [2, 3, 4].includes(mediationState);
      const isTerminalState = [2, 3, 4, 5].includes(mediationState);
      
      if (isTerminalState) {
        console.log('案件已处于终态状态:', mediationState, ',跳过外呼和存储');
@@ -437,15 +431,34 @@
      // 加载任务时间数据
      await loadTaskTime(timelineData);
       EvidenceAPIService.processCaseFilesOcr(params.caseId).catch((ocrError) => {
        console.error('触发案件文件OCR失败:', ocrError);
      });
      try {
        await OutboundBotAPIService.syncStatusByCase({ caseId: params.caseId });
      } catch (syncError) {
        console.error('同步外呼状态失败:', syncError);
      }
      try {
        await OutboundBotAPIService.backfillConversationByCase({ caseId: params.caseId });
      } catch (backfillError) {
        console.error('回补通话记录失败:', backfillError);
      }
      console.log('Case data loaded successfully:', timelineData);
    } catch (err) {
      console.error('Failed to load case data:', err);
      setError(err.message || '加载案件数据失败');
      
      // 显示错误提示
      message.error('加载案件数据失败,请稍后重试');
      message.error('加载案件数据失败,使用模拟数据');
      
      // 使用Mock数据(缓存数据不包含nodes,所以统一使用Mock)
      // 使用Mock数据作为降级方案
      const mockTimeline = mockTimelineData.data?.timeline || mockTimelineData;
      console.log('使用Mock数据降级:', mockTimeline);
      setCaseData(mockTimeline);
      setProcessNodes(mockTimelineData.data?.nodes || []);
      setHasLoaded(true);
      
    } finally {
web-app/src/services/OutboundBotAPIService.js
@@ -27,8 +27,8 @@
   * 获取通话录音接口
   * GET /api/v1/outbound-bot/conversation-log
   * @param {Object} params - 查询参数
   * @param {string} params.caseRef - 案件引用ID
   * @param {string} params.phoneNumber - 电话号码
   * @param {string} params.caseId - 案件ID
   * @param {string} params.personId - 当事人ID
   * @param {string} params.jobId - 工作ID
   * @returns {Promise} 通话记录
   */
@@ -68,6 +68,32 @@
  static backfillConversationByCase(data) {
    return request.post('/api/v1/outbound-bot/backfill-conversation-by-case', data);
  }
  /**
   * 获取通话录音文件
   * GET /api/v1/outbound-bot/file-play
   * @param {string} recordUrl - 录音文件相对路径
   * @returns {Promise} 音频文件Blob
   */
  static async getAudioFile(recordUrl) {
    try {
      const response = await request.get('/api/v1/outbound-bot/file-play', {
        recordUrl
      }, {
        responseType: 'blob',
        headers: {
          'Content-Type': 'audio/wav',
          'Content-Disposition': 'inline'
        }
      });
      // axios返回的是response对象,blob在response.data中
      return response.data;
    } catch (error) {
      console.error('获取录音文件失败:', error);
      throw error;
    }
  }
}
export default OutboundBotAPIService;
web-app/src/services/ProcessAPIService.js
@@ -143,6 +143,26 @@
    return request.put(`/api/v1/mediation-timeline/v2/case/${caseId}/takeover`, data);
  }
  /**
   * AI调解状态控制API(终止/恢复)
   * PUT /api/v1/mediation-timeline/v2/case/{caseId}/state
   * @param {string} caseId - 案件ID
   * @param {Object} data - 请求数据
   * @param {number} data.action - 操作类型:0-终止,1-恢复
   * @param {string} data.userName - 操作人姓名(可选)
   * @returns {Promise} 状态更新结果
   *
   * @example
   * // 终止调解
   * ProcessAPIService.updateMediationState('1001', { action: 0, userName: '张三' });
   *
   * // 恢复调解
   * ProcessAPIService.updateMediationState('1001', { action: 1, userName: '李四' });
   */
  static updateMediationState(caseId, data) {
    return request.put(`/api/v1/mediation-timeline/v2/case/${caseId}/state`, data);
  }
}
export default ProcessAPIService;
web-app/src/services/request.js
@@ -8,6 +8,7 @@
import config from '../config/env';
// 创建 axios 实例
console.log('🔧 Current environment config:', config);
const service = axios.create({
  baseURL: config.baseURL,
  timeout: config.timeout,
web-app/src/setupProxy.js
New file
@@ -0,0 +1,14 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
  // API代理 - 将 /api 请求转发到后端服务
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:9015',
      changeOrigin: true,
      secure: false,
      logLevel: 'debug'
    })
  );
};
web-app/src/utils/stateTranslator.js
@@ -14,9 +14,10 @@
    1: '调解中',
    2: '调解成功',
    3: '调解失败',
    4: '人工接管'
    4: '人工接管',
    5: 'AI调解已暂停'
  };
  return stateMap[state] || '未知状态';
};
web-app/yarn.lock
@@ -2019,7 +2019,7 @@
  resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz"
  integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==
"@types/http-proxy@^1.17.8":
"@types/http-proxy@^1.17.15", "@types/http-proxy@^1.17.8":
  version "1.17.17"
  resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz"
  integrity sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==
@@ -3782,7 +3782,7 @@
  dependencies:
    ms "^2.1.1"
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.3, debug@4:
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.6, debug@^4.4.3, debug@4:
  version "4.4.3"
  resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
  integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -5255,7 +5255,7 @@
http-proxy-middleware@^2.0.3:
  version "2.0.9"
  resolved "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz"
  resolved "https://registry.npmmirror.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz"
  integrity sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==
  dependencies:
    "@types/http-proxy" "^1.17.8"
@@ -5263,6 +5263,18 @@
    is-glob "^4.0.1"
    is-plain-obj "^3.0.0"
    micromatch "^4.0.2"
http-proxy-middleware@^3.0.5:
  version "3.0.5"
  resolved "https://registry.npmmirror.com/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz"
  integrity sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==
  dependencies:
    "@types/http-proxy" "^1.17.15"
    debug "^4.3.6"
    http-proxy "^1.18.1"
    is-glob "^4.0.3"
    is-plain-object "^5.0.0"
    micromatch "^4.0.8"
http-proxy@^1.18.1:
  version "1.18.1"
@@ -5551,9 +5563,14 @@
is-plain-obj@^3.0.0:
  version "3.0.0"
  resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz"
  resolved "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz"
  integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==
is-plain-object@^5.0.0:
  version "5.0.0"
  resolved "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz"
  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
is-potential-custom-element-name@^1.0.1:
  version "1.0.1"
  resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz"