From 85f3f863950fa1d807697da437591062868e782c Mon Sep 17 00:00:00 2001
From: chengmw <chengmingwei_1984122@126.com>
Date: Thu, 26 Mar 2026 17:27:54 +0800
Subject: [PATCH] feat: 添加通话记录查看器功能模块

---
 web-app/src/setupProxy.js                                                |   14 
 web-app/src/components/call-record/CallRecordModal.css                   |  105 +++
 web-app/src/components/call-record/index.js                              |    3 
 web-app/yarn.lock                                                        |   25 
 web-app/src/components/call-record/ConversationList.css                  |  189 ++++++
 openspec/changes/add-call-record-viewer/tasks.md                         |  107 +++
 openspec/changes/add-call-record-viewer/proposal.md                      |   78 ++
 web-app/src/components/call-record/AudioPlayer.jsx                       |  228 +++++++
 openspec/changes/add-call-record-viewer/specs/call-record-viewer/spec.md |  123 ++++
 openspec/changes/add-call-record-viewer/design.md                        |  198 ++++++
 web-app/src/components/call-record/CallRecordModal.jsx                   |  243 ++++++++
 web-app/src/services/OutboundBotAPIService.js                            |   30 
 web-app/src/components/dashboard/TabContainer.jsx                        |   93 ++
 web-app/src/services/request.js                                          |    1 
 web-app/src/components/call-record/AudioPlayer.css                       |  189 ++++++
 web-app/package-lock.json                                                |   61 +
 web-app/package.json                                                     |    1 
 web-app/src/components/call-record/ConversationList.jsx                  |   88 ++
 document/原型/index.html                                                   |    4 
 19 files changed, 1,741 insertions(+), 39 deletions(-)

diff --git "a/document/\345\216\237\345\236\213/index.html" "b/document/\345\216\237\345\236\213/index.html"
index 8260512..39f6197 100644
--- "a/document/\345\216\237\345\236\213/index.html"
+++ "b/document/\345\216\237\345\236\213/index.html"
@@ -1997,10 +1997,6 @@
 					<i class="fas fa-user-tie"></i>
 					人工接管
 				</button>
-				<button class="floating-control-btn" id="floatingStopBtn">
-					<i class="fas fa-user-tie"></i>
-					人工接管
-				</button>
 			</div>
 		</div>
 
