From 6bb08c2297be1b6415c8bc02e6917eba6ee355e5 Mon Sep 17 00:00:00 2001
From: shimai <shimai@example.com>
Date: Fri, 03 Apr 2026 10:42:08 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/test/tony.cheng/260312' into test/shimai.huang/260309
---
web-app/src/components/call-record/index.js | 3
web-app/src/components/call-record/ConversationList.css | 189 ++++
openspec/changes/implement-mediation-state-control/tasks.md | 55 +
web-app/src/components/call-record/CallRecordModal.jsx | 243 +++++
web-app/src/services/OutboundBotAPIService.js | 30
web-app/src/services/ProcessAPIService.js | 20
openspec/changes/implement-mediation-state-control/specs/mediation-state-control/spec.md | 134 +++
web-app/src/components/call-record/AudioPlayer.css | 189 ++++
web-app/src/components/call-record/ConversationList.jsx | 88 ++
web-app/src/App.css | 53 +
web-app/src/setupProxy.js | 14
web-app/src/components/call-record/CallRecordModal.css | 105 ++
web-app/src/components/dashboard/FloatingControlPanel.jsx | 202 ++++
web-app/yarn.lock | 25
openspec/changes/implement-mediation-state-control/design.md | 143 +++
openspec/changes/implement-mediation-state-control/proposal.md | 58 +
openspec/changes/add-call-record-viewer/tasks.md | 107 ++
web-app/src/contexts/CaseDataContext.jsx | 47
openspec/changes/add-call-record-viewer/proposal.md | 78 +
web-app/src/components/call-record/AudioPlayer.jsx | 228 +++++
web-app/src/components/common/OutboundCallWidget.jsx | 12
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/dashboard/TabContainer.jsx | 94 +
web-app/src/services/request.js | 1
web-app/src/utils/stateTranslator.js | 5
web-app/package-lock.json | 61 +
web-app/package.json | 1
28 files changed, 2,443 insertions(+), 63 deletions(-)
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/openspec/changes/implement-mediation-state-control/design.md b/openspec/changes/implement-mediation-state-control/design.md
new file mode 100644
index 0000000..b70d824
--- /dev/null
+++ b/openspec/changes/implement-mediation-state-control/design.md
@@ -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调用成功/失败场景测试
+- 状态转换的正确性验证
+
+### 集成测试
+- 完整的用户操作流程测试
+- 与现有功能的兼容性测试
+- 异常场景的恢复能力测试
+
+### 用户验收测试
+- 实际调解场景下的功能验证
+- 易用性和直观性评估
+- 性能影响评估
\ No newline at end of file
diff --git a/openspec/changes/implement-mediation-state-control/proposal.md b/openspec/changes/implement-mediation-state-control/proposal.md
new file mode 100644
index 0000000..93499b3
--- /dev/null
+++ b/openspec/changes/implement-mediation-state-control/proposal.md
@@ -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. **自动状态检测和恢复**:缺乏人工控制灵活性,可能在不适当的时候触发状态变更
+
+选择在现有界面中直接添加按钮的方式,既保持了界面的一致性,又提供了直观的操作方式。
\ No newline at end of file
diff --git a/openspec/changes/implement-mediation-state-control/specs/mediation-state-control/spec.md b/openspec/changes/implement-mediation-state-control/specs/mediation-state-control/spec.md
new file mode 100644
index 0000000..003d038
--- /dev/null
+++ b/openspec/changes/implement-mediation-state-control/specs/mediation-state-control/spec.md
@@ -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)
\ No newline at end of file
diff --git a/openspec/changes/implement-mediation-state-control/tasks.md b/openspec/changes/implement-mediation-state-control/tasks.md
new file mode 100644
index 0000000..d02cf97
--- /dev/null
+++ b/openspec/changes/implement-mediation-state-control/tasks.md
@@ -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检查
+- 功能在本地开发环境中正常工作
+- 不引入新的编译警告或错误
+- 符合现有的代码风格和架构模式
\ No newline at end of file
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/App.css b/web-app/src/App.css
index 5b1204c..84b67ec 100644
--- a/web-app/src/App.css
+++ b/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;
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/common/OutboundCallWidget.jsx b/web-app/src/components/common/OutboundCallWidget.jsx
index 2fe026b..9bead7c 100644
--- a/web-app/src/components/common/OutboundCallWidget.jsx
+++ b/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]);
diff --git a/web-app/src/components/dashboard/FloatingControlPanel.jsx b/web-app/src/components/dashboard/FloatingControlPanel.jsx
index a0f75c3..c3f5001 100644
--- a/web-app/src/components/dashboard/FloatingControlPanel.jsx
+++ b/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="人工接管确认"
diff --git a/web-app/src/components/dashboard/TabContainer.jsx b/web-app/src/components/dashboard/TabContainer.jsx
index 3e0a166..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,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;
+
+
+
+
+
+
+
diff --git a/web-app/src/contexts/CaseDataContext.jsx b/web-app/src/contexts/CaseDataContext.jsx
index e64fbfa..8987d20 100644
--- a/web-app/src/contexts/CaseDataContext.jsx
+++ b/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 {
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/ProcessAPIService.js b/web-app/src/services/ProcessAPIService.js
index 7b1b67e..bad9165 100644
--- a/web-app/src/services/ProcessAPIService.js
+++ b/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;
\ No newline at end of file
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/src/utils/stateTranslator.js b/web-app/src/utils/stateTranslator.js
index b3550b5..ebda6b2 100644
--- a/web-app/src/utils/stateTranslator.js
+++ b/web-app/src/utils/stateTranslator.js
@@ -14,9 +14,10 @@
1: '调解中',
2: '调解成功',
3: '调解失败',
- 4: '人工接管'
+ 4: '人工接管',
+ 5: 'AI调解已暂停'
};
-
+
return stateMap[state] || '未知状态';
};
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