diff --git a/openspec/changes/add-call-record-viewer/design.md b/openspec/changes/add-call-record-viewer/design.md
new file mode 100644
index 0000000..9b39062
--- /dev/null
+++ b/openspec/changes/add-call-record-viewer/design.md
@@ -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                                │
+│  └──────┘                                                   │
+│                                                              │
+└─────────────────────────────────────────────────────────────┘
+```
diff --git a/openspec/changes/add-call-record-viewer/proposal.md b/openspec/changes/add-call-record-viewer/proposal.md
new file mode 100644
index 0000000..a3a9968
--- /dev/null
+++ b/openspec/changes/add-call-record-viewer/proposal.md
@@ -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. **仅在已接通记录显示按钮**:未接通状态用户无法了解情况,信息不完整
+
+选择在调解记录卡片内直接查看的方式,保持用户操作的连贯性和上下文关联。
diff --git a/openspec/changes/add-call-record-viewer/specs/call-record-viewer/spec.md b/openspec/changes/add-call-record-viewer/specs/call-record-viewer/spec.md
new file mode 100644
index 0000000..cdbbd29
--- /dev/null
+++ b/openspec/changes/add-call-record-viewer/specs/call-record-viewer/spec.md
@@ -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)
diff --git a/openspec/changes/add-call-record-viewer/tasks.md b/openspec/changes/add-call-record-viewer/tasks.md
new file mode 100644
index 0000000..7f50128
--- /dev/null
+++ b/openspec/changes/add-call-record-viewer/tasks.md
@@ -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样式与系统整体风格一致
diff --git a/web-app/package-lock.json b/web-app/package-lock.json
index f30d19f..fe7f58b 100644
--- a/web-app/package-lock.json
+++ b/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",
diff --git a/web-app/package.json b/web-app/package.json
index 10eb0a2..bf6b6e2 100644
--- a/web-app/package.json
+++ b/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",
diff --git a/web-app/src/components/call-record/AudioPlayer.css b/web-app/src/components/call-record/AudioPlayer.css
new file mode 100644
index 0000000..70b30a9
--- /dev/null
+++ b/web-app/src/components/call-record/AudioPlayer.css
@@ -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;
+  }
+}
diff --git a/web-app/src/components/call-record/AudioPlayer.jsx b/web-app/src/components/call-record/AudioPlayer.jsx
new file mode 100644
index 0000000..f70271d
--- /dev/null
+++ b/web-app/src/components/call-record/AudioPlayer.jsx
@@ -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;
diff --git a/web-app/src/components/call-record/CallRecordModal.css b/web-app/src/components/call-record/CallRecordModal.css
new file mode 100644
index 0000000..299dcf0
--- /dev/null
+++ b/web-app/src/components/call-record/CallRecordModal.css
@@ -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;
+  }
+}
diff --git a/web-app/src/components/call-record/CallRecordModal.jsx b/web-app/src/components/call-record/CallRecordModal.jsx
new file mode 100644
index 0000000..aaad755
--- /dev/null
+++ b/web-app/src/components/call-record/CallRecordModal.jsx
@@ -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;
diff --git a/web-app/src/components/call-record/ConversationList.css b/web-app/src/components/call-record/ConversationList.css
new file mode 100644
index 0000000..2ce56f3
--- /dev/null
+++ b/web-app/src/components/call-record/ConversationList.css
@@ -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;
+  }
+}
diff --git a/web-app/src/components/call-record/ConversationList.jsx b/web-app/src/components/call-record/ConversationList.jsx
new file mode 100644
index 0000000..826c32d
--- /dev/null
+++ b/web-app/src/components/call-record/ConversationList.jsx
@@ -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;
diff --git a/web-app/src/components/call-record/index.js b/web-app/src/components/call-record/index.js
new file mode 100644
index 0000000..88878ea
--- /dev/null
+++ b/web-app/src/components/call-record/index.js
@@ -0,0 +1,3 @@
+export { default as CallRecordModal } from './CallRecordModal';
+export { default as AudioPlayer } from './AudioPlayer';
+export { default as ConversationList } from './ConversationList';
diff --git a/web-app/src/components/dashboard/TabContainer.jsx b/web-app/src/components/dashboard/TabContainer.jsx
index e732c80..ca215e3 100644
--- a/web-app/src/components/dashboard/TabContainer.jsx
+++ b/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,18 +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';
@@ -182,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,
@@ -394,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>
@@ -440,6 +495,13 @@
           </div>
         ))}
       </div>
+      
+      {/* 通话记录弹窗 */}
+      <CallRecordModal
+        visible={callRecordVisible}
+        onClose={() => setCallRecordVisible(false)}
+        record={currentRecord}
+      />
     </>
   );
 };
@@ -1659,3 +1721,10 @@
 };
 
 export default TabContainer;
+
+
+
+
+
+
+
diff --git a/web-app/src/services/OutboundBotAPIService.js b/web-app/src/services/OutboundBotAPIService.js
index dcc64ca..b181d6b 100644
--- a/web-app/src/services/OutboundBotAPIService.js
+++ b/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;
diff --git a/web-app/src/services/request.js b/web-app/src/services/request.js
index 7e335f7..7420171 100644
--- a/web-app/src/services/request.js
+++ b/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,
diff --git a/web-app/src/setupProxy.js b/web-app/src/setupProxy.js
new file mode 100644
index 0000000..2e9d46b
--- /dev/null
+++ b/web-app/src/setupProxy.js
@@ -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'
+    })
+  );
+};
diff --git a/web-app/yarn.lock b/web-app/yarn.lock
index c083445..4129b22 100644
--- a/web-app/yarn.lock
+++ b/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"

--
Gitblit v1.8.